From d1f6908b69d114b99afce7be4b6a2c06d6fbcff9 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Mon, 1 May 2023 15:14:50 -0700 Subject: [PATCH 1/2] Disable missed meal detection if there are user-entered and/or calibration points --- Loop/Managers/LoopDataManager.swift | 33 +++++++++++++------ .../MealDetectionManager.swift | 24 ++++++++++++-- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 18a0816621..aedce6c2aa 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -874,8 +874,8 @@ extension LoopDataManager { logger.default("Loop ended") notify(forChange: .loopFinished) - let carbEffectStart = now().addingTimeInterval(-MissedMealSettings.maxRecency) - carbStore.getGlucoseEffects(start: carbEffectStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in + let samplesStart = now().addingTimeInterval(-MissedMealSettings.maxRecency) + carbStore.getGlucoseEffects(start: samplesStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in guard let self = self, case .success((_, let carbEffects)) = result @@ -885,15 +885,28 @@ extension LoopDataManager { } return } - - self.mealDetectionManager.generateMissedMealNotificationIfNeeded( - insulinCounteractionEffects: self.insulinCounteractionEffects, - carbEffects: carbEffects, - pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, - bolusDurationEstimator: { [unowned self] bolusAmount in - return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) + + glucoseStore.getGlucoseSamples(start: samplesStart, end: now()) {[weak self] result in + guard + let self = self, + case .success(let glucoseSamples) = result + else { + if case .failure(let error) = result { + self?.logger.error("Failed to fetch glucose samples to check for missed meal: %{public}@", String(describing: error)) + } + return } - ) + + self.mealDetectionManager.generateMissedMealNotificationIfNeeded( + glucoseSamples: glucoseSamples, + insulinCounteractionEffects: self.insulinCounteractionEffects, + carbEffects: carbEffects, + pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, + bolusDurationEstimator: { [unowned self] bolusAmount in + return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) + } + ) + } } // 5 second delay to allow stores to cache data before it is read by widget diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index 5de7972225..a1b8af02ea 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -64,13 +64,22 @@ class MealDetectionManager { } // MARK: Meal Detection - func hasMissedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], completion: @escaping (MissedMealStatus) -> Void) { + func hasMissedMeal(glucoseSamples: [some GlucoseSampleValue], insulinCounteractionEffects: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], completion: @escaping (MissedMealStatus) -> Void) { let delta = TimeInterval(minutes: 5) let intervalStart = currentDate(timeIntervalSinceNow: -MissedMealSettings.maxRecency) let intervalEnd = currentDate(timeIntervalSinceNow: -MissedMealSettings.minRecency) let now = self.currentDate - + + let filteredGlucoseValues = glucoseSamples.filter { intervalStart <= $0.startDate && $0.startDate <= now } + + /// Only try to detect if there's a missed meal if there are no calibration/user-entered BGs, + /// since these can cause large jumps + guard !filteredGlucoseValues.containsUserEntered() else { + completion(.noMissedMeal) + return + } + let filteredCarbEffects = carbEffects.filterDateRange(intervalStart, now) /// Compute how much of the ICE effect we can't explain via our entered carbs @@ -213,12 +222,13 @@ class MealDetectionManager { /// - pendingAutobolusUnits: any autobolus units that are still being delivered. Used to delay the missed meal notification to avoid notifying during an autobolus. /// - bolusDurationEstimator: estimator of bolus duration that takes the units of the bolus as an input. Used to delay the missed meal notification to avoid notifying during an autobolus. func generateMissedMealNotificationIfNeeded( + glucoseSamples: [some GlucoseSampleValue], insulinCounteractionEffects: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], pendingAutobolusUnits: Double? = nil, bolusDurationEstimator: @escaping (Double) -> TimeInterval? ) { - hasMissedMeal(insulinCounteractionEffects: insulinCounteractionEffects, carbEffects: carbEffects) {[weak self] status in + hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: insulinCounteractionEffects, carbEffects: carbEffects) {[weak self] status in self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits, bolusDurationEstimator: bolusDurationEstimator) } } @@ -294,3 +304,11 @@ class MealDetectionManager { completionHandler(report.joined(separator: "\n")) } } + +fileprivate extension BidirectionalCollection where Element: GlucoseSampleValue, Index == Int { + /// Returns whether there are any user-entered or calibration points + /// Runtime: O(n) + func containsUserEntered() -> Bool { + return !isCalibrated() || filter({ $0.wasUserEntered }).count != 0 + } +} From 4e244c8eb504fe721d0add46ed17ac4c0827a762 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Mon, 1 May 2023 15:15:00 -0700 Subject: [PATCH 2/2] Tests for changes --- .../Managers/MealDetectionManagerTests.swift | 72 +++++++++++++++++-- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index d987e3cc3f..88b45fc984 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -12,6 +12,22 @@ import LoopCore import LoopKit @testable import Loop +fileprivate class MockGlucoseSample: GlucoseSampleValue { + let provenanceIdentifier = "" + let isDisplayOnly: Bool + let wasUserEntered: Bool + let condition: LoopKit.GlucoseCondition? = nil + let trendRate: HKQuantity? = nil + let quantity: HKQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100) + let startDate: Date + + init(startDate: Date, isDisplayOnly: Bool = false, wasUserEntered: Bool = false) { + self.startDate = startDate + self.isDisplayOnly = isDisplayOnly + self.wasUserEntered = wasUserEntered + } +} + enum MissedMealTestType { private static var dateFormatter = ISO8601DateFormatter.localTimeDate() @@ -160,6 +176,8 @@ class MealDetectionManagerTests: XCTestCase { var bolusUnits: Double? var bolusDurationEstimator: ((Double) -> TimeInterval?)! + fileprivate var glucoseSamples: [MockGlucoseSample]! + @discardableResult func setUp(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { let healthStore = HKHealthStoreMock() @@ -198,6 +216,8 @@ class MealDetectionManagerTests: XCTestCase { test_currentDate: testType.currentDate ) + glucoseSamples = [MockGlucoseSample(startDate: now)] + bolusDurationEstimator = { units in self.bolusUnits = units return self.pumpManager.estimatedDuration(toBolus: units) @@ -252,7 +272,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } @@ -264,7 +284,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } @@ -277,7 +297,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 55)) updateGroup.leave() } @@ -290,7 +310,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) updateGroup.leave() } @@ -303,7 +323,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 50)) updateGroup.leave() } @@ -315,7 +335,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } @@ -328,7 +348,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) updateGroup.leave() } @@ -436,6 +456,44 @@ class MealDetectionManagerTests: XCTestCase { XCTAssertEqual(bolusUnits, 4.5) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime2) } + + func testHasCalibrationPoints_NoNotification() { + let testType = MissedMealTestType.manyMeals + let counteractionEffects = setUp(for: testType) + + let calibratedGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, isDisplayOnly: true)] + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: calibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .noMissedMeal) + updateGroup.leave() + } + updateGroup.wait() + + let manualGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, wasUserEntered: true)] + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: manualGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .noMissedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testHasTooOldCalibrationPoint_NoImpactOnNotificationDelivery() { + let testType = MissedMealTestType.manyMeals + let counteractionEffects = setUp(for: testType) + + let tooOldCalibratedGlucoseSamples = [MockGlucoseSample(startDate: now, isDisplayOnly: false), MockGlucoseSample(startDate: now.addingTimeInterval(-MissedMealSettings.maxRecency-1), isDisplayOnly: true)] + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: tooOldCalibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) + updateGroup.leave() + } + updateGroup.wait() + } } extension MealDetectionManagerTests {