From 9f7a19957bf61719a772ed3f3134e94924908da6 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Sun, 18 Sep 2022 20:06:30 -0700 Subject: [PATCH 01/51] Add unannounced meal func to CarbStoreProtocol --- Loop/Managers/Store Protocols/CarbStoreProtocol.swift | 3 +++ LoopTests/Mock Stores/MockCarbStore.swift | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index 41308cc24e..92dcb57f96 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -60,6 +60,9 @@ protocol CarbStoreProtocol: AnyObject { func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) + + // MARK: Unannounced Meal Detection + func containsUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: (UnannouncedMealStatus) -> Void) } extension CarbStore: CarbStoreProtocol { } diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index f61fcd70ed..7f213b73dc 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -106,6 +106,10 @@ class MockCarbStore: CarbStoreProtocol { return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) }))) } + + func containsUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: (UnannouncedMealStatus) -> Void) { + completion(.noMeal) + } } extension MockCarbStore { From 17555732f79fcbe5e652c8c67b253bcc94ce186c Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 20 Sep 2022 08:35:44 -0700 Subject: [PATCH 02/51] V1 of UAM algo --- Loop/Managers/LoopDataManager.swift | 14 +++++++++++++- Loop/Managers/NotificationManager.swift | 16 ++++++++++++++++ .../Store Protocols/CarbStoreProtocol.swift | 2 +- LoopTests/Mock Stores/MockCarbStore.swift | 2 +- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index b88b7d6e06..98fb80d6fe 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1046,6 +1046,8 @@ extension LoopDataManager { return (dosingDecision, nil) } + + generateUnannouncedMealNotificationIfNeeded(using: insulinCounteractionEffects) return updatePredictedGlucoseAndRecommendedDose(with: dosingDecision) } @@ -1427,6 +1429,17 @@ extension LoopDataManager { volumeRounder: volumeRounder ) } + + public func generateUnannouncedMealNotificationIfNeeded(using insulinCounteractionEffects: [GlucoseEffectVelocity]) { + carbStore.containsUnannouncedMeal(insulinCounteractionEffects: self.insulinCounteractionEffects) { status in + guard case .hasMeal(let startTime) = status else { + // No unannounced meal! + return + } + + NotificationManager.sendUnannouncedMealNotification(mealStart: startTime) + } + } /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. /// @@ -1703,7 +1716,6 @@ extension LoopDataManager { } } } - } /// Describes a view into the loop state diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 7ad18d308e..00cfbd0418 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -201,6 +201,22 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } + static func sendUnannouncedMealNotification(mealStart: Date) { + let notification = UNMutableNotificationContent() + + notification.title = String(format: NSLocalizedString("Possible Unannounced Meal", comment: "The notification title for a meal that was possibly not logged in Loop.")) + notification.body = String(format: NSLocalizedString("It looks like you may not have logged a meal you ate. Logging your carbohydrate entry now will improve the performance of Loop's algorithm.", comment: "The notification description for a meal that was possibly not logged in Loop.")) + notification.sound = .default + + let request = UNNotificationRequest( + identifier: LoopNotificationCategory.unannouncedMeal.rawValue, + content: notification, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) + } + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index 92dcb57f96..1c68c480f1 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -62,7 +62,7 @@ protocol CarbStoreProtocol: AnyObject { func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) // MARK: Unannounced Meal Detection - func containsUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: (UnannouncedMealStatus) -> Void) + func containsUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) } extension CarbStore: CarbStoreProtocol { } diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 7f213b73dc..a974a8ada2 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -107,7 +107,7 @@ class MockCarbStore: CarbStoreProtocol { }))) } - func containsUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: (UnannouncedMealStatus) -> Void) { + func containsUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { completion(.noMeal) } } From f2273523ce63529b906fd894341af640d45f6af2 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 20 Sep 2022 11:40:58 -0700 Subject: [PATCH 03/51] Add debug logs --- Loop/Managers/LoopDataManager.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 98fb80d6fe..5188fbd6bd 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1431,12 +1431,16 @@ extension LoopDataManager { } public func generateUnannouncedMealNotificationIfNeeded(using insulinCounteractionEffects: [GlucoseEffectVelocity]) { - carbStore.containsUnannouncedMeal(insulinCounteractionEffects: self.insulinCounteractionEffects) { status in - guard case .hasMeal(let startTime) = status else { + carbStore.containsUnannouncedMeal(insulinCounteractionEffects: self.insulinCounteractionEffects) {[weak self] status in + guard + let self = self, + case .hasMeal(let startTime) = status + else { // No unannounced meal! return } + self.logger.debug("Delivering a missed meal notification") NotificationManager.sendUnannouncedMealNotification(mealStart: startTime) } } From b42a834fab77abe1cfb0dd26b8465c26cf83300e Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 20 Sep 2022 12:05:12 -0700 Subject: [PATCH 04/51] Handle unannounced meal notification & open up controller --- Loop/Managers/LoopAppManager.swift | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 5833477ebb..8fa99b246e 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -437,7 +437,8 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { LoopNotificationCategory.remoteBolus.rawValue, LoopNotificationCategory.remoteBolusFailure.rawValue, LoopNotificationCategory.remoteCarbs.rawValue, - LoopNotificationCategory.remoteCarbsFailure.rawValue: + LoopNotificationCategory.remoteCarbsFailure.rawValue, + LoopNotificationCategory.unannouncedMeal.rawValue: completionHandler([.badge, .sound, .list, .banner]) default: // All other userNotifications are not to be displayed while in the foreground @@ -469,6 +470,25 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String { alertManager?.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier)) } + case UNNotificationDefaultActionIdentifier: + guard response.notification.request.identifier == LoopNotificationCategory.unannouncedMeal.rawValue else { + break + } + + var carbActivity = NSUserActivity.forNewCarbEntry() + + let userInfo = response.notification.request.content.userInfo + if let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date { + let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + doubleValue: CarbStore.unannouncedCarbThreshold), + startDate: mealTime, + foodType: nil, + absorptionTime: nil) + carbActivity.update(from: unannouncedEntry) + } + + rootViewController?.restoreUserActivityState(carbActivity) + default: break } From 64247d2c29fa4a40c39d3d33eda57200db65b21b Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 20 Sep 2022 12:05:29 -0700 Subject: [PATCH 05/51] Add mealstart to notification userinfo --- Loop/Managers/NotificationManager.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 00cfbd0418..36818b5c2d 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -207,6 +207,10 @@ extension NotificationManager { notification.title = String(format: NSLocalizedString("Possible Unannounced Meal", comment: "The notification title for a meal that was possibly not logged in Loop.")) notification.body = String(format: NSLocalizedString("It looks like you may not have logged a meal you ate. Logging your carbohydrate entry now will improve the performance of Loop's algorithm.", comment: "The notification description for a meal that was possibly not logged in Loop.")) notification.sound = .default + + notification.userInfo = [ + LoopNotificationUserInfoKey.unannouncedMealTime.rawValue: mealStart + ] let request = UNNotificationRequest( identifier: LoopNotificationCategory.unannouncedMeal.rawValue, From 4fdf7bbc5de9cc9b2630bb495b34662c31ab4f1d Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 21 Sep 2022 07:47:57 -0700 Subject: [PATCH 06/51] Improve notification description --- Loop/Managers/NotificationManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 36818b5c2d..f18bd1ec2d 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -205,7 +205,7 @@ extension NotificationManager { let notification = UNMutableNotificationContent() notification.title = String(format: NSLocalizedString("Possible Unannounced Meal", comment: "The notification title for a meal that was possibly not logged in Loop.")) - notification.body = String(format: NSLocalizedString("It looks like you may not have logged a meal you ate. Logging your carbohydrate entry now will improve the performance of Loop's algorithm.", comment: "The notification description for a meal that was possibly not logged in Loop.")) + notification.body = String(format: NSLocalizedString("It looks like you may not have logged a meal you ate. Tap to log it now.", comment: "The notification description for a meal that was possibly not logged in Loop.")) notification.sound = .default notification.userInfo = [ From ed72d3cd7cd5b404991c021716612b22e0164646 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 21 Sep 2022 14:48:48 -0700 Subject: [PATCH 07/51] Add direct entry to carb flow from notification on watch --- WatchApp Extension/ExtensionDelegate.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 20fd1b45fc..ba9316d41f 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -13,6 +13,7 @@ import Intents import os import os.log import UserNotifications +import LoopKit final class ExtensionDelegate: NSObject, WKExtensionDelegate { @@ -245,6 +246,23 @@ extension ExtensionDelegate: WCSessionDelegate { extension ExtensionDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + switch response.actionIdentifier { + case UNNotificationDefaultActionIdentifier: + guard response.notification.request.identifier == LoopNotificationCategory.unannouncedMeal.rawValue else { + break + } + + if let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController { + statusController.addCarbs() + } + default: + break + } + + completionHandler() + } + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.badge, .sound, .list, .banner]) } From d3e2c5dcbf8b784529a091fdf1b53efc94bcfc7e Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 21 Sep 2022 16:14:09 -0700 Subject: [PATCH 08/51] Add auto-setting of carb entry on watch --- Loop/Managers/LoopAppManager.swift | 4 ++-- .../CarbAndBolusFlowController.swift | 2 +- .../Controllers/HUDInterfaceController.swift | 7 +++++- WatchApp Extension/ExtensionDelegate.swift | 17 ++++++++++++-- .../Carb Entry & Bolus/CarbAndBolusFlow.swift | 23 +++++++++++++++---- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 8fa99b246e..e3345d6682 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -475,9 +475,9 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { break } - var carbActivity = NSUserActivity.forNewCarbEntry() - + let carbActivity = NSUserActivity.forNewCarbEntry() let userInfo = response.notification.request.content.userInfo + if let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date { let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: CarbStore.unannouncedCarbThreshold), diff --git a/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift b/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift index a6e38bd6c8..102bc98de8 100644 --- a/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift +++ b/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift @@ -25,7 +25,7 @@ final class CarbAndBolusFlowController: WKHostingController, I ) }() - private var configuration: CarbAndBolusFlow.Configuration = .carbEntry + private var configuration: CarbAndBolusFlow.Configuration = .carbEntry(nil) override var body: CarbAndBolusFlow { CarbAndBolusFlow(viewModel: viewModel) diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift index a0bb8f6aa5..dce87f5cac 100644 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -8,6 +8,7 @@ import WatchKit import LoopCore +import LoopKit class HUDInterfaceController: WKInterfaceController { private var activeContextObserver: NSObjectProtocol? @@ -84,7 +85,11 @@ class HUDInterfaceController: WKInterfaceController { } @IBAction func addCarbs() { - presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.carbEntry) + presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.carbEntry(nil)) + } + + func addCarbs(initialEntry: NewCarbEntry) { + presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.carbEntry(initialEntry)) } @IBAction func setBolus() { diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index ba9316d41f..f96bec7d8a 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -249,11 +249,24 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: - guard response.notification.request.identifier == LoopNotificationCategory.unannouncedMeal.rawValue else { + guard + response.notification.request.identifier == LoopNotificationCategory.unannouncedMeal.rawValue, + let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController + else { break } - if let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController { + let userInfo = response.notification.request.content.userInfo + // If we have info about a meal, the carb entry UI should reflect it + if let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date { + let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + doubleValue: CarbStore.unannouncedCarbThreshold), + startDate: mealTime, + foodType: nil, + absorptionTime: nil) + statusController.addCarbs(initialEntry: unannouncedEntry) + // Otherwise, just provide the ability to add carbs + } else { statusController.addCarbs() } default: diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift index 7528e71d9c..0c27958fe9 100644 --- a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift +++ b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift @@ -12,8 +12,8 @@ import LoopKit struct CarbAndBolusFlow: View { - enum Configuration { - case carbEntry + enum Configuration: Equatable { + case carbEntry(NewCarbEntry?) case manualBolus } @@ -34,8 +34,10 @@ struct CarbAndBolusFlow: View { @Environment(\.sizeClass) private var sizeClass // MARK: - State: Carb Entry + // Date the user last changed the carb entry with the UI @State private var carbLastEntryDate = Date() @State private var carbAmount = 15 + // Date of the carb entry @State private var carbEntryDate = Date() @State private var carbAbsorptionTime: CarbAbsorptionTime = .medium @State private var inputMode: CarbEntryInputMode = .carbs @@ -54,8 +56,15 @@ struct CarbAndBolusFlow: View { init(viewModel: CarbAndBolusFlowViewModel) { switch viewModel.configuration { - case .carbEntry: + case .carbEntry(let entry): _flowState = State(initialValue: .carbEntry) + + if let entry = entry { + _carbEntryDate = State(initialValue: entry.startDate) + + let initialCarbAmount = entry.quantity.doubleValue(for: .gram()) + _carbAmount = State(initialValue: Int(initialCarbAmount)) + } case .manualBolus: _flowState = State(initialValue: .bolusEntry) } @@ -159,7 +168,11 @@ extension CarbAndBolusFlow { case .size42mm: return 0 case .size40mm, .size41mm: - return configuration == .carbEntry ? 7 : 19 + if case .carbEntry = configuration { + return 7 + } else { + return 19 + } case .size44mm, .size45mm: return 5 } @@ -205,7 +218,7 @@ extension CarbAndBolusFlow { withAnimation { self.flowState = .bolusConfirmation } - } else if self.configuration == .carbEntry { + } else if case .carbEntry = self.configuration { self.viewModel.addCarbsWithoutBolusing() } } From 49c6928e9b0edbc6d5bc83020292e720886ee76a Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Sun, 2 Oct 2022 08:05:18 -0700 Subject: [PATCH 09/51] carbThreshold -> mealCarbThreshold --- Loop/Managers/LoopAppManager.swift | 2 +- WatchApp Extension/ExtensionDelegate.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index e3345d6682..53e0f171b9 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -480,7 +480,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { if let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date { let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), - doubleValue: CarbStore.unannouncedCarbThreshold), + doubleValue: CarbStore.unannouncedMealCarbThreshold), startDate: mealTime, foodType: nil, absorptionTime: nil) diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index f96bec7d8a..ba50235359 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -260,7 +260,7 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { // If we have info about a meal, the carb entry UI should reflect it if let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date { let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), - doubleValue: CarbStore.unannouncedCarbThreshold), + doubleValue: CarbStore.unannouncedMealCarbThreshold), startDate: mealTime, foodType: nil, absorptionTime: nil) From 54118f943facbf7ab64f14eeea689fcb2352b701 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Sun, 2 Oct 2022 08:05:28 -0700 Subject: [PATCH 10/51] Retract UAM notifications after the carbs have expired --- Loop/Managers/LoopDataManager.swift | 16 ++++++----- Loop/Managers/NotificationManager.swift | 36 ++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 5188fbd6bd..b472146f87 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1432,15 +1432,19 @@ extension LoopDataManager { public func generateUnannouncedMealNotificationIfNeeded(using insulinCounteractionEffects: [GlucoseEffectVelocity]) { carbStore.containsUnannouncedMeal(insulinCounteractionEffects: self.insulinCounteractionEffects) {[weak self] status in - guard - let self = self, - case .hasMeal(let startTime) = status - else { + self?.manageMealNotifications(for: status) + } + } + + private func manageMealNotifications(for status: UnannouncedMealStatus) { + // We should remove expired notifications regardless of whether or not there was a meal + NotificationManager.removeExpiredMealNotifications { [weak self] in + guard case .hasMeal(let startTime) = status else { // No unannounced meal! return } - - self.logger.debug("Delivering a missed meal notification") + + self?.logger.debug("Delivering a missed meal notification") NotificationManager.sendUnannouncedMealNotification(mealStart: startTime) } } diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index f18bd1ec2d..7a66d8bc9d 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -9,6 +9,7 @@ import UIKit import UserNotifications import LoopKit +import LoopCore enum NotificationManager { @@ -203,13 +204,16 @@ extension NotificationManager { static func sendUnannouncedMealNotification(mealStart: Date) { let notification = UNMutableNotificationContent() + // Notifications should expire after the missed meal is no longer relevant + let expirationDate = Date().addingTimeInterval(LoopCoreConstants.defaultCarbAbsorptionTimes.slow) notification.title = String(format: NSLocalizedString("Possible Unannounced Meal", comment: "The notification title for a meal that was possibly not logged in Loop.")) notification.body = String(format: NSLocalizedString("It looks like you may not have logged a meal you ate. Tap to log it now.", comment: "The notification description for a meal that was possibly not logged in Loop.")) notification.sound = .default notification.userInfo = [ - LoopNotificationUserInfoKey.unannouncedMealTime.rawValue: mealStart + LoopNotificationUserInfoKey.unannouncedMealTime.rawValue: mealStart, + LoopNotificationUserInfoKey.expirationDate.rawValue: expirationDate ] let request = UNNotificationRequest( @@ -221,6 +225,36 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } + static func removeExpiredMealNotifications(completion: @escaping () -> Void) { + let notificationCenter = UNUserNotificationCenter.current() + var identifiersToRemove: [String] = [] + + notificationCenter.getPendingNotificationRequests { requests in + defer { + completion() + } + + for request in requests { + guard + request.identifier == LoopNotificationCategory.unannouncedMeal.rawValue, + let expirationDate = request.content.userInfo[LoopNotificationUserInfoKey.expirationDate.rawValue] as? Date, + expirationDate < Date() + else { + continue + } + + // The notification is expired: mark it for removal + identifiersToRemove.append(request.identifier) + } + + guard identifiersToRemove.count > 0 else { + return + } + + notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiersToRemove) + } + } + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } From e5e114571a8ce4d5b0ef30d018bed241585fba30 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Mon, 3 Oct 2022 21:19:06 -0700 Subject: [PATCH 11/51] Improve function naming --- Loop/Managers/LoopDataManager.swift | 2 +- Loop/Managers/Store Protocols/CarbStoreProtocol.swift | 2 +- LoopTests/Mock Stores/MockCarbStore.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index b472146f87..69ad07edef 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1431,7 +1431,7 @@ extension LoopDataManager { } public func generateUnannouncedMealNotificationIfNeeded(using insulinCounteractionEffects: [GlucoseEffectVelocity]) { - carbStore.containsUnannouncedMeal(insulinCounteractionEffects: self.insulinCounteractionEffects) {[weak self] status in + carbStore.hasUnannouncedMeal(insulinCounteractionEffects: self.insulinCounteractionEffects) {[weak self] status in self?.manageMealNotifications(for: status) } } diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index 1c68c480f1..a1db69d0c3 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -62,7 +62,7 @@ protocol CarbStoreProtocol: AnyObject { func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) // MARK: Unannounced Meal Detection - func containsUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) + func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) } extension CarbStore: CarbStoreProtocol { } diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index a974a8ada2..09f13d3575 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -107,7 +107,7 @@ class MockCarbStore: CarbStoreProtocol { }))) } - func containsUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { + func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { completion(.noMeal) } } From 2416b24eec3fcdc0cb537e84f3088d974f16422f Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 12 Oct 2022 08:22:01 -0700 Subject: [PATCH 12/51] Move notification logic from LoopKit to Loop --- Loop/Managers/LoopDataManager.swift | 23 ++++++++++++++++++++--- LoopCore/NSUserDefaults.swift | 14 ++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 69ad07edef..20c6fc7d8c 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -397,6 +397,13 @@ final class LoopDataManager { // Confined to dataAccessQueue private var retrospectiveCorrection: RetrospectiveCorrection + + /// The last time an unannounced meal notification was sent + private var lastUAMNotificationDeliveryTime: Date? = UserDefaults.standard.lastUAMNotificationDeliveryTime { + didSet { + UserDefaults.standard.lastUAMNotificationDeliveryTime = lastUAMNotificationDeliveryTime + } + } // MARK: - Background task management @@ -1439,12 +1446,21 @@ extension LoopDataManager { private func manageMealNotifications(for status: UnannouncedMealStatus) { // We should remove expired notifications regardless of whether or not there was a meal NotificationManager.removeExpiredMealNotifications { [weak self] in - guard case .hasMeal(let startTime) = status else { - // No unannounced meal! + guard let self = self else { return } + + let now = Date() + let notificationTimeTooRecent = now.timeIntervalSince(self.lastUAMNotificationDeliveryTime ?? .distantPast) < (CarbStore.unannouncedMealMaxRecency - CarbStore.unannouncedMealMinRecency) + + guard + case .hasMeal(let startTime) = status, + !notificationTimeTooRecent + else { + // No notification needed! return } - self?.logger.debug("Delivering a missed meal notification") + self.logger.debug("Delivering a missed meal notification") + self.lastUAMNotificationDeliveryTime = now NotificationManager.sendUnannouncedMealNotification(mealStart: startTime) } } @@ -2034,6 +2050,7 @@ extension LoopDataManager { "recommendedAutomaticDose: \(String(describing: state.recommendedAutomaticDose))", "lastBolus: \(String(describing: manager.lastRequestedBolus))", "lastLoopCompleted: \(String(describing: manager.lastLoopCompleted))", + "lastUnannouncedMealNotificationTime: \(String(describing: self.lastUAMNotificationDeliveryTime))", "basalDeliveryState: \(String(describing: manager.basalDeliveryState))", "carbsOnBoard: \(String(describing: state.carbsOnBoard))", "error: \(String(describing: state.error))", diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index 81aa90b52b..b86209bcbc 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -20,6 +20,7 @@ extension UserDefaults { case lastProfileExpirationAlertDate = "com.loopkit.Loop.lastProfileExpirationAlertDate" case allowDebugFeatures = "com.loopkit.Loop.allowDebugFeatures" case allowSimulators = "com.loopkit.Loop.allowSimulators" + case LastUAMNotificationDeliveryTime = "com.loopkit.Loop.lastUAMNotificationDeliveryTime" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -114,6 +115,19 @@ extension UserDefaults { } } + public var lastUAMNotificationDeliveryTime: Date? { + get { + if let rawValue = value(forKey: Key.LastUAMNotificationDeliveryTime.rawValue) as? Date { + return rawValue + } else { + return nil + } + } + set { + set(newValue, forKey: Key.LastUAMNotificationDeliveryTime.rawValue) + } + } + public var allowDebugFeatures: Bool { return bool(forKey: Key.allowDebugFeatures.rawValue) } From f34f4f93492390ea9133e80d094c107d82fa9a35 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Mon, 17 Oct 2022 16:41:28 -0700 Subject: [PATCH 13/51] Make current date configurable --- Loop/Managers/Store Protocols/CarbStoreProtocol.swift | 8 +++++++- LoopTests/Mock Stores/MockCarbStore.swift | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index a1db69d0c3..e9090979f7 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -62,7 +62,13 @@ protocol CarbStoreProtocol: AnyObject { func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) // MARK: Unannounced Meal Detection - func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) + func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], currentDate: Date, completion: @escaping (UnannouncedMealStatus) -> Void) } extension CarbStore: CarbStoreProtocol { } + +extension CarbStoreProtocol { + func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { + hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects, currentDate: Date(), completion: completion) + } +} diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 09f13d3575..9ffdc50667 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -107,7 +107,7 @@ class MockCarbStore: CarbStoreProtocol { }))) } - func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { + func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], currentDate: Date, completion: @escaping (UnannouncedMealStatus) -> Void) { completion(.noMeal) } } From 1364e565b923546eff2fd0ec6175c1bd159a4dec Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 18 Oct 2022 07:42:38 -0700 Subject: [PATCH 14/51] Make current date configurable during unit testing --- Loop/Managers/Store Protocols/CarbStoreProtocol.swift | 8 +------- LoopTests/Mock Stores/MockCarbStore.swift | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index e9090979f7..a1db69d0c3 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -62,13 +62,7 @@ protocol CarbStoreProtocol: AnyObject { func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) // MARK: Unannounced Meal Detection - func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], currentDate: Date, completion: @escaping (UnannouncedMealStatus) -> Void) + func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) } extension CarbStore: CarbStoreProtocol { } - -extension CarbStoreProtocol { - func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { - hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects, currentDate: Date(), completion: completion) - } -} diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 9ffdc50667..9bb5934a62 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -107,8 +107,8 @@ class MockCarbStore: CarbStoreProtocol { }))) } - func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], currentDate: Date, completion: @escaping (UnannouncedMealStatus) -> Void) { completion(.noMeal) + func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { } } From 52ca5f44cfe8566b092a3f76f576d094cbe17488 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 18 Oct 2022 07:43:01 -0700 Subject: [PATCH 15/51] Update status enum naming for point-of-use clarity --- Loop/Managers/LoopDataManager.swift | 2 +- LoopTests/Mock Stores/MockCarbStore.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 20c6fc7d8c..27c063aee2 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1452,7 +1452,7 @@ extension LoopDataManager { let notificationTimeTooRecent = now.timeIntervalSince(self.lastUAMNotificationDeliveryTime ?? .distantPast) < (CarbStore.unannouncedMealMaxRecency - CarbStore.unannouncedMealMinRecency) guard - case .hasMeal(let startTime) = status, + case .hasUnannouncedMeal(let startTime) = status, !notificationTimeTooRecent else { // No notification needed! diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 9bb5934a62..527f03c950 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -107,8 +107,8 @@ class MockCarbStore: CarbStoreProtocol { }))) } - completion(.noMeal) func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { + completion(.noUnannouncedMeal) } } From 194fcac625a49862365d67eeb1493bb151deedc7 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 19 Oct 2022 06:01:42 -0700 Subject: [PATCH 16/51] Make 'now' time configurable --- Loop/Managers/LoopDataManager.swift | 7 +++++-- LoopTests/Managers/LoopDataManagerTests.swift | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index e213fc275d..6bbbeb12b8 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -399,7 +399,8 @@ final class LoopDataManager { private var retrospectiveCorrection: RetrospectiveCorrection /// The last time an unannounced meal notification was sent - private var lastUAMNotificationDeliveryTime: Date? = UserDefaults.standard.lastUAMNotificationDeliveryTime { + /// Internal for unit testing + internal var lastUAMNotificationDeliveryTime: Date? = UserDefaults.standard.lastUAMNotificationDeliveryTime { didSet { UserDefaults.standard.lastUAMNotificationDeliveryTime = lastUAMNotificationDeliveryTime } @@ -1446,11 +1447,13 @@ extension LoopDataManager { } private func manageMealNotifications(for status: UnannouncedMealStatus) { + // Internal for unit testing + internal func manageMealNotifications(for status: UnannouncedMealStatus) { // We should remove expired notifications regardless of whether or not there was a meal NotificationManager.removeExpiredMealNotifications { [weak self] in guard let self = self else { return } - let now = Date() + let now = self.now() let notificationTimeTooRecent = now.timeIntervalSince(self.lastUAMNotificationDeliveryTime ?? .distantPast) < (CarbStore.unannouncedMealMaxRecency - CarbStore.unannouncedMealMinRecency) guard diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index d8c9c3c361..d83e77e7cf 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -73,6 +73,7 @@ class LoopDataManagerDosingTests: XCTestCase { } // MARK: Mock stores + var now: Date! var dosingDecisionStore: MockDosingDecisionStore! var automaticDosingStatus: AutomaticDosingStatus! var loopDataManager: LoopDataManager! @@ -96,6 +97,7 @@ class LoopDataManagerDosingTests: XCTestCase { let carbStore = MockCarbStore(for: test) let currentDate = glucoseStore.latestGlucose!.startDate + now = currentDate dosingDecisionStore = MockDosingDecisionStore() automaticDosingStatus = AutomaticDosingStatus(isClosedLoop: true, isClosedLoopAllowed: true) From 480c8910cac0e27f22ea9de5c0c4ee543bdaaee0 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 19 Oct 2022 06:04:59 -0700 Subject: [PATCH 17/51] Extract loop data manager testing logic into base class --- LoopTests/Managers/LoopDataManagerTests.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index d83e77e7cf..b9e99e8983 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -45,7 +45,7 @@ extension ISO8601DateFormatter { } } -class LoopDataManagerDosingTests: XCTestCase { +class LoopDataManagerTests: XCTestCase { // MARK: Constants for testing let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) let retrospectiveCorrectionGroupingInterval = 1.01 @@ -123,7 +123,10 @@ class LoopDataManagerDosingTests: XCTestCase { override func tearDownWithError() throws { loopDataManager = nil } - +} + + +class LoopDataManagerDosingTests: LoopDataManagerTests { // MARK: Functions to load fixtures func loadGlucoseEffect(_ name: String) -> [GlucoseEffect] { let fixture: [JSONDictionary] = loadFixture(name) @@ -562,7 +565,7 @@ class LoopDataManagerDosingTests: XCTestCase { } -extension LoopDataManagerDosingTests { +extension LoopDataManagerTests { public var bundle: Bundle { return Bundle(for: type(of: self)) } From 9b7b2d98f9e1a8c25a04eb61fbf85038fd29766a Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 19 Oct 2022 06:32:39 -0700 Subject: [PATCH 18/51] Extract loop data manager dosing tests into their own file --- .../Managers/LoopDataManagerDosingTests.swift | 451 ++++++++++++++++++ LoopTests/Managers/LoopDataManagerTests.swift | 440 ----------------- 2 files changed, 451 insertions(+), 440 deletions(-) create mode 100644 LoopTests/Managers/LoopDataManagerDosingTests.swift diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift new file mode 100644 index 0000000000..0e65a5d6bb --- /dev/null +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -0,0 +1,451 @@ +// +// LoopDataManagerDosingTests.swift +// LoopTests +// +// Created by Anna Quinlan on 10/19/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +@testable import LoopCore +@testable import Loop + +class LoopDataManagerDosingTests: LoopDataManagerTests { + // MARK: Functions to load fixtures + func loadGlucoseEffect(_ name: String) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(name) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) + } + } + + // MARK: Tests + func testFlatAndStable() { + setUp(for: .flatAndStable) + let predictedGlucoseOutput = loadGlucoseEffect("flat_and_stable_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedDose: AutomaticDoseRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedDose = state.recommendedAutomaticDose?.recommendation + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + let recommendedTempBasal = recommendedDose?.basalAdjustment + + XCTAssertEqual(1.40, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func testHighAndStable() { + setUp(for: .highAndStable) + let predictedGlucoseOutput = loadGlucoseEffect("high_and_stable_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + XCTAssertEqual(4.63, recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func testHighAndFalling() { + setUp(for: .highAndFalling) + let predictedGlucoseOutput = loadGlucoseEffect("high_and_falling_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedTempBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func testHighAndRisingWithCOB() { + setUp(for: .highAndRisingWithCOB) + let predictedGlucoseOutput = loadGlucoseEffect("high_and_rising_with_cob_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedBolus: ManualBolusRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedBolus = try? state.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + XCTAssertEqual(1.6, recommendedBolus!.amount, accuracy: defaultAccuracy) + } + + func testLowAndFallingWithCOB() { + setUp(for: .lowAndFallingWithCOB) + let predictedGlucoseOutput = loadGlucoseEffect("low_and_falling_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedTempBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func testLowWithLowTreatment() { + setUp(for: .lowWithLowTreatment) + let predictedGlucoseOutput = loadGlucoseEffect("low_with_low_treatment_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedTempBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + class MockDelegate: LoopDataManagerDelegate { + var recommendation: AutomaticDoseRecommendation? + var error: LoopError? + func loopDataManager(_ manager: LoopDataManager, didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), completion: @escaping (LoopError?) -> Void) { + self.recommendation = automaticDose.recommendation + completion(error) + } + func loopDataManager(_ manager: LoopDataManager, roundBasalRate unitsPerHour: Double) -> Double { unitsPerHour } + func loopDataManager(_ manager: LoopDataManager, roundBolusVolume units: Double) -> Double { units } + var pumpManagerStatus: PumpManagerStatus? + var cgmManagerStatus: CGMManagerStatus? + var pumpStatusHighlight: DeviceStatusHighlight? + } + + func waitOnDataQueue(timeout: TimeInterval = 1.0) { + let e = expectation(description: "dataQueue") + loopDataManager.getLoopState { _, _ in + e.fulfill() + } + wait(for: [e], timeout: timeout) + } + + func testValidateMaxTempBasalDoesntCancelTempBasalIfHigher() { + let dose = DoseEntry(type: .tempBasal, startDate: Date(), endDate: nil, value: 3.0, unit: .unitsPerHour, deliveredUnits: nil, description: nil, syncIdentifier: nil, scheduledBasalRate: nil) + setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) + // This wait is working around the issue presented by LoopDataManager.init(). It cancels the temp basal if + // `isClosedLoop` is false (which it is from `setUp` above). When that happens, it races with + // `maxTempBasalSavePreflight` below. This ensures only one happens at a time. + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + var error: Error? + let exp = expectation(description: #function) + XCTAssertNil(delegate.recommendation) + loopDataManager.maxTempBasalSavePreflight(unitsPerHour: 5.0) { + error = $0 + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + XCTAssertNil(error) + XCTAssertNil(delegate.recommendation) + XCTAssertTrue(dosingDecisionStore.dosingDecisions.isEmpty) + } + + func testValidateMaxTempBasalCancelsTempBasalIfLower() { + let dose = DoseEntry(type: .tempBasal, startDate: Date(), endDate: nil, value: 5.0, unit: .unitsPerHour, deliveredUnits: nil, description: nil, syncIdentifier: nil, scheduledBasalRate: nil) + setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) + // This wait is working around the issue presented by LoopDataManager.init(). It cancels the temp basal if + // `isClosedLoop` is false (which it is from `setUp` above). When that happens, it races with + // `maxTempBasalSavePreflight` below. This ensures only one happens at a time. + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + var error: Error? + let exp = expectation(description: #function) + XCTAssertNil(delegate.recommendation) + loopDataManager.maxTempBasalSavePreflight(unitsPerHour: 3.0) { + error = $0 + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + XCTAssertNil(error) + XCTAssertEqual(delegate.recommendation, AutomaticDoseRecommendation(basalAdjustment: .cancel)) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "maximumBasalRateChanged") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, AutomaticDoseRecommendation(basalAdjustment: .cancel)) + } + + func testChangingMaxBasalUpdatesLoopData() { + setUp(for: .highAndStable) + waitOnDataQueue() + var loopDataUpdated = false + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in + loopDataUpdated = true + exp.fulfill() + } + XCTAssertFalse(loopDataUpdated) + loopDataManager.mutateSettings { $0.maximumBasalRatePerHour = 2.0 } + wait(for: [exp], timeout: 1.0) + XCTAssertTrue(loopDataUpdated) + NotificationCenter.default.removeObserver(observer) + } + + func testOpenLoopCancelsTempBasal() { + let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 1.0, unit: .unitsPerHour) + setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in + exp.fulfill() + } + automaticDosingStatus.isClosedLoop = false + wait(for: [exp], timeout: 1.0) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) + XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "closedLoopDisabled") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + NotificationCenter.default.removeObserver(observer) + } + + func testReceivedUnreliableCGMReadingCancelsTempBasal() { + let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 5.0, unit: .unitsPerHour) + setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in + exp.fulfill() + } + loopDataManager.receivedUnreliableCGMReading() + wait(for: [exp], timeout: 1.0) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) + XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "unreliableCGMData") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + NotificationCenter.default.removeObserver(observer) + } + + func testLoopEnactsTempBasalWithoutManualBolusRecommendation() { + setUp(for: .highAndStable) + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopCompleted, object: nil, queue: nil) { _ in + exp.fulfill() + } + loopDataManager.loop() + wait(for: [exp], timeout: 1.0) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 4.577747629410191, duration: .minutes(30))) + XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + if dosingDecisionStore.dosingDecisions.count == 1 { + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) + } + NotificationCenter.default.removeObserver(observer) + } + + func testLoopRecommendsTempBasalWithoutEnactingIfOpenLoop() { + setUp(for: .highAndStable) + automaticDosingStatus.isClosedLoop = false + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopCompleted, object: nil, queue: nil) { _ in + exp.fulfill() + } + loopDataManager.loop() + wait(for: [exp], timeout: 1.0) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 4.577747629410191, duration: .minutes(30))) + XCTAssertNil(delegate.recommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) + NotificationCenter.default.removeObserver(observer) + } + + func testLoopGetStateRecommendsManualBolus() { + setUp(for: .highAndStable) + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 1.82, accuracy: 0.01) + } + + func testLoopGetStateRecommendsManualBolusWithMomentum() { + setUp(for: .highAndRisingWithCOB) + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + XCTAssertEqual(recommendedBolus!.amount, 1.62, accuracy: 0.01) + } + + func testLoopGetStateRecommendsManualBolusWithoutMomentum() { + setUp(for: .highAndRisingWithCOB) + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + XCTAssertEqual(recommendedBolus!.amount, 1.52, accuracy: 0.01) + } + + func testIsClosedLoopAvoidsTriggeringTempBasalCancelOnCreation() { + let settings = LoopSettings( + dosingEnabled: false, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + maximumBasalRatePerHour: maxBasalRate, + maximumBolus: maxBolus, + suspendThreshold: suspendThreshold + ) + + let doseStore = MockDoseStore() + let glucoseStore = MockGlucoseStore() + let carbStore = MockCarbStore() + + let currentDate = Date() + + dosingDecisionStore = MockDosingDecisionStore() + automaticDosingStatus = AutomaticDosingStatus(isClosedLoop: false, isClosedLoopAllowed: true) + let existingTempBasal = DoseEntry( + type: .tempBasal, + startDate: currentDate.addingTimeInterval(-.minutes(2)), + endDate: currentDate.addingTimeInterval(.minutes(28)), + value: 1.0, + unit: .unitsPerHour, + deliveredUnits: nil, + description: "Mock Temp Basal", + syncIdentifier: "asdf", + scheduledBasalRate: nil, + insulinType: .novolog, + automatic: true, + manuallyEntered: false, + isMutable: true) + loopDataManager = LoopDataManager( + lastLoopCompleted: currentDate.addingTimeInterval(-.minutes(5)), + basalDeliveryState: .tempBasal(existingTempBasal), + settings: settings, + overrideHistory: TemporaryScheduleOverrideHistory(), + analyticsServicesManager: AnalyticsServicesManager(), + localCacheDuration: .days(1), + doseStore: doseStore, + glucoseStore: glucoseStore, + carbStore: carbStore, + dosingDecisionStore: dosingDecisionStore, + latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), + now: { currentDate }, + pumpInsulinType: .novolog, + automaticDosingStatus: automaticDosingStatus, + trustedTimeOffset: { 0 } + ) + let mockDelegate = MockDelegate() + loopDataManager.delegate = mockDelegate + + // Dose enacting happens asynchronously, as does receiving isClosedLoop signals + waitOnMain(timeout: 5) + XCTAssertNil(mockDelegate.recommendation) + } +} diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index b9e99e8983..b4ae7e9525 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -125,446 +125,6 @@ class LoopDataManagerTests: XCTestCase { } } - -class LoopDataManagerDosingTests: LoopDataManagerTests { - // MARK: Functions to load fixtures - func loadGlucoseEffect(_ name: String) -> [GlucoseEffect] { - let fixture: [JSONDictionary] = loadFixture(name) - let dateFormatter = ISO8601DateFormatter.localTimeDate() - - return fixture.map { - return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) - } - } - - // MARK: Tests - func testFlatAndStable() { - setUp(for: .flatAndStable) - let predictedGlucoseOutput = loadGlucoseEffect("flat_and_stable_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedDose: AutomaticDoseRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedDose = state.recommendedAutomaticDose?.recommendation - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - let recommendedTempBasal = recommendedDose?.basalAdjustment - - XCTAssertEqual(1.40, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testHighAndStable() { - setUp(for: .highAndStable) - let predictedGlucoseOutput = loadGlucoseEffect("high_and_stable_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(4.63, recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testHighAndFalling() { - setUp(for: .highAndFalling) - let predictedGlucoseOutput = loadGlucoseEffect("high_and_falling_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedTempBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testHighAndRisingWithCOB() { - setUp(for: .highAndRisingWithCOB) - let predictedGlucoseOutput = loadGlucoseEffect("high_and_rising_with_cob_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedBolus: ManualBolusRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedBolus = try? state.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(1.6, recommendedBolus!.amount, accuracy: defaultAccuracy) - } - - func testLowAndFallingWithCOB() { - setUp(for: .lowAndFallingWithCOB) - let predictedGlucoseOutput = loadGlucoseEffect("low_and_falling_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedTempBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testLowWithLowTreatment() { - setUp(for: .lowWithLowTreatment) - let predictedGlucoseOutput = loadGlucoseEffect("low_with_low_treatment_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedTempBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - class MockDelegate: LoopDataManagerDelegate { - var recommendation: AutomaticDoseRecommendation? - var error: LoopError? - func loopDataManager(_ manager: LoopDataManager, didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), completion: @escaping (LoopError?) -> Void) { - self.recommendation = automaticDose.recommendation - completion(error) - } - func loopDataManager(_ manager: LoopDataManager, roundBasalRate unitsPerHour: Double) -> Double { unitsPerHour } - func loopDataManager(_ manager: LoopDataManager, roundBolusVolume units: Double) -> Double { units } - var pumpManagerStatus: PumpManagerStatus? - var cgmManagerStatus: CGMManagerStatus? - var pumpStatusHighlight: DeviceStatusHighlight? - } - - func waitOnDataQueue(timeout: TimeInterval = 1.0) { - let e = expectation(description: "dataQueue") - loopDataManager.getLoopState { _, _ in - e.fulfill() - } - wait(for: [e], timeout: timeout) - } - - func testValidateMaxTempBasalDoesntCancelTempBasalIfHigher() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), endDate: nil, value: 3.0, unit: .unitsPerHour, deliveredUnits: nil, description: nil, syncIdentifier: nil, scheduledBasalRate: nil) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - // This wait is working around the issue presented by LoopDataManager.init(). It cancels the temp basal if - // `isClosedLoop` is false (which it is from `setUp` above). When that happens, it races with - // `maxTempBasalSavePreflight` below. This ensures only one happens at a time. - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - var error: Error? - let exp = expectation(description: #function) - XCTAssertNil(delegate.recommendation) - loopDataManager.maxTempBasalSavePreflight(unitsPerHour: 5.0) { - error = $0 - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertNil(error) - XCTAssertNil(delegate.recommendation) - XCTAssertTrue(dosingDecisionStore.dosingDecisions.isEmpty) - } - - func testValidateMaxTempBasalCancelsTempBasalIfLower() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), endDate: nil, value: 5.0, unit: .unitsPerHour, deliveredUnits: nil, description: nil, syncIdentifier: nil, scheduledBasalRate: nil) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - // This wait is working around the issue presented by LoopDataManager.init(). It cancels the temp basal if - // `isClosedLoop` is false (which it is from `setUp` above). When that happens, it races with - // `maxTempBasalSavePreflight` below. This ensures only one happens at a time. - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - var error: Error? - let exp = expectation(description: #function) - XCTAssertNil(delegate.recommendation) - loopDataManager.maxTempBasalSavePreflight(unitsPerHour: 3.0) { - error = $0 - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertNil(error) - XCTAssertEqual(delegate.recommendation, AutomaticDoseRecommendation(basalAdjustment: .cancel)) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "maximumBasalRateChanged") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, AutomaticDoseRecommendation(basalAdjustment: .cancel)) - } - - func testChangingMaxBasalUpdatesLoopData() { - setUp(for: .highAndStable) - waitOnDataQueue() - var loopDataUpdated = false - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in - loopDataUpdated = true - exp.fulfill() - } - XCTAssertFalse(loopDataUpdated) - loopDataManager.mutateSettings { $0.maximumBasalRatePerHour = 2.0 } - wait(for: [exp], timeout: 1.0) - XCTAssertTrue(loopDataUpdated) - NotificationCenter.default.removeObserver(observer) - } - - func testOpenLoopCancelsTempBasal() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 1.0, unit: .unitsPerHour) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in - exp.fulfill() - } - automaticDosingStatus.isClosedLoop = false - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "closedLoopDisabled") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - NotificationCenter.default.removeObserver(observer) - } - - func testReceivedUnreliableCGMReadingCancelsTempBasal() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 5.0, unit: .unitsPerHour) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in - exp.fulfill() - } - loopDataManager.receivedUnreliableCGMReading() - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "unreliableCGMData") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - NotificationCenter.default.removeObserver(observer) - } - - func testLoopEnactsTempBasalWithoutManualBolusRecommendation() { - setUp(for: .highAndStable) - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopCompleted, object: nil, queue: nil) { _ in - exp.fulfill() - } - loopDataManager.loop() - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 4.577747629410191, duration: .minutes(30))) - XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - if dosingDecisionStore.dosingDecisions.count == 1 { - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) - } - NotificationCenter.default.removeObserver(observer) - } - - func testLoopRecommendsTempBasalWithoutEnactingIfOpenLoop() { - setUp(for: .highAndStable) - automaticDosingStatus.isClosedLoop = false - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopCompleted, object: nil, queue: nil) { _ in - exp.fulfill() - } - loopDataManager.loop() - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 4.577747629410191, duration: .minutes(30))) - XCTAssertNil(delegate.recommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) - NotificationCenter.default.removeObserver(observer) - } - - func testLoopGetStateRecommendsManualBolus() { - setUp(for: .highAndStable) - let exp = expectation(description: #function) - var recommendedBolus: ManualBolusRecommendation? - loopDataManager.getLoopState { (_, loopState) in - recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) - exp.fulfill() - } - wait(for: [exp], timeout: 100000.0) - XCTAssertEqual(recommendedBolus!.amount, 1.82, accuracy: 0.01) - } - - func testLoopGetStateRecommendsManualBolusWithMomentum() { - setUp(for: .highAndRisingWithCOB) - let exp = expectation(description: #function) - var recommendedBolus: ManualBolusRecommendation? - loopDataManager.getLoopState { (_, loopState) in - recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertEqual(recommendedBolus!.amount, 1.62, accuracy: 0.01) - } - - func testLoopGetStateRecommendsManualBolusWithoutMomentum() { - setUp(for: .highAndRisingWithCOB) - let exp = expectation(description: #function) - var recommendedBolus: ManualBolusRecommendation? - loopDataManager.getLoopState { (_, loopState) in - recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertEqual(recommendedBolus!.amount, 1.52, accuracy: 0.01) - } - - func testIsClosedLoopAvoidsTriggeringTempBasalCancelOnCreation() { - let settings = LoopSettings( - dosingEnabled: false, - glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, - maximumBasalRatePerHour: maxBasalRate, - maximumBolus: maxBolus, - suspendThreshold: suspendThreshold - ) - - let doseStore = MockDoseStore() - let glucoseStore = MockGlucoseStore() - let carbStore = MockCarbStore() - - let currentDate = Date() - - dosingDecisionStore = MockDosingDecisionStore() - automaticDosingStatus = AutomaticDosingStatus(isClosedLoop: false, isClosedLoopAllowed: true) - let existingTempBasal = DoseEntry( - type: .tempBasal, - startDate: currentDate.addingTimeInterval(-.minutes(2)), - endDate: currentDate.addingTimeInterval(.minutes(28)), - value: 1.0, - unit: .unitsPerHour, - deliveredUnits: nil, - description: "Mock Temp Basal", - syncIdentifier: "asdf", - scheduledBasalRate: nil, - insulinType: .novolog, - automatic: true, - manuallyEntered: false, - isMutable: true) - loopDataManager = LoopDataManager( - lastLoopCompleted: currentDate.addingTimeInterval(-.minutes(5)), - basalDeliveryState: .tempBasal(existingTempBasal), - settings: settings, - overrideHistory: TemporaryScheduleOverrideHistory(), - analyticsServicesManager: AnalyticsServicesManager(), - localCacheDuration: .days(1), - doseStore: doseStore, - glucoseStore: glucoseStore, - carbStore: carbStore, - dosingDecisionStore: dosingDecisionStore, - latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), - now: { currentDate }, - pumpInsulinType: .novolog, - automaticDosingStatus: automaticDosingStatus, - trustedTimeOffset: { 0 } - ) - let mockDelegate = MockDelegate() - loopDataManager.delegate = mockDelegate - - // Dose enacting happens asynchronously, as does receiving isClosedLoop signals - waitOnMain(timeout: 5) - XCTAssertNil(mockDelegate.recommendation) - } - -} - extension LoopDataManagerTests { public var bundle: Bundle { return Bundle(for: type(of: self)) From be4abd85c0f1554d4924b8d9713b556580c4e35e Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 19 Oct 2022 06:32:58 -0700 Subject: [PATCH 19/51] Add unannounced meal tests --- Loop.xcodeproj/project.pbxproj | 16 ++++++ .../Managers/LoopDataManagerUAMTests.swift | 49 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 LoopTests/Managers/LoopDataManagerUAMTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index fa42fed9eb..6104c63398 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -550,6 +550,8 @@ E942DE96253BE68F00AC532D /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; E942DE9F253BE6A900AC532D /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; E942DF34253BF87F00AC532D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 43785E9B2120E7060057DED1 /* Intents.intentdefinition */; }; + E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */; }; + E950CA9329002E1A00B5B692 /* LoopDataManagerUAMTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E950CA9229002E1A00B5B692 /* LoopDataManagerUAMTests.swift */; }; E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */; }; E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */; }; E95D380524EADF78005E2F50 /* GlucoseStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */; }; @@ -1484,6 +1486,8 @@ E93E86C824E2E02200FF40C8 /* high_and_stable_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_counteraction_effect.json; sourceTree = ""; }; E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_momentum_effect.json; sourceTree = ""; }; E942DE6D253BE5E100AC532D /* Loop Intent Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Intent Extension.entitlements"; sourceTree = ""; }; + E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManagerDosingTests.swift; sourceTree = ""; }; + E950CA9229002E1A00B5B692 /* LoopDataManagerUAMTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManagerUAMTests.swift; sourceTree = ""; }; E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseStoreProtocol.swift; sourceTree = ""; }; E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbStoreProtocol.swift; sourceTree = ""; }; E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStoreProtocol.swift; sourceTree = ""; }; @@ -1683,10 +1687,13 @@ isa = PBXGroup; children = ( 1DA7A84024476E98008257F0 /* Alerts */, + E950CA9429002E4B00B5B692 /* LoopDataManager */, C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, + E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */, + E950CA9229002E1A00B5B692 /* LoopDataManagerUAMTests.swift */, 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */, A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */, ); @@ -2693,6 +2700,13 @@ path = high_and_stable; sourceTree = ""; }; + E950CA9429002E4B00B5B692 /* LoopDataManager */ = { + isa = PBXGroup; + children = ( + ); + path = LoopDataManager; + sourceTree = ""; + }; E95D37FF24EADE68005E2F50 /* Store Protocols */ = { isa = PBXGroup; children = ( @@ -3784,6 +3798,8 @@ 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */, A9BD28E7272226B40071DF15 /* TestLocalizedError.swift in Sources */, A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */, + E950CA9329002E1A00B5B692 /* LoopDataManagerUAMTests.swift in Sources */, + E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */, B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */, A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */, diff --git a/LoopTests/Managers/LoopDataManagerUAMTests.swift b/LoopTests/Managers/LoopDataManagerUAMTests.swift new file mode 100644 index 0000000000..427c28ba17 --- /dev/null +++ b/LoopTests/Managers/LoopDataManagerUAMTests.swift @@ -0,0 +1,49 @@ +// +// LoopDataManagerUAMTests.swift +// LoopTests +// +// Created by Anna Quinlan on 10/19/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +@testable import LoopCore +@testable import Loop + +class LoopDataManagerUAMTests: LoopDataManagerTests { + override func tearDownWithError() throws { + loopDataManager.lastUAMNotificationDeliveryTime = nil + try super.tearDownWithError() + } + + func testNoUnannouncedMealLastNotificationTime() { + setUp(for: .highAndRisingWithCOB) + XCTAssertNil(loopDataManager.lastUAMNotificationDeliveryTime) + + let status = UnannouncedMealStatus.noUnannouncedMeal + loopDataManager.manageMealNotifications(for: status) + XCTAssertNil(loopDataManager.lastUAMNotificationDeliveryTime) + } + + func testUnannouncedMealUpdatesLastNotificationTime() { + setUp(for: .highAndRisingWithCOB) + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + loopDataManager.manageMealNotifications(for: status) + XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, now) + } + + func testUnannouncedMealWithTooRecentNotificationTime() { + setUp(for: .highAndRisingWithCOB) + + let oldTime = now.addingTimeInterval(.hours(1)) + loopDataManager.lastUAMNotificationDeliveryTime = oldTime + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + loopDataManager.manageMealNotifications(for: status) + XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, oldTime) + } +} + From c96c00514b221912941d9a6bbdb69952c492c9eb Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 19 Oct 2022 06:33:10 -0700 Subject: [PATCH 20/51] Fix concurrency issue --- Loop/Managers/LoopDataManager.swift | 36 ++++++++++++------------- Loop/Managers/NotificationManager.swift | 3 ++- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 6bbbeb12b8..4ce96480ed 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1445,29 +1445,27 @@ extension LoopDataManager { self?.manageMealNotifications(for: status) } } - - private func manageMealNotifications(for status: UnannouncedMealStatus) { + // Internal for unit testing internal func manageMealNotifications(for status: UnannouncedMealStatus) { // We should remove expired notifications regardless of whether or not there was a meal - NotificationManager.removeExpiredMealNotifications { [weak self] in - guard let self = self else { return } - - let now = self.now() - let notificationTimeTooRecent = now.timeIntervalSince(self.lastUAMNotificationDeliveryTime ?? .distantPast) < (CarbStore.unannouncedMealMaxRecency - CarbStore.unannouncedMealMinRecency) - - guard - case .hasUnannouncedMeal(let startTime) = status, - !notificationTimeTooRecent - else { - // No notification needed! - return - } - - self.logger.debug("Delivering a missed meal notification") - self.lastUAMNotificationDeliveryTime = now - NotificationManager.sendUnannouncedMealNotification(mealStart: startTime) + NotificationManager.removeExpiredMealNotifications() + + // Figure out if we should deliver a notification + let now = now() + let notificationTimeTooRecent = now.timeIntervalSince(lastUAMNotificationDeliveryTime ?? .distantPast) < (CarbStore.unannouncedMealMaxRecency - CarbStore.unannouncedMealMinRecency) + + guard + case .hasUnannouncedMeal(let startTime) = status, + !notificationTimeTooRecent + else { + // No notification needed! + return } + + logger.debug("Delivering a missed meal notification") + lastUAMNotificationDeliveryTime = now + NotificationManager.sendUnannouncedMealNotification(mealStart: startTime) } /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 7a66d8bc9d..6c4efda5ce 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -226,12 +226,13 @@ extension NotificationManager { } static func removeExpiredMealNotifications(completion: @escaping () -> Void) { + static func removeExpiredMealNotifications(completion: (() -> Void)? = nil) { let notificationCenter = UNUserNotificationCenter.current() var identifiersToRemove: [String] = [] notificationCenter.getPendingNotificationRequests { requests in defer { - completion() + completion?() } for request in requests { From 20202fb18a3b0e6c21b4816ca55e516eceb29c30 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 19 Oct 2022 06:57:02 -0700 Subject: [PATCH 21/51] Pull UAM constants into separate struct --- Loop/Managers/LoopAppManager.swift | 2 +- Loop/Managers/LoopDataManager.swift | 2 +- LoopTests/Managers/LoopDataManagerUAMTests.swift | 1 - WatchApp Extension/ExtensionDelegate.swift | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 9f8ee31164..2a1025a091 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -481,7 +481,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { if let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date { let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), - doubleValue: CarbStore.unannouncedMealCarbThreshold), + doubleValue: UAMSettings.carbThreshold), startDate: mealTime, foodType: nil, absorptionTime: nil) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 4ce96480ed..f8226c9222 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1453,7 +1453,7 @@ extension LoopDataManager { // Figure out if we should deliver a notification let now = now() - let notificationTimeTooRecent = now.timeIntervalSince(lastUAMNotificationDeliveryTime ?? .distantPast) < (CarbStore.unannouncedMealMaxRecency - CarbStore.unannouncedMealMinRecency) + let notificationTimeTooRecent = now.timeIntervalSince(lastUAMNotificationDeliveryTime ?? .distantPast) < (UAMSettings.maxRecency - UAMSettings.minRecency) guard case .hasUnannouncedMeal(let startTime) = status, diff --git a/LoopTests/Managers/LoopDataManagerUAMTests.swift b/LoopTests/Managers/LoopDataManagerUAMTests.swift index 427c28ba17..b91e025676 100644 --- a/LoopTests/Managers/LoopDataManagerUAMTests.swift +++ b/LoopTests/Managers/LoopDataManagerUAMTests.swift @@ -20,7 +20,6 @@ class LoopDataManagerUAMTests: LoopDataManagerTests { func testNoUnannouncedMealLastNotificationTime() { setUp(for: .highAndRisingWithCOB) - XCTAssertNil(loopDataManager.lastUAMNotificationDeliveryTime) let status = UnannouncedMealStatus.noUnannouncedMeal loopDataManager.manageMealNotifications(for: status) diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index ba50235359..d489880ddd 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -260,7 +260,7 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { // If we have info about a meal, the carb entry UI should reflect it if let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date { let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), - doubleValue: CarbStore.unannouncedMealCarbThreshold), + doubleValue: UAMSettings.carbThreshold), startDate: mealTime, foodType: nil, absorptionTime: nil) From a19311f51d24786155b3c5fd7ebf7916d86f9be6 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Sun, 23 Oct 2022 17:10:46 -0400 Subject: [PATCH 22/51] Fix notifications not being retracted after their expiration date --- Loop/Managers/NotificationManager.swift | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 6c4efda5ce..a9e6687498 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -204,7 +204,7 @@ extension NotificationManager { static func sendUnannouncedMealNotification(mealStart: Date) { let notification = UNMutableNotificationContent() - // Notifications should expire after the missed meal is no longer relevant + /// Notifications should expire after the missed meal is no longer relevant let expirationDate = Date().addingTimeInterval(LoopCoreConstants.defaultCarbAbsorptionTimes.slow) notification.title = String(format: NSLocalizedString("Possible Unannounced Meal", comment: "The notification title for a meal that was possibly not logged in Loop.")) @@ -217,6 +217,7 @@ extension NotificationManager { ] let request = UNNotificationRequest( + /// We use the same `identifier` for all requests so a newer missed meal notification will replace a current one (if it exists) identifier: LoopNotificationCategory.unannouncedMeal.rawValue, content: notification, trigger: nil @@ -224,35 +225,39 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - - static func removeExpiredMealNotifications(completion: @escaping () -> Void) { - static func removeExpiredMealNotifications(completion: (() -> Void)? = nil) { + + static func removeExpiredMealNotifications(now: Date = Date(), completion: (() -> Void)? = nil) { let notificationCenter = UNUserNotificationCenter.current() var identifiersToRemove: [String] = [] - notificationCenter.getPendingNotificationRequests { requests in + notificationCenter.getDeliveredNotifications { notifications in defer { completion?() } - for request in requests { + for notification in notifications { + let request = notification.request + guard request.identifier == LoopNotificationCategory.unannouncedMeal.rawValue, let expirationDate = request.content.userInfo[LoopNotificationUserInfoKey.expirationDate.rawValue] as? Date, - expirationDate < Date() + expirationDate < now else { continue } - // The notification is expired: mark it for removal + /// The notification is expired: mark it for removal identifiersToRemove.append(request.identifier) + /// We can break early because all missed meal notifications have the same `identifier`, + /// so there will only ever be 1 outstanding missed meal notification + break } guard identifiersToRemove.count > 0 else { return } - notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiersToRemove) + notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToRemove) } } From 85cb4c5bd55bc9233976e823898f84ee2fc04e0e Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Sun, 23 Oct 2022 19:28:26 -0400 Subject: [PATCH 23/51] Expire using start of meal instead of current time --- Loop/Managers/NotificationManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index a9e6687498..666211cff1 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -205,7 +205,7 @@ extension NotificationManager { static func sendUnannouncedMealNotification(mealStart: Date) { let notification = UNMutableNotificationContent() /// Notifications should expire after the missed meal is no longer relevant - let expirationDate = Date().addingTimeInterval(LoopCoreConstants.defaultCarbAbsorptionTimes.slow) + let expirationDate = mealStart.addingTimeInterval(LoopCoreConstants.defaultCarbAbsorptionTimes.slow) notification.title = String(format: NSLocalizedString("Possible Unannounced Meal", comment: "The notification title for a meal that was possibly not logged in Loop.")) notification.body = String(format: NSLocalizedString("It looks like you may not have logged a meal you ate. Tap to log it now.", comment: "The notification description for a meal that was possibly not logged in Loop.")) From c9195c60e4c8b0a9791f7bb1d8237e50c6a883f7 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Mon, 7 Nov 2022 17:21:32 -0800 Subject: [PATCH 24/51] Add unannounced meal notifications permission --- Loop/Managers/LoopDataManager.swift | 3 +- .../StatusTableViewController.swift | 2 +- .../AlertPermissionsViewModel.swift | 41 +++++++++++++++++++ Loop/View Models/SettingsViewModel.swift | 10 ++--- ...icationsCriticalAlertPermissionsView.swift | 31 ++++++++------ Loop/Views/SettingsView.swift | 6 +-- .../Managers/LoopDataManagerUAMTests.swift | 17 ++++++++ 7 files changed, 88 insertions(+), 22 deletions(-) create mode 100644 Loop/View Models/AlertPermissionsViewModel.swift diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index f8226c9222..f1dc6aa18c 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1457,7 +1457,8 @@ extension LoopDataManager { guard case .hasUnannouncedMeal(let startTime) = status, - !notificationTimeTooRecent + !notificationTimeTooRecent, + UserDefaults.standard.unannouncedMealNotificationsEnabled else { // No notification needed! return diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 23dda7dc1a..30598cde00 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1406,7 +1406,7 @@ final class StatusTableViewController: LoopChartsTableViewController { activeServices: { [weak self] in self?.deviceManager.servicesManager.activeServices ?? [] }, delegate: self) let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) - let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, + let viewModel = SettingsViewModel(alertPermissionsViewModel: AlertPermissionsViewModel(checker: alertPermissionsChecker), versionUpdateViewModel: versionUpdateViewModel, pumpManagerSettingsViewModel: pumpViewModel, cgmManagerSettingsViewModel: cgmViewModel, diff --git a/Loop/View Models/AlertPermissionsViewModel.swift b/Loop/View Models/AlertPermissionsViewModel.swift new file mode 100644 index 0000000000..3356d4c394 --- /dev/null +++ b/Loop/View Models/AlertPermissionsViewModel.swift @@ -0,0 +1,41 @@ +// +// AlertPermissionsViewModel.swift +// Loop +// +// Created by Anna Quinlan on 11/7/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +public class AlertPermissionsViewModel: ObservableObject { + @Published var unannouncedMealNotificationsEnabled: Bool { + didSet { + UserDefaults.standard.unannouncedMealNotificationsEnabled = unannouncedMealNotificationsEnabled + } + } + + @Published var checker: AlertPermissionsChecker + + init(checker: AlertPermissionsChecker) { + self.unannouncedMealNotificationsEnabled = UserDefaults.standard.unannouncedMealNotificationsEnabled + self.checker = checker + } +} + + +extension UserDefaults { + + private enum Key: String { + case unannouncedMealNotificationsEnabled = "com.loopkit.Loop.UnannouncedMealNotificationsEnabled" + } + + var unannouncedMealNotificationsEnabled: Bool { + get { + return object(forKey: Key.unannouncedMealNotificationsEnabled.rawValue) as? Bool ?? false + } + set { + set(newValue, forKey: Key.unannouncedMealNotificationsEnabled.rawValue) + } + } +} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 96f705476b..67fd24b936 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -58,7 +58,7 @@ public protocol SettingsViewModelDelegate: AnyObject { public class SettingsViewModel: ObservableObject { - let alertPermissionsChecker: AlertPermissionsChecker + let alertPermissionsViewModel: AlertPermissionsViewModel let versionUpdateViewModel: VersionUpdateViewModel @@ -100,7 +100,7 @@ public class SettingsViewModel: ObservableObject { lazy private var cancellables = Set() - public init(alertPermissionsChecker: AlertPermissionsChecker, + public init(alertPermissionsViewModel: AlertPermissionsViewModel, versionUpdateViewModel: VersionUpdateViewModel, pumpManagerSettingsViewModel: PumpManagerViewModel, cgmManagerSettingsViewModel: CGMManagerViewModel, @@ -117,7 +117,7 @@ public class SettingsViewModel: ObservableObject { therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, delegate: SettingsViewModelDelegate? ) { - self.alertPermissionsChecker = alertPermissionsChecker + self.alertPermissionsViewModel = alertPermissionsViewModel self.versionUpdateViewModel = versionUpdateViewModel self.pumpManagerSettingsViewModel = pumpManagerSettingsViewModel self.cgmManagerSettingsViewModel = cgmManagerSettingsViewModel @@ -135,7 +135,7 @@ public class SettingsViewModel: ObservableObject { self.delegate = delegate // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) - alertPermissionsChecker.objectWillChange.sink { [weak self] in + alertPermissionsViewModel.checker.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } .store(in: &cancellables) @@ -177,7 +177,7 @@ extension SettingsViewModel { } static var preview: SettingsViewModel { - return SettingsViewModel(alertPermissionsChecker: AlertPermissionsChecker(), + return SettingsViewModel(alertPermissionsViewModel: AlertPermissionsViewModel(checker: AlertPermissionsChecker()) , versionUpdateViewModel: VersionUpdateViewModel(supportManager: nil, guidanceColors: GuidanceColors()), pumpManagerSettingsViewModel: DeviceViewModel(), cgmManagerSettingsViewModel: DeviceViewModel(), diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index ea56d5a53b..2efb33bb82 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -14,7 +14,7 @@ public struct NotificationsCriticalAlertPermissionsView: View { @Environment(\.appName) private var appName private let backButtonText: String - @ObservedObject private var checker: AlertPermissionsChecker + @ObservedObject private var viewModel: AlertPermissionsViewModel // TODO: This screen is used in both the 'old Settings UI' and the 'new Settings UI'. This is temporary. // In the old UI, it is a "top level" navigation view. In the new UI, it is just part of the "flow". This @@ -24,9 +24,9 @@ public struct NotificationsCriticalAlertPermissionsView: View { } private let mode: PresentationMode - public init(backButtonText: String = "", mode: PresentationMode = .topLevel, checker: AlertPermissionsChecker) { + public init(backButtonText: String = "", mode: PresentationMode = .topLevel, viewModel: AlertPermissionsViewModel) { self.backButtonText = backButtonText - self.checker = checker + self.viewModel = viewModel self.mode = mode } @@ -54,17 +54,18 @@ public struct NotificationsCriticalAlertPermissionsView: View { manageNotifications notificationsEnabledStatus if #available(iOS 15.0, *) { - if !checker.notificationCenterSettings.notificationsDisabled { + if !viewModel.checker.notificationCenterSettings.notificationsDisabled { notificationDelivery } } criticalAlertsStatus if #available(iOS 15.0, *) { - if !checker.notificationCenterSettings.notificationsDisabled { + if !viewModel.checker.notificationCenterSettings.notificationsDisabled { timeSensitiveStatus } } } + unannouncedMealAlertSection notificationAndCriticalAlertPermissionSupportSection } .insetGroupedListStyle() @@ -87,7 +88,7 @@ extension NotificationsCriticalAlertPermissionsView { } private var manageNotifications: some View { - Button( action: { self.checker.gotoSettings() } ) { + Button( action: { self.viewModel.checker.gotoSettings() } ) { HStack { Text(NSLocalizedString("Manage Permissions in Settings", comment: "Manage Permissions in Settings button text")) Spacer() @@ -101,7 +102,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Notifications", comment: "Notifications Status text") Spacer() - onOff(!checker.notificationCenterSettings.notificationsDisabled) + onOff(!viewModel.checker.notificationCenterSettings.notificationsDisabled) } } @@ -109,7 +110,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Critical Alerts", comment: "Critical Alerts Status text") Spacer() - onOff(!checker.notificationCenterSettings.criticalAlertsDisabled) + onOff(!viewModel.checker.notificationCenterSettings.criticalAlertsDisabled) } } @@ -118,7 +119,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Time Sensitive Notifications", comment: "Time Sensitive Status text") Spacer() - onOff(!checker.notificationCenterSettings.timeSensitiveNotificationsDisabled) + onOff(!viewModel.checker.notificationCenterSettings.timeSensitiveNotificationsDisabled) } } @@ -127,7 +128,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Notification Delivery", comment: "Notification Delivery Status text") Spacer() - if checker.notificationCenterSettings.scheduledDeliveryEnabled { + if viewModel.checker.notificationCenterSettings.scheduledDeliveryEnabled { Image(systemName: "exclamationmark.triangle.fill").foregroundColor(.critical) Text("Scheduled", comment: "Scheduled Delivery status text") } else { @@ -135,6 +136,12 @@ extension NotificationsCriticalAlertPermissionsView { } } } + + private var unannouncedMealAlertSection: some View { + Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects that you may have forgotten to log a meal.", comment: "Description of unannounced meal notifications."))) { + Toggle("Missed Meal Notifications", isOn: $viewModel.unannouncedMealNotificationsEnabled) + } + } private var notificationAndCriticalAlertPermissionSupportSection: some View { Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "Section title for Support"))) { @@ -149,11 +156,11 @@ extension NotificationsCriticalAlertPermissionsView { struct NotificationsCriticalAlertPermissionsView_Previews: PreviewProvider { static var previews: some View { return Group { - NotificationsCriticalAlertPermissionsView(checker: AlertPermissionsChecker()) + NotificationsCriticalAlertPermissionsView(viewModel: AlertPermissionsViewModel(checker: AlertPermissionsChecker())) .colorScheme(.light) .previewDevice(PreviewDevice(rawValue: "iPhone SE")) .previewDisplayName("SE light") - NotificationsCriticalAlertPermissionsView(checker: AlertPermissionsChecker()) + NotificationsCriticalAlertPermissionsView(viewModel: AlertPermissionsViewModel(checker: AlertPermissionsChecker())) .colorScheme(.dark) .previewDevice(PreviewDevice(rawValue: "iPhone XS Max")) .previewDisplayName("XS Max dark") diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 6475cc08a2..f0bd876220 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -124,12 +124,12 @@ extension SettingsView { private var alertPermissionsSection: some View { Section { NavigationLink(destination: - NotificationsCriticalAlertPermissionsView(mode: .flow, checker: viewModel.alertPermissionsChecker)) + NotificationsCriticalAlertPermissionsView(mode: .flow, viewModel: viewModel.alertPermissionsViewModel)) { HStack { Text(NSLocalizedString("Alert Permissions", comment: "Alert Permissions button text")) - if viewModel.alertPermissionsChecker.showWarning || - viewModel.alertPermissionsChecker.notificationCenterSettings.scheduledDeliveryEnabled { + if viewModel.alertPermissionsViewModel.checker.showWarning || + viewModel.alertPermissionsViewModel.checker.notificationCenterSettings.scheduledDeliveryEnabled { Spacer() Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.critical) diff --git a/LoopTests/Managers/LoopDataManagerUAMTests.swift b/LoopTests/Managers/LoopDataManagerUAMTests.swift index b91e025676..844910c3c1 100644 --- a/LoopTests/Managers/LoopDataManagerUAMTests.swift +++ b/LoopTests/Managers/LoopDataManagerUAMTests.swift @@ -15,33 +15,50 @@ import LoopKit class LoopDataManagerUAMTests: LoopDataManagerTests { override func tearDownWithError() throws { loopDataManager.lastUAMNotificationDeliveryTime = nil + UserDefaults.standard.unannouncedMealNotificationsEnabled = false try super.tearDownWithError() } func testNoUnannouncedMealLastNotificationTime() { setUp(for: .highAndRisingWithCOB) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true let status = UnannouncedMealStatus.noUnannouncedMeal loopDataManager.manageMealNotifications(for: status) + XCTAssertNil(loopDataManager.lastUAMNotificationDeliveryTime) } func testUnannouncedMealUpdatesLastNotificationTime() { setUp(for: .highAndRisingWithCOB) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) loopDataManager.manageMealNotifications(for: status) + XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, now) } + + func testUnannouncedMealWithoutNotificationsEnabled() { + setUp(for: .highAndRisingWithCOB) + UserDefaults.standard.unannouncedMealNotificationsEnabled = false + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + loopDataManager.manageMealNotifications(for: status) + + XCTAssertNil(loopDataManager.lastUAMNotificationDeliveryTime) + } func testUnannouncedMealWithTooRecentNotificationTime() { setUp(for: .highAndRisingWithCOB) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true let oldTime = now.addingTimeInterval(.hours(1)) loopDataManager.lastUAMNotificationDeliveryTime = oldTime let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) loopDataManager.manageMealNotifications(for: status) + XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, oldTime) } } From 2d9bf9f2ed64b061b85ee0a2b270a7847e205334 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Mon, 7 Nov 2022 17:22:01 -0800 Subject: [PATCH 25/51] Add AlertPermissionsViewModel --- Loop.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 6104c63398..3e526bd0f1 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -579,6 +579,7 @@ E9C58A7E24DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7924DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json */; }; E9C58A7F24DB529A00487A17 /* counteraction_effect_falling_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7A24DB529A00487A17 /* counteraction_effect_falling_glucose.json */; }; E9C58A8024DB529A00487A17 /* insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7B24DB529A00487A17 /* insulin_effect.json */; }; + E9CECC8A2919CFCE004B8BE8 /* AlertPermissionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CECC892919CFCE004B8BE8 /* AlertPermissionsViewModel.swift */; }; E9E5E56024D3519700B5DFFE /* far_future_high_bg_forecast_after_6_hours.json in Resources */ = {isa = PBXBuildFile; fileRef = E9E5E55F24D3519700B5DFFE /* far_future_high_bg_forecast_after_6_hours.json */; }; /* End PBXBuildFile section */ @@ -1514,6 +1515,7 @@ E9C58A7924DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = dynamic_glucose_effect_partially_observed.json; sourceTree = ""; }; E9C58A7A24DB529A00487A17 /* counteraction_effect_falling_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = counteraction_effect_falling_glucose.json; sourceTree = ""; }; E9C58A7B24DB529A00487A17 /* insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = insulin_effect.json; sourceTree = ""; }; + E9CECC892919CFCE004B8BE8 /* AlertPermissionsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPermissionsViewModel.swift; sourceTree = ""; }; E9E5E55F24D3519700B5DFFE /* far_future_high_bg_forecast_after_6_hours.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = far_future_high_bg_forecast_after_6_hours.json; sourceTree = ""; }; F5D9C01727DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Intents.strings; sourceTree = ""; }; F5D9C01827DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/LaunchScreen.strings; sourceTree = ""; }; @@ -2441,6 +2443,7 @@ 897A5A9724C22DCE00C4E71D /* View Models */ = { isa = PBXGroup; children = ( + E9CECC892919CFCE004B8BE8 /* AlertPermissionsViewModel.swift */, 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */, A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */, C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */, @@ -3595,6 +3598,7 @@ C18913B52524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift in Sources */, B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */, 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */, + E9CECC8A2919CFCE004B8BE8 /* AlertPermissionsViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 6449982ec27aba7aff30322fba22d0663ac3eadb Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Mon, 7 Nov 2022 17:22:05 -0800 Subject: [PATCH 26/51] Remove unneeded `AnyView`s --- Loop/Views/NotificationsCriticalAlertPermissionsView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index 2efb33bb82..e17227b86f 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -30,10 +30,11 @@ public struct NotificationsCriticalAlertPermissionsView: View { self.mode = mode } + @ViewBuilder public var body: some View { switch mode { - case .flow: return AnyView(content()) - case .topLevel: return AnyView(navigationContent()) + case .flow: content() + case .topLevel: navigationContent() } } From 5224f5782ebd4c46f083e0b1609847235191149e Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 8 Nov 2022 09:27:41 -0800 Subject: [PATCH 27/51] Removed unused completion block --- Loop/Managers/NotificationManager.swift | 8 ++------ .../Views/NotificationsCriticalAlertPermissionsView.swift | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 666211cff1..c906362538 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -226,15 +226,11 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - static func removeExpiredMealNotifications(now: Date = Date(), completion: (() -> Void)? = nil) { + static func removeExpiredMealNotifications(now: Date = Date()) { let notificationCenter = UNUserNotificationCenter.current() var identifiersToRemove: [String] = [] - notificationCenter.getDeliveredNotifications { notifications in - defer { - completion?() - } - + notificationCenter.getDeliveredNotifications { notifications in for notification in notifications { let request = notification.request diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index e17227b86f..1d4540743e 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -139,7 +139,7 @@ extension NotificationsCriticalAlertPermissionsView { } private var unannouncedMealAlertSection: some View { - Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects that you may have forgotten to log a meal.", comment: "Description of unannounced meal notifications."))) { + Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects a meal that wasn't logged.", comment: "Description of unannounced meal notifications."))) { Toggle("Missed Meal Notifications", isOn: $viewModel.unannouncedMealNotificationsEnabled) } } From 759052a62092dd4531fae53653480b55a1079f6f Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 9 Nov 2022 08:31:08 -0800 Subject: [PATCH 28/51] Use the counteraction effects passed into the function --- Loop/Managers/LoopDataManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index f1dc6aa18c..98d1354f43 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1441,7 +1441,7 @@ extension LoopDataManager { } public func generateUnannouncedMealNotificationIfNeeded(using insulinCounteractionEffects: [GlucoseEffectVelocity]) { - carbStore.hasUnannouncedMeal(insulinCounteractionEffects: self.insulinCounteractionEffects) {[weak self] status in + carbStore.hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in self?.manageMealNotifications(for: status) } } From f43c7ecc1e7f36782f4320dc2f39b2ae1f60bf6f Mon Sep 17 00:00:00 2001 From: Anna Quinlan <31571514+novalegra@users.noreply.github.com> Date: Wed, 16 Nov 2022 19:02:03 -0800 Subject: [PATCH 29/51] Schedule missed meal notifications to avoid notification during an microbolus (#3) * Delay missed meal notification if possible to avoid delivering it during an autobolus * Add tests for notification delay --- Loop/Managers/DeviceDataManager.swift | 4 ++ Loop/Managers/LoopDataManager.swift | 37 ++++++++++--- Loop/Managers/NotificationManager.swift | 10 +++- LoopTests/Managers/DoseEnactorTests.swift | 6 ++ .../Managers/LoopDataManagerDosingTests.swift | 36 +++++++----- .../Managers/LoopDataManagerUAMTests.swift | 55 ++++++++++++++++++- 6 files changed, 123 insertions(+), 25 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 98ce78d718..7727a67a0e 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1276,6 +1276,10 @@ extension DeviceDataManager: LoopDataManagerDelegate { return rounded } + + func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration bolusUnits: Double) -> TimeInterval? { + pumpManager?.estimatedDuration(toDeliver: bolusUnits) + } func loopDataManager( _ manager: LoopDataManager, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 98d1354f43..a8f8a9acc7 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1056,8 +1056,6 @@ extension LoopDataManager { return (dosingDecision, nil) } - - generateUnannouncedMealNotificationIfNeeded(using: insulinCounteractionEffects) return updatePredictedGlucoseAndRecommendedDose(with: dosingDecision) } @@ -1440,14 +1438,14 @@ extension LoopDataManager { ) } - public func generateUnannouncedMealNotificationIfNeeded(using insulinCounteractionEffects: [GlucoseEffectVelocity]) { + public func generateUnannouncedMealNotificationIfNeeded(using insulinCounteractionEffects: [GlucoseEffectVelocity], pendingAutobolusUnits: Double? = nil) { carbStore.hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in - self?.manageMealNotifications(for: status) + self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits) } } // Internal for unit testing - internal func manageMealNotifications(for status: UnannouncedMealStatus) { + internal func manageMealNotifications(for status: UnannouncedMealStatus, pendingAutobolusUnits: Double? = nil) { // We should remove expired notifications regardless of whether or not there was a meal NotificationManager.removeExpiredMealNotifications() @@ -1465,8 +1463,21 @@ extension LoopDataManager { } logger.debug("Delivering a missed meal notification") - lastUAMNotificationDeliveryTime = now - NotificationManager.sendUnannouncedMealNotification(mealStart: startTime) + + /// Coordinate the unannounced meal notification time with any pending autoboluses that `update` may have started + /// so that the user doesn't have to cancel the current autobolus to bolus in response to the missed meal notification + if + let pendingAutobolusUnits, + pendingAutobolusUnits > 0, + let estimatedBolusDuration = delegate?.loopDataManager(self, estimateBolusDuration: pendingAutobolusUnits), + estimatedBolusDuration < UAMSettings.maxNotificationDelay + { + NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, delay: estimatedBolusDuration) + lastUAMNotificationDeliveryTime = now.advanced(by: estimatedBolusDuration) + } else { + NotificationManager.sendUnannouncedMealNotification(mealStart: startTime) + lastUAMNotificationDeliveryTime = now + } } /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. @@ -1693,6 +1704,9 @@ extension LoopDataManager { /// *This method should only be called from the `dataAccessQueue`* private func enactRecommendedAutomaticDose() -> LoopError? { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + generateUnannouncedMealNotificationIfNeeded(using: insulinCounteractionEffects, + pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits) guard let recommendedDose = self.recommendedAutomaticDose else { return nil @@ -2118,7 +2132,14 @@ protocol LoopDataManagerDelegate: AnyObject { /// - rate: The recommended rate in U/hr /// - Returns: a supported rate of delivery in Units/hr. The rate returned should not be larger than the passed in rate. func loopDataManager(_ manager: LoopDataManager, roundBasalRate unitsPerHour: Double) -> Double - + + /// Asks the delegate to estimate the duration to deliver the bolus. + /// + /// - Parameters: + /// - bolusUnits: size of the bolus in U + /// - Returns: the estimated time it will take to deliver bolus + func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration bolusUnits: Double) -> TimeInterval? + /// Asks the delegate to round a recommended bolus volume to a supported volume /// /// - Parameters: diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index c906362538..718cd6fadb 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -202,7 +202,7 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - static func sendUnannouncedMealNotification(mealStart: Date) { + static func sendUnannouncedMealNotification(mealStart: Date, delay: TimeInterval? = nil) { let notification = UNMutableNotificationContent() /// Notifications should expire after the missed meal is no longer relevant let expirationDate = mealStart.addingTimeInterval(LoopCoreConstants.defaultCarbAbsorptionTimes.slow) @@ -215,12 +215,18 @@ extension NotificationManager { LoopNotificationUserInfoKey.unannouncedMealTime.rawValue: mealStart, LoopNotificationUserInfoKey.expirationDate.rawValue: expirationDate ] + + + var notificationTrigger: UNTimeIntervalNotificationTrigger? = nil + if let delay { + notificationTrigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false) + } let request = UNNotificationRequest( /// We use the same `identifier` for all requests so a newer missed meal notification will replace a current one (if it exists) identifier: LoopNotificationCategory.unannouncedMeal.rawValue, content: notification, - trigger: nil + trigger: notificationTrigger ) UNUserNotificationCenter.current().add(request) diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 794682ced3..9dc5c63122 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -42,6 +42,8 @@ class MockPumpManager: PumpManager { static var onboardingSupportedMaximumBolusVolumes: [Double] = [1,2,3] + let deliveryUnitsPerMinute = 1.5 + var supportedBasalRates: [Double] = [1,2,3] var supportedBolusVolumes: [Double] = [1,2,3] @@ -114,6 +116,10 @@ class MockPumpManager: PumpManager { func syncDeliveryLimits(limits deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { } + + func estimatedDuration(toDeliver bolusUnits: Double) -> TimeInterval { + .minutes(bolusUnits / deliveryUnitsPerMinute) + } var managerIdentifier: String = "MockPumpManager" diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index 0e65a5d6bb..3a5fb667ef 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -12,6 +12,28 @@ import LoopKit @testable import LoopCore @testable import Loop +class MockDelegate: LoopDataManagerDelegate { + let pumpManager = MockPumpManager() + + var bolusUnits: Double? + func loopDataManager(_ manager: Loop.LoopDataManager, estimateBolusDuration bolusUnits: Double) -> TimeInterval? { + self.bolusUnits = bolusUnits + return pumpManager.estimatedDuration(toDeliver: bolusUnits) + } + + var recommendation: AutomaticDoseRecommendation? + var error: LoopError? + func loopDataManager(_ manager: LoopDataManager, didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), completion: @escaping (LoopError?) -> Void) { + self.recommendation = automaticDose.recommendation + completion(error) + } + func loopDataManager(_ manager: LoopDataManager, roundBasalRate unitsPerHour: Double) -> Double { unitsPerHour } + func loopDataManager(_ manager: LoopDataManager, roundBolusVolume units: Double) -> Double { units } + var pumpManagerStatus: PumpManagerStatus? + var cgmManagerStatus: CGMManagerStatus? + var pumpStatusHighlight: DeviceStatusHighlight? +} + class LoopDataManagerDosingTests: LoopDataManagerTests { // MARK: Functions to load fixtures func loadGlucoseEffect(_ name: String) -> [GlucoseEffect] { @@ -187,20 +209,6 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) } - - class MockDelegate: LoopDataManagerDelegate { - var recommendation: AutomaticDoseRecommendation? - var error: LoopError? - func loopDataManager(_ manager: LoopDataManager, didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), completion: @escaping (LoopError?) -> Void) { - self.recommendation = automaticDose.recommendation - completion(error) - } - func loopDataManager(_ manager: LoopDataManager, roundBasalRate unitsPerHour: Double) -> Double { unitsPerHour } - func loopDataManager(_ manager: LoopDataManager, roundBolusVolume units: Double) -> Double { units } - var pumpManagerStatus: PumpManagerStatus? - var cgmManagerStatus: CGMManagerStatus? - var pumpStatusHighlight: DeviceStatusHighlight? - } func waitOnDataQueue(timeout: TimeInterval = 1.0) { let e = expectation(description: "dataQueue") diff --git a/LoopTests/Managers/LoopDataManagerUAMTests.swift b/LoopTests/Managers/LoopDataManagerUAMTests.swift index 844910c3c1..f582fa53af 100644 --- a/LoopTests/Managers/LoopDataManagerUAMTests.swift +++ b/LoopTests/Managers/LoopDataManagerUAMTests.swift @@ -12,13 +12,15 @@ import LoopKit @testable import LoopCore @testable import Loop -class LoopDataManagerUAMTests: LoopDataManagerTests { +class LoopDataManagerUAMTests: LoopDataManagerTests { + // MARK: Testing Utilities override func tearDownWithError() throws { loopDataManager.lastUAMNotificationDeliveryTime = nil UserDefaults.standard.unannouncedMealNotificationsEnabled = false try super.tearDownWithError() } + // MARK: Tests func testNoUnannouncedMealLastNotificationTime() { setUp(for: .highAndRisingWithCOB) UserDefaults.standard.unannouncedMealNotificationsEnabled = true @@ -61,5 +63,56 @@ class LoopDataManagerUAMTests: LoopDataManagerTests { XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, oldTime) } + + func testUnannouncedMealNoPendingBolus() { + setUp(for: .highAndRisingWithCOB) + + let delegate = MockDelegate() + loopDataManager.delegate = delegate + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 0) + + /// The bolus units time delegate should never be called if there are 0 pending units + XCTAssertNil(delegate.bolusUnits) + XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, now) + } + + func testUnannouncedMealLongPendingBolus() { + setUp(for: .highAndRisingWithCOB) + + let delegate = MockDelegate() + loopDataManager.delegate = delegate + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10) + + XCTAssertEqual(delegate.bolusUnits, 10) + XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, now) + } + + func testNoUnannouncedMealShortPendingBolus_DelaysNotificationTime() { + setUp(for: .highAndRisingWithCOB) + + let delegate = MockDelegate() + loopDataManager.delegate = delegate + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 2) + + let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(80)) + XCTAssertEqual(delegate.bolusUnits, 2) + XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, expectedDeliveryTime) + + loopDataManager.lastUAMNotificationDeliveryTime = nil + loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5) + + let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) + XCTAssertEqual(delegate.bolusUnits, 4.5) + XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, expectedDeliveryTime2) + } } From d0af6ad8306c278815469216b3303d0a00556c79 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Thu, 17 Nov 2022 15:45:10 -0800 Subject: [PATCH 30/51] Update `estimatedDuration` function headers in response to PR feedback --- Loop/Managers/DeviceDataManager.swift | 4 ++-- LoopTests/Managers/DoseEnactorTests.swift | 4 ++-- LoopTests/Managers/LoopDataManagerDosingTests.swift | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 7727a67a0e..487eb0665b 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1277,8 +1277,8 @@ extension DeviceDataManager: LoopDataManagerDelegate { return rounded } - func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration bolusUnits: Double) -> TimeInterval? { - pumpManager?.estimatedDuration(toDeliver: bolusUnits) + func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? { + pumpManager?.estimatedDuration(toBolus: units) } func loopDataManager( diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 9dc5c63122..93ad8c6e6b 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -117,8 +117,8 @@ class MockPumpManager: PumpManager { } - func estimatedDuration(toDeliver bolusUnits: Double) -> TimeInterval { - .minutes(bolusUnits / deliveryUnitsPerMinute) + func estimatedDuration(toBolus units: Double) -> TimeInterval { + .minutes(units / deliveryUnitsPerMinute) } var managerIdentifier: String = "MockPumpManager" diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index 3a5fb667ef..2c2c1b4414 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -16,9 +16,9 @@ class MockDelegate: LoopDataManagerDelegate { let pumpManager = MockPumpManager() var bolusUnits: Double? - func loopDataManager(_ manager: Loop.LoopDataManager, estimateBolusDuration bolusUnits: Double) -> TimeInterval? { - self.bolusUnits = bolusUnits - return pumpManager.estimatedDuration(toDeliver: bolusUnits) + func loopDataManager(_ manager: Loop.LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? { + self.bolusUnits = units + return pumpManager.estimatedDuration(toBolus: units) } var recommendation: AutomaticDoseRecommendation? From 7a4cb45da67516afdce802a8ac7adf85d046c3b0 Mon Sep 17 00:00:00 2001 From: Anna Quinlan <31571514+novalegra@users.noreply.github.com> Date: Fri, 25 Nov 2022 21:02:04 -0800 Subject: [PATCH 31/51] Add UAM banner to carb entry screen (#5) * Add warning banner to remind the user to edit the meal estimate * Update warning text --- Loop/Managers/LoopAppManager.swift | 2 +- .../CarbEntryViewController.swift | 109 ++++++++++++------ 2 files changed, 75 insertions(+), 36 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 13aff53c20..271b8010e2 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -490,7 +490,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { startDate: mealTime, foodType: nil, absorptionTime: nil) - carbActivity.update(from: unannouncedEntry) + carbActivity.update(from: unannouncedEntry, isUnannouncedMeal: true) } rootViewController?.restoreUserActivityState(carbActivity) diff --git a/Loop/View Controllers/CarbEntryViewController.swift b/Loop/View Controllers/CarbEntryViewController.swift index 4c1a3c744d..7d3c34a45a 100644 --- a/Loop/View Controllers/CarbEntryViewController.swift +++ b/Loop/View Controllers/CarbEntryViewController.swift @@ -13,6 +13,20 @@ import LoopKitUI import LoopCore import LoopUI +private enum CarbEntryWarning: Equatable { + case rateOfChange + case unannouncedMeal + + var description: String { + switch self { + case .rateOfChange: + return NSLocalizedString("Your glucose is rapidly rising. Check that any carbs you've eaten were logged. If you logged carbs, check that the time you entered lines up with when you started eating.", comment: "Warning to ensure the carb entry is accurate") + case .unannouncedMeal: + return NSLocalizedString("Loop has detected an unannounced meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten.", comment: "Warning displayed when user is adding a meal from an unannounced meal notification") + } + } +} + final class CarbEntryViewController: LoopChartsTableViewController, IdentifiableClass { var navigationDelegate = CarbEntryNavigationDelegate() @@ -107,10 +121,10 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable private var shouldBeginEditingQuantity = true private var shouldBeginEditingFoodType = false - - private var shouldDisplayAccurateCarbEntryWarning = false { + + private var carbEntryWarning: CarbEntryWarning? = nil { didSet { - if shouldDisplayAccurateCarbEntryWarning != oldValue { + if carbEntryWarning != oldValue { self.displayAccuracyWarning() } } @@ -183,7 +197,7 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if shouldBeginEditingQuantity, let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.value.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayAccurateCarbEntryWarning))) as? DecimalTextFieldTableViewCell { + if shouldBeginEditingQuantity, let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.value.rawValue, section: Sections.indexForDetailsSection(warningSection: carbEntryWarning))) as? DecimalTextFieldTableViewCell { shouldBeginEditingQuantity = false cell.textField.becomeFirstResponder() } @@ -223,30 +237,51 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable DispatchQueue.main.async { switch result { case .failure: - self?.shouldDisplayAccurateCarbEntryWarning = false + self?.removeRateOfChangeCarbWarning() case .success(let samples): let filteredSamples = samples.filterDateRange(startDate, now) guard let startSample = filteredSamples.first, let endSample = filteredSamples.last else { - self?.shouldDisplayAccurateCarbEntryWarning = false + self?.removeRateOfChangeCarbWarning() return } let duration = endSample.startDate.timeIntervalSince(startSample.startDate) guard duration >= LoopConstants.missedMealWarningVelocitySampleMinDuration else { - self?.shouldDisplayAccurateCarbEntryWarning = false + self?.removeRateOfChangeCarbWarning() return } let delta = endSample.quantity.doubleValue(for: .milligramsPerDeciliter) - startSample.quantity.doubleValue(for: .milligramsPerDeciliter) let velocity = delta / duration.minutes // Unit = mg/dL/m - self?.shouldDisplayAccurateCarbEntryWarning = velocity > LoopConstants.missedMealWarningGlucoseRiseThreshold + + if velocity > LoopConstants.missedMealWarningGlucoseRiseThreshold { + self?.addRateOfChangeCarbWarning() + } else { + self?.removeRateOfChangeCarbWarning() + } } } } } + private func addRateOfChangeCarbWarning() { + /// UAM should have display priority over a rate of change warning + if let carbEntryWarning, carbEntryWarning == .unannouncedMeal { + return + } + + self.carbEntryWarning = .rateOfChange + } + + private func removeRateOfChangeCarbWarning() { + /// We don't want to remove a `.unannouncedMeal` if it's currently set + if let carbEntryWarning, carbEntryWarning == .rateOfChange { + self.carbEntryWarning = nil + } + } + private func displayAccuracyWarning() { tableView.beginUpdates() - if shouldDisplayAccurateCarbEntryWarning { + if carbEntryWarning != nil { tableView.insertSections([Sections.warning.rawValue], with: .top) } else { tableView.deleteSections([Sections.warning.rawValue], with: .top) @@ -260,40 +295,40 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable case warning case details - static func indexForDetailsSection(displayWarningSection: Bool) -> Int { - return displayWarningSection ? Sections.details.rawValue : Sections.details.rawValue - 1 + static func indexForDetailsSection(warningSection: CarbEntryWarning?) -> Int { + return warningSection != nil ? Sections.details.rawValue : Sections.details.rawValue - 1 } - static func numberOfSections(displayWarningSection: Bool) -> Int { - return displayWarningSection ? Sections.allCases.count : Sections.allCases.count - 1 + static func numberOfSections(warningSection: CarbEntryWarning?) -> Int { + return warningSection != nil ? Sections.allCases.count : Sections.allCases.count - 1 } - static func section(for indexPath: IndexPath, displayWarningSection: Bool) -> Int { - return displayWarningSection ? indexPath.section : indexPath.section + 1 + static func section(for indexPath: IndexPath, warningSection: CarbEntryWarning?) -> Int { + return warningSection != nil ? indexPath.section : indexPath.section + 1 } - static func numberOfRows(for section: Int, displayWarningSection: Bool) -> Int { - if section == Sections.warning.rawValue && displayWarningSection { + static func numberOfRows(for section: Int, warningSection: CarbEntryWarning?) -> Int { + if section == Sections.warning.rawValue && warningSection != nil { return 1 } return DetailsRow.allCases.count } - static func footer(for section: Int, displayWarningSection: Bool) -> String? { - if section == Sections.warning.rawValue && displayWarningSection { + static func footer(for section: Int, warningSection: CarbEntryWarning?) -> String? { + if section == Sections.warning.rawValue && warningSection != nil { return nil } return NSLocalizedString("Choose a longer absorption time for larger meals, or those containing fats and proteins. This is only guidance to the algorithm and need not be exact.", comment: "Carb entry section footer text explaining absorption time") } - static func headerHeight(for section: Int, displayWarningSection: Bool) -> CGFloat { + static func headerHeight(for section: Int, warningSection: CarbEntryWarning?) -> CGFloat { return 8 } - static func footerHeight(for section: Int, displayWarningSection: Bool) -> CGFloat { - if section == Sections.warning.rawValue && displayWarningSection { + static func footerHeight(for section: Int, warningSection: CarbEntryWarning?) -> CGFloat { + if section == Sections.warning.rawValue && warningSection != nil { return 1 } @@ -309,15 +344,15 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable } override func numberOfSections(in tableView: UITableView) -> Int { - return Sections.numberOfSections(displayWarningSection: shouldDisplayAccurateCarbEntryWarning) + return Sections.numberOfSections(warningSection: carbEntryWarning) } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Sections.numberOfRows(for: section, displayWarningSection: shouldDisplayAccurateCarbEntryWarning) + return Sections.numberOfRows(for: section, warningSection: carbEntryWarning) } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch Sections(rawValue: Sections.section(for: indexPath, displayWarningSection: shouldDisplayAccurateCarbEntryWarning))! { + switch Sections(rawValue: Sections.section(for: indexPath, warningSection: carbEntryWarning))! { case .warning: let cell: UITableViewCell if let existingCell = tableView.dequeueReusableCell(withIdentifier: "CarbEntryAccuracyWarningCell") { @@ -329,7 +364,7 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable cell.imageView?.image = UIImage(systemName: "exclamationmark.triangle.fill") cell.imageView?.tintColor = .destructive cell.textLabel?.numberOfLines = 0 - cell.textLabel?.text = NSLocalizedString("Your glucose is rapidly rising. Check that any carbs you've eaten were logged. If you logged carbs, check that the time you entered lines up with when you started eating.", comment: "Warning to ensure the carb entry is accurate") + cell.textLabel?.text = carbEntryWarning?.description cell.textLabel?.font = UIFont.preferredFont(forTextStyle: .caption1) cell.textLabel?.textColor = .secondaryLabel cell.isUserInteractionEnabled = false @@ -415,7 +450,7 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - switch Sections(rawValue: Sections.section(for: indexPath, displayWarningSection: shouldDisplayAccurateCarbEntryWarning)) { + switch Sections(rawValue: Sections.section(for: indexPath, warningSection: carbEntryWarning)) { case .details: switch DetailsRow(rawValue: indexPath.row)! { case .value, .date: @@ -434,15 +469,15 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable } override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - return Sections.footer(for: section, displayWarningSection: shouldDisplayAccurateCarbEntryWarning) + return Sections.footer(for: section, warningSection: carbEntryWarning) } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return Sections.headerHeight(for: section, displayWarningSection: shouldDisplayAccurateCarbEntryWarning) + return Sections.headerHeight(for: section, warningSection: carbEntryWarning) } override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return Sections.footerHeight(for: section, displayWarningSection: shouldDisplayAccurateCarbEntryWarning) + return Sections.footerHeight(for: section, warningSection: carbEntryWarning) } // MARK: - UITableViewDelegate @@ -459,7 +494,7 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable case is FoodTypeShortcutCell: usesCustomFoodType = true shouldBeginEditingFoodType = true - tableView.reloadRows(at: [IndexPath(row: DetailsRow.foodType.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayAccurateCarbEntryWarning))], with: .none) + tableView.reloadRows(at: [IndexPath(row: DetailsRow.foodType.rawValue, section: Sections.indexForDetailsSection(warningSection: carbEntryWarning))], with: .none) default: break } @@ -485,6 +520,10 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable self.absorptionTime = absorptionTime absorptionTimeWasEdited = true } + + if activity.entryIsUnannouncedMeal { + carbEntryWarning = .unannouncedMeal + } } } @@ -605,14 +644,14 @@ extension CarbEntryViewController: FoodTypeShortcutCellDelegate { tableView.beginUpdates() usesCustomFoodType = true shouldBeginEditingFoodType = true - tableView.reloadRows(at: [IndexPath(row: DetailsRow.foodType.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayAccurateCarbEntryWarning))], with: .fade) + tableView.reloadRows(at: [IndexPath(row: DetailsRow.foodType.rawValue, section: Sections.indexForDetailsSection(warningSection: carbEntryWarning))], with: .fade) tableView.endUpdates() } if let absorptionTime = absorptionTime { self.absorptionTime = absorptionTime - if let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.absorptionTime.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayAccurateCarbEntryWarning))) as? DateAndDurationTableViewCell { + if let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.absorptionTime.rawValue, section: Sections.indexForDetailsSection(warningSection: carbEntryWarning))) as? DateAndDurationTableViewCell { cell.duration = absorptionTime } } @@ -624,7 +663,7 @@ extension CarbEntryViewController: FoodTypeShortcutCellDelegate { extension CarbEntryViewController: EmojiInputControllerDelegate { func emojiInputControllerDidAdvanceToStandardInputMode(_ controller: EmojiInputController) { - if let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.foodType.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayAccurateCarbEntryWarning))) as? TextFieldTableViewCell, let textField = cell.textField as? CustomInputTextField, textField.customInput != nil { + if let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.foodType.rawValue, section: Sections.indexForDetailsSection(warningSection: carbEntryWarning))) as? TextFieldTableViewCell, let textField = cell.textField as? CustomInputTextField, textField.customInput != nil { let customInput = textField.customInput textField.customInput = nil textField.resignFirstResponder() @@ -642,7 +681,7 @@ extension CarbEntryViewController: EmojiInputControllerDelegate { // only adjust the absorption time if it wasn't already set. absorptionTime = orderedAbsorptionTimes[section] - if let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.absorptionTime.rawValue, section: Sections.indexForDetailsSection(displayWarningSection: shouldDisplayAccurateCarbEntryWarning))) as? DateAndDurationTableViewCell { + if let cell = tableView.cellForRow(at: IndexPath(row: DetailsRow.absorptionTime.rawValue, section: Sections.indexForDetailsSection(warningSection: carbEntryWarning))) as? DateAndDurationTableViewCell { cell.duration = orderedAbsorptionTimes[section] } } From 936110bb9fceacf30174feae24ddefe53fca105f Mon Sep 17 00:00:00 2001 From: Anna Quinlan <31571514+novalegra@users.noreply.github.com> Date: Mon, 28 Nov 2022 15:48:36 -0800 Subject: [PATCH 32/51] UAM algo updates: only use directly observed carb absorption (#6) * Add ability to calculate the number of carbs in a missed meal (#4) * Plumb a customizable carb amount through UAM notification architecture * Fix merge conflict * Update tests for changes from the merge * Make UAM notifications unit testable * Add carb autofill clamping based on the user's carb threshold & max bolus * Update target order to group extensions together * Improve issue report description --- Loop.xcodeproj/project.pbxproj | 8 ++- Loop/Managers/LoopAppManager.swift | 7 ++- Loop/Managers/LoopDataManager.swift | 31 ++++++++---- Loop/Managers/NotificationManager.swift | 3 +- LoopCore/NSUserDefaults.swift | 22 +++++--- LoopCore/UAMNotification.swift | 20 ++++++++ LoopTests/Managers/LoopDataManagerTests.swift | 8 +++ .../Managers/LoopDataManagerUAMTests.swift | 50 ++++++++++++------- WatchApp Extension/ExtensionDelegate.swift | 7 ++- 9 files changed, 117 insertions(+), 39 deletions(-) create mode 100644 LoopCore/UAMNotification.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index bfa4dd5ed6..6d570366a2 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -593,6 +593,8 @@ E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */; }; + E9B3551D292844950076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; + E9B3551E292844B90076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BB27AA23B85C3500FB4987 /* SleepStore.swift */; }; E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EEF24C620EF00628F35 /* LoopSettings.swift */; }; E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EEF24C620EF00628F35 /* LoopSettings.swift */; }; @@ -1553,6 +1555,7 @@ E9B07FED253BBC7100BAD8F8 /* OverrideIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideIntentHandler.swift; sourceTree = ""; }; E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+LoopIntents.swift"; sourceTree = ""; }; E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentExtensionInfo.swift; sourceTree = ""; }; + E9B3551B292844010076AB04 /* UAMNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UAMNotification.swift; sourceTree = ""; }; E9BB27AA23B85C3500FB4987 /* SleepStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStore.swift; sourceTree = ""; }; E9C00EEF24C620EF00628F35 /* LoopSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettings.swift; sourceTree = ""; }; E9C00EF424C623EF00628F35 /* LoopSettings+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopSettings+Loop.swift"; sourceTree = ""; }; @@ -2070,6 +2073,7 @@ children = ( C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */, 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, + E9B3551B292844010076AB04 /* UAMNotification.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, @@ -3256,9 +3260,9 @@ targets = ( 43776F8B1B8022E90074EA36 /* Loop */, 4F70C1DB1DE8DCA7006380B7 /* Loop Status Extension */, - 14B1735B28AED9EC006CCD7C /* SmallStatusWidgetExtension */, 43A943711B926B7B0051FA24 /* WatchApp */, 43A9437D1B926B7B0051FA24 /* WatchApp Extension */, + 14B1735B28AED9EC006CCD7C /* SmallStatusWidgetExtension */, E9B07F7B253BBA6500BAD8F8 /* Loop Intent Extension */, 43D9FFA121EA9A0C00AF44BF /* Learn */, 43D9FFCE21EAE05D00AF44BF /* LoopCore */, @@ -3833,6 +3837,7 @@ A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */, 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, + E9B3551E292844B90076AB04 /* UAMNotification.swift in Sources */, 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3884,6 +3889,7 @@ 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */, 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, + E9B3551D292844950076AB04 /* UAMNotification.swift in Sources */, 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 271b8010e2..96097555b1 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -484,9 +484,12 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { let carbActivity = NSUserActivity.forNewCarbEntry() let userInfo = response.notification.request.content.userInfo - if let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date { + if + let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date, + let carbAmount = userInfo[LoopNotificationUserInfoKey.unannouncedMealCarbAmount.rawValue] as? Double + { let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), - doubleValue: UAMSettings.carbThreshold), + doubleValue: carbAmount), startDate: mealTime, foodType: nil, absorptionTime: nil) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index e514ce64b4..c00fdbd1da 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -400,11 +400,11 @@ final class LoopDataManager { // Confined to dataAccessQueue private var retrospectiveCorrection: RetrospectiveCorrection - /// The last time an unannounced meal notification was sent + /// The last unannounced meal notification that was sent /// Internal for unit testing - internal var lastUAMNotificationDeliveryTime: Date? = UserDefaults.standard.lastUAMNotificationDeliveryTime { + internal var lastUAMNotification: UAMNotification? = UserDefaults.standard.lastUAMNotification { didSet { - UserDefaults.standard.lastUAMNotificationDeliveryTime = lastUAMNotificationDeliveryTime + UserDefaults.standard.lastUAMNotification = lastUAMNotification } } @@ -1459,10 +1459,10 @@ extension LoopDataManager { // Figure out if we should deliver a notification let now = now() - let notificationTimeTooRecent = now.timeIntervalSince(lastUAMNotificationDeliveryTime ?? .distantPast) < (UAMSettings.maxRecency - UAMSettings.minRecency) + let notificationTimeTooRecent = now.timeIntervalSince(lastUAMNotification?.deliveryTime ?? .distantPast) < (UAMSettings.maxRecency - UAMSettings.minRecency) guard - case .hasUnannouncedMeal(let startTime) = status, + case .hasUnannouncedMeal(let startTime, let carbAmount) = status, !notificationTimeTooRecent, UserDefaults.standard.unannouncedMealNotificationsEnabled else { @@ -1470,6 +1470,15 @@ extension LoopDataManager { return } + var clampedCarbAmount = carbAmount + if + let maxBolus = settings.maximumBolus, + let currentCarbRatio = settings.carbRatioSchedule?.quantity(at: now).doubleValue(for: .gram()) + { + let maxAllowedCarbAutofill = maxBolus * currentCarbRatio + clampedCarbAmount = min(clampedCarbAmount, maxAllowedCarbAutofill) + } + logger.debug("Delivering a missed meal notification") /// Coordinate the unannounced meal notification time with any pending autoboluses that `update` may have started @@ -1480,11 +1489,12 @@ extension LoopDataManager { let estimatedBolusDuration = delegate?.loopDataManager(self, estimateBolusDuration: pendingAutobolusUnits), estimatedBolusDuration < UAMSettings.maxNotificationDelay { - NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, delay: estimatedBolusDuration) - lastUAMNotificationDeliveryTime = now.advanced(by: estimatedBolusDuration) + NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) + lastUAMNotification = UAMNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), + carbAmount: clampedCarbAmount) } else { - NotificationManager.sendUnannouncedMealNotification(mealStart: startTime) - lastUAMNotificationDeliveryTime = now + NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount) + lastUAMNotification = UAMNotification(deliveryTime: now, carbAmount: clampedCarbAmount) } } @@ -2076,7 +2086,8 @@ extension LoopDataManager { "recommendedAutomaticDose: \(String(describing: state.recommendedAutomaticDose))", "lastBolus: \(String(describing: manager.lastRequestedBolus))", "lastLoopCompleted: \(String(describing: manager.lastLoopCompleted))", - "lastUnannouncedMealNotificationTime: \(String(describing: self.lastUAMNotificationDeliveryTime))", + "lastUnannouncedMealNotificationTime: \(String(describing: self.lastUAMNotification?.deliveryTime))", + "lastUnannouncedMealCarbEstimate: \(String(describing: self.lastUAMNotification?.carbAmount))", "basalDeliveryState: \(String(describing: manager.basalDeliveryState))", "carbsOnBoard: \(String(describing: state.carbsOnBoard))", "error: \(String(describing: state.error))", diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 718cd6fadb..ad68cb3fe1 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -202,7 +202,7 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - static func sendUnannouncedMealNotification(mealStart: Date, delay: TimeInterval? = nil) { + static func sendUnannouncedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { let notification = UNMutableNotificationContent() /// Notifications should expire after the missed meal is no longer relevant let expirationDate = mealStart.addingTimeInterval(LoopCoreConstants.defaultCarbAbsorptionTimes.slow) @@ -213,6 +213,7 @@ extension NotificationManager { notification.userInfo = [ LoopNotificationUserInfoKey.unannouncedMealTime.rawValue: mealStart, + LoopNotificationUserInfoKey.unannouncedMealCarbAmount.rawValue: amountInGrams, LoopNotificationUserInfoKey.expirationDate.rawValue: expirationDate ] diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index b86209bcbc..80e28d046f 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -20,7 +20,7 @@ extension UserDefaults { case lastProfileExpirationAlertDate = "com.loopkit.Loop.lastProfileExpirationAlertDate" case allowDebugFeatures = "com.loopkit.Loop.allowDebugFeatures" case allowSimulators = "com.loopkit.Loop.allowSimulators" - case LastUAMNotificationDeliveryTime = "com.loopkit.Loop.lastUAMNotificationDeliveryTime" + case LastUAMNotification = "com.loopkit.Loop.lastUAMNotification" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -115,16 +115,26 @@ extension UserDefaults { } } - public var lastUAMNotificationDeliveryTime: Date? { + public var lastUAMNotification: UAMNotification? { get { - if let rawValue = value(forKey: Key.LastUAMNotificationDeliveryTime.rawValue) as? Date { - return rawValue - } else { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.LastUAMNotification.rawValue) as? Data else { return nil } + return try? decoder.decode(UAMNotification.self, from: data) } set { - set(newValue, forKey: Key.LastUAMNotificationDeliveryTime.rawValue) + do { + if let newValue = newValue { + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + set(data, forKey: Key.LastUAMNotification.rawValue) + } else { + set(nil, forKey: Key.LastUAMNotification.rawValue) + } + } catch { + assertionFailure("Unable to encode UAMNotification") + } } } diff --git a/LoopCore/UAMNotification.swift b/LoopCore/UAMNotification.swift new file mode 100644 index 0000000000..607721440e --- /dev/null +++ b/LoopCore/UAMNotification.swift @@ -0,0 +1,20 @@ +// +// UAMNotification.swift +// Loop +// +// Created by Anna Quinlan on 11/18/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Information about an unannounced meal notification +public struct UAMNotification: Equatable, Codable { + public let deliveryTime: Date + public let carbAmount: Double + + public init(deliveryTime: Date, carbAmount: Double) { + self.deliveryTime = deliveryTime + self.carbAmount = carbAmount + } +} diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index b4ae7e9525..a2b5af1d33 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -80,11 +80,19 @@ class LoopDataManagerTests: XCTestCase { func setUp(for test: DataManagerTestType, basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil) { let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") + let carbRatioSchedule = CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 10.0), + ], + timeZone: .utcTimeZone + )! let settings = LoopSettings( dosingEnabled: false, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, basalRateSchedule: basalRateSchedule, + carbRatioSchedule: carbRatioSchedule, maximumBasalRatePerHour: maxBasalRate, maximumBolus: maxBolus, suspendThreshold: suspendThreshold diff --git a/LoopTests/Managers/LoopDataManagerUAMTests.swift b/LoopTests/Managers/LoopDataManagerUAMTests.swift index f582fa53af..8141dba1f0 100644 --- a/LoopTests/Managers/LoopDataManagerUAMTests.swift +++ b/LoopTests/Managers/LoopDataManagerUAMTests.swift @@ -15,7 +15,7 @@ import LoopKit class LoopDataManagerUAMTests: LoopDataManagerTests { // MARK: Testing Utilities override func tearDownWithError() throws { - loopDataManager.lastUAMNotificationDeliveryTime = nil + loopDataManager.lastUAMNotification = nil UserDefaults.standard.unannouncedMealNotificationsEnabled = false try super.tearDownWithError() } @@ -28,27 +28,28 @@ class LoopDataManagerUAMTests: LoopDataManagerTests { let status = UnannouncedMealStatus.noUnannouncedMeal loopDataManager.manageMealNotifications(for: status) - XCTAssertNil(loopDataManager.lastUAMNotificationDeliveryTime) + XCTAssertNil(loopDataManager.lastUAMNotification) } func testUnannouncedMealUpdatesLastNotificationTime() { setUp(for: .highAndRisingWithCOB) UserDefaults.standard.unannouncedMealNotificationsEnabled = true - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) loopDataManager.manageMealNotifications(for: status) - XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, now) + XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 40) } func testUnannouncedMealWithoutNotificationsEnabled() { setUp(for: .highAndRisingWithCOB) UserDefaults.standard.unannouncedMealNotificationsEnabled = false - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) loopDataManager.manageMealNotifications(for: status) - XCTAssertNil(loopDataManager.lastUAMNotificationDeliveryTime) + XCTAssertNil(loopDataManager.lastUAMNotification) } func testUnannouncedMealWithTooRecentNotificationTime() { @@ -56,12 +57,24 @@ class LoopDataManagerUAMTests: LoopDataManagerTests { UserDefaults.standard.unannouncedMealNotificationsEnabled = true let oldTime = now.addingTimeInterval(.hours(1)) - loopDataManager.lastUAMNotificationDeliveryTime = oldTime + let oldNotification = UAMNotification(deliveryTime: oldTime, carbAmount: 40) + loopDataManager.lastUAMNotification = oldNotification - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: UAMSettings.minCarbThreshold) loopDataManager.manageMealNotifications(for: status) - XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, oldTime) + XCTAssertEqual(loopDataManager.lastUAMNotification, oldNotification) + } + + func testUnannouncedMealCarbClamping() { + setUp(for: .highAndRisingWithCOB) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 120) + loopDataManager.manageMealNotifications(for: status) + + XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 100) } func testUnannouncedMealNoPendingBolus() { @@ -71,12 +84,13 @@ class LoopDataManagerUAMTests: LoopDataManagerTests { loopDataManager.delegate = delegate UserDefaults.standard.unannouncedMealNotificationsEnabled = true - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 0) /// The bolus units time delegate should never be called if there are 0 pending units XCTAssertNil(delegate.bolusUnits) - XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, now) + XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 40) } func testUnannouncedMealLongPendingBolus() { @@ -86,11 +100,13 @@ class LoopDataManagerUAMTests: LoopDataManagerTests { loopDataManager.delegate = delegate UserDefaults.standard.unannouncedMealNotificationsEnabled = true - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10) XCTAssertEqual(delegate.bolusUnits, 10) - XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, now) + /// There shouldn't be a delay in delivering notification, since the autobolus will take the length of the notification window to deliver + XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 40) } func testNoUnannouncedMealShortPendingBolus_DelaysNotificationTime() { @@ -100,19 +116,19 @@ class LoopDataManagerUAMTests: LoopDataManagerTests { loopDataManager.delegate = delegate UserDefaults.standard.unannouncedMealNotificationsEnabled = true - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now) + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 30) loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 2) let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(80)) XCTAssertEqual(delegate.bolusUnits, 2) - XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, expectedDeliveryTime) + XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime) - loopDataManager.lastUAMNotificationDeliveryTime = nil + loopDataManager.lastUAMNotification = nil loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5) let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) XCTAssertEqual(delegate.bolusUnits, 4.5) - XCTAssertEqual(loopDataManager.lastUAMNotificationDeliveryTime, expectedDeliveryTime2) + XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime2) } } diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index d489880ddd..442da8cb89 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -258,9 +258,12 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { let userInfo = response.notification.request.content.userInfo // If we have info about a meal, the carb entry UI should reflect it - if let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date { + if + let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date, + let carbAmount = userInfo[LoopNotificationUserInfoKey.unannouncedMealCarbAmount.rawValue] as? Double + { let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), - doubleValue: UAMSettings.carbThreshold), + doubleValue: carbAmount), startDate: mealTime, foodType: nil, absorptionTime: nil) From 722c61c334ec8aee50227d4f361ab7a4c1ee6dd2 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Mon, 28 Nov 2022 17:45:53 -0800 Subject: [PATCH 33/51] Create `MealDetectionManager` from old UAM functions in `CarbStore` --- Loop.xcodeproj/project.pbxproj | 34 +- Loop/Managers/LoopDataManager.swift | 20 +- .../MealDetectionManager.swift | 238 +++++++++++++ .../Missed Meal Detection/UAMSettings.swift | 25 ++ .../Store Protocols/CarbStoreProtocol.swift | 3 - .../Managers/MealDetectionManagerTests.swift | 328 ++++++++++++++++++ LoopTests/Mock Stores/HKHealthStoreMock.swift | 82 +++++ LoopTests/Mock Stores/MockCarbStore.swift | 4 - 8 files changed, 716 insertions(+), 18 deletions(-) create mode 100644 Loop/Managers/Missed Meal Detection/MealDetectionManager.swift create mode 100644 Loop/Managers/Missed Meal Detection/UAMSettings.swift create mode 100644 LoopTests/Managers/MealDetectionManagerTests.swift create mode 100644 LoopTests/Mock Stores/HKHealthStoreMock.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 6d570366a2..7ba10cd14c 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -593,8 +593,12 @@ E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */; }; - E9B3551D292844950076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; - E9B3551E292844B90076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; + E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552129358C440076AB04 /* MealDetectionManager.swift */; }; + E9B355292935919E0076AB04 /* UAMSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B35525293590980076AB04 /* UAMSettings.swift */; }; + E9B3552A293591E70076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; + E9B3552B293591E70076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; + E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */; }; + E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */; }; E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BB27AA23B85C3500FB4987 /* SleepStore.swift */; }; E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EEF24C620EF00628F35 /* LoopSettings.swift */; }; E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EEF24C620EF00628F35 /* LoopSettings.swift */; }; @@ -1556,6 +1560,10 @@ E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+LoopIntents.swift"; sourceTree = ""; }; E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentExtensionInfo.swift; sourceTree = ""; }; E9B3551B292844010076AB04 /* UAMNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UAMNotification.swift; sourceTree = ""; }; + E9B3552129358C440076AB04 /* MealDetectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetectionManager.swift; sourceTree = ""; }; + E9B35525293590980076AB04 /* UAMSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UAMSettings.swift; sourceTree = ""; }; + E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetectionManagerTests.swift; sourceTree = ""; }; + E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKHealthStoreMock.swift; sourceTree = ""; }; E9BB27AA23B85C3500FB4987 /* SleepStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStore.swift; sourceTree = ""; }; E9C00EEF24C620EF00628F35 /* LoopSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettings.swift; sourceTree = ""; }; E9C00EF424C623EF00628F35 /* LoopSettings+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopSettings+Loop.swift"; sourceTree = ""; }; @@ -1773,6 +1781,7 @@ E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */, E950CA9229002E1A00B5B692 /* LoopDataManagerUAMTests.swift */, + E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */, 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */, A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */, ); @@ -2073,7 +2082,6 @@ children = ( C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */, 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, - E9B3551B292844010076AB04 /* UAMNotification.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, @@ -2084,6 +2092,7 @@ 43D9FFD221EAE05D00AF44BF /* Info.plist */, E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, C16575742539FD60004AE16E /* LoopCoreConstants.swift */, + E9B3551B292844010076AB04 /* UAMNotification.swift */, ); path = LoopCore; sourceTree = ""; @@ -2248,6 +2257,7 @@ 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */, 1DA6499D2441266400F61E75 /* Alerts */, E95D37FF24EADE68005E2F50 /* Store Protocols */, + E9B355232935906B0076AB04 /* Missed Meal Detection */, C1F2075B26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift */, A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, ); @@ -2747,6 +2757,7 @@ E93E86B124DDE21D00FF40C8 /* MockCarbStore.swift */, E98A55F024EDD85E0008715D /* MockDosingDecisionStore.swift */, E98A55F224EDD9530008715D /* MockSettingsStore.swift */, + E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */, ); path = "Mock Stores"; sourceTree = ""; @@ -2805,6 +2816,15 @@ path = "Loop Intent Extension"; sourceTree = ""; }; + E9B355232935906B0076AB04 /* Missed Meal Detection */ = { + isa = PBXGroup; + children = ( + E9B3552129358C440076AB04 /* MealDetectionManager.swift */, + E9B35525293590980076AB04 /* UAMSettings.swift */, + ); + path = "Missed Meal Detection"; + sourceTree = ""; + }; E9C58A7624DB510500487A17 /* Fixtures */ = { isa = PBXGroup; children = ( @@ -3568,6 +3588,7 @@ C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */, A9B996F227238705002DC09C /* DosingDecisionStore.swift in Sources */, 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, + E9B355292935919E0076AB04 /* UAMSettings.swift in Sources */, 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, 4372E48B213CB5F00068E043 /* Double.swift in Sources */, @@ -3680,6 +3701,7 @@ 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */, A9CBE45C248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift in Sources */, 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, + E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */, C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */, 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */, @@ -3837,7 +3859,7 @@ A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */, 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, - E9B3551E292844B90076AB04 /* UAMNotification.swift in Sources */, + E9B3552B293591E70076AB04 /* UAMNotification.swift in Sources */, 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3889,7 +3911,7 @@ 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */, 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, - E9B3551D292844950076AB04 /* UAMNotification.swift in Sources */, + E9B3552A293591E70076AB04 /* UAMNotification.swift in Sources */, 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3930,6 +3952,7 @@ 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */, A9BD28E7272226B40071DF15 /* TestLocalizedError.swift in Sources */, A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */, + E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */, E950CA9329002E1A00B5B692 /* LoopDataManagerUAMTests.swift in Sources */, E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */, B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, @@ -3945,6 +3968,7 @@ E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */, A9E8A80528A7CAC000C0A8A4 /* RemoteCommandTests.swift in Sources */, 1DFE9E172447B6270082C280 /* UserNotificationAlertIssuerTests.swift in Sources */, + E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */, B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */, C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */, A9C1719725366F780053BCBD /* WatchHistoricalGlucoseTest.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index c00fdbd1da..32fee390e7 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -30,6 +30,8 @@ final class LoopDataManager { static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" private let carbStore: CarbStoreProtocol + + private let mealDetectionManager: MealDetectionManager private let doseStore: DoseStoreProtocol @@ -105,6 +107,7 @@ final class LoopDataManager { self.now = now self.latestStoredSettingsProvider = latestStoredSettingsProvider + self.mealDetectionManager = MealDetectionManager(carbStore: carbStore) self.lockedPumpInsulinType = Locked(pumpInsulinType) @@ -1447,7 +1450,7 @@ extension LoopDataManager { } public func generateUnannouncedMealNotificationIfNeeded(using insulinCounteractionEffects: [GlucoseEffectVelocity], pendingAutobolusUnits: Double? = nil) { - carbStore.hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits) } } @@ -2108,16 +2111,21 @@ extension LoopDataManager { self.doseStore.generateDiagnosticReport { (report) in entries.append(report) entries.append("") - - UNUserNotificationCenter.current().generateDiagnosticReport { (report) in + + self.mealDetectionManager.generateDiagnosticReport { report in entries.append(report) entries.append("") - - UIDevice.current.generateDiagnosticReport { (report) in + + UNUserNotificationCenter.current().generateDiagnosticReport { (report) in entries.append(report) entries.append("") - completion(entries.joined(separator: "\n")) + UIDevice.current.generateDiagnosticReport { (report) in + entries.append(report) + entries.append("") + + completion(entries.joined(separator: "\n")) + } } } } diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift new file mode 100644 index 0000000000..c9623de137 --- /dev/null +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -0,0 +1,238 @@ +// +// MealDetectionManager.swift +// Loop +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import OSLog +import LoopKit + +enum UnannouncedMealStatus: Equatable { + case hasUnannouncedMeal(startTime: Date, carbAmount: Double) + case noUnannouncedMeal +} + +class MealDetectionManager { + private let log = OSLog(category: "MealDetectionManager") + + /// Debug info for UAM + /// Timeline from the most recent check for unannounced meals + private var lastEvaluatedUamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + + /// Timeline from the most recent detection of an unannounced meal + private var lastDetectedUamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + + private var carbStore: CarbStoreProtocol + + /// Allows for controlling uses of the system date in unit testing + internal var test_currentDate: Date? + + /// Current date. Will return the unit-test configured date if set, or the current date otherwise. + internal var currentDate: Date { + test_currentDate ?? Date() + } + + internal func currentDate(timeIntervalSinceNow: TimeInterval = 0) -> Date { + return currentDate.addingTimeInterval(timeIntervalSinceNow) + } + + public init( + carbStore: CarbStoreProtocol, + test_currentDate: Date? = nil + ) { + self.carbStore = carbStore + self.test_currentDate = test_currentDate + } + + // MARK: Meal Detection + public func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { + let delta = TimeInterval(minutes: 5) + + let intervalStart = currentDate(timeIntervalSinceNow: -UAMSettings.maxRecency) + let intervalEnd = currentDate(timeIntervalSinceNow: -UAMSettings.minRecency) + let now = self.currentDate + + carbStore.getGlucoseEffects(start: intervalStart, end: now, effectVelocities: insulinCounteractionEffects) {[weak self] result in + guard + let self = self, + case .success((let carbEntries, let carbEffects)) = result + else { + if case .failure(let error) = result { + self?.log.error("Failed to fetch glucose effects to check for missed meal: %{public}@", String(describing: error)) + } + + completion(.noUnannouncedMeal) + return + } + + /// Compute how much of the ICE effect we can't explain via our entered carbs + /// Effect caching inspired by `LoopMath.predictGlucose` + var effectValueCache: [Date: Double] = [:] + let unit = HKUnit.milligramsPerDeciliter + + /// Carb effects are cumulative, so we have to subtract the previous effect value + var previousEffectValue: Double = carbEffects.first?.quantity.doubleValue(for: unit) ?? 0 + + /// Counteraction effects only take insulin into account, so we need to account for the carb effects when computing the unexpected deviations + for effect in carbEffects { + let value = effect.quantity.doubleValue(for: unit) + /// We do `-1 * (value - previousEffectValue)` because this will compute the carb _counteraction_ effect + effectValueCache[effect.startDate] = (effectValueCache[effect.startDate] ?? 0) + -1 * (value - previousEffectValue) + previousEffectValue = value + } + + let processedICE = insulinCounteractionEffects + .filterDateRange(intervalStart, now) + .compactMap { + /// Clamp starts & ends to `intervalStart...now` since our algorithm assumes all effects occur within that interval + let start = max($0.startDate, intervalStart) + let end = min($0.endDate, now) + + guard let effect = $0.effect(from: start, to: end) else { + let item: GlucoseEffect? = nil // FIXME: we get a compiler error if we try to return `nil` directly + return item + } + + return GlucoseEffect(startDate: effect.startDate.dateFlooredToTimeInterval(delta), + quantity: effect.quantity) + } + + for effect in processedICE { + let value = effect.quantity.doubleValue(for: unit) + effectValueCache[effect.startDate] = (effectValueCache[effect.startDate] ?? 0) + value + } + + var unexpectedDeviation: Double = 0 + var mealTime = now + + /// Dates the algorithm uses when computing effects + /// Have the range go from newest -> oldest time + let summationRange = LoopMath.simulationDateRange(from: intervalStart, + to: now, + delta: delta) + .reversed() + + /// Dates the algorithm is allowed to check for the presence of a UAM + let dateSearchRange = Set(LoopMath.simulationDateRange(from: intervalStart, + to: intervalEnd, + delta: delta)) + + /// Timeline used for debug purposes + var uamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + + for pastTime in summationRange { + guard + let unexpectedEffect = effectValueCache[pastTime], + !carbEntries.contains(where: { $0.startDate >= pastTime }) + else { + uamTimeline.append((pastTime, nil, nil, nil)) + continue + } + + unexpectedDeviation += unexpectedEffect + + guard dateSearchRange.contains(pastTime) else { + /// This time is too recent to check for a UAM + uamTimeline.append((pastTime, unexpectedDeviation, nil, nil)) + continue + } + + /// Find the threshold based on a minimum of `unannouncedMealGlucoseRiseThreshold` of change per minute + let minutesAgo = now.timeIntervalSince(pastTime).minutes + let deviationChangeThreshold = UAMSettings.glucoseRiseThreshold * minutesAgo + + /// Find the total effect we'd expect to see for a meal with `carbThreshold`-worth of carbs that started at `pastTime` + guard let modeledMealEffectThreshold = self.effectThreshold(mealStart: pastTime, carbsInGrams: UAMSettings.minCarbThreshold) else { + continue + } + + uamTimeline.append((pastTime, unexpectedDeviation, modeledMealEffectThreshold, deviationChangeThreshold)) + + /// Use the higher of the 2 thresholds to ensure noisy CGM data doesn't cause false-positives for more recent times + let effectThreshold = max(deviationChangeThreshold, modeledMealEffectThreshold) + + if unexpectedDeviation >= effectThreshold { + mealTime = pastTime + } + } + + self.lastEvaluatedUamTimeline = uamTimeline.reversed() + + let mealTimeTooRecent = now.timeIntervalSince(mealTime) < UAMSettings.minRecency + guard !mealTimeTooRecent else { + completion(.noUnannouncedMeal) + return + } + + self.lastDetectedUamTimeline = uamTimeline.reversed() + + let carbAmount = self.determineCarbs(mealtime: mealTime, unexpectedDeviation: unexpectedDeviation) + completion(.hasUnannouncedMeal(startTime: mealTime, carbAmount: carbAmount ?? UAMSettings.minCarbThreshold)) + } + } + + private func determineCarbs(mealtime: Date, unexpectedDeviation: Double) -> Double? { + var mealCarbs: Double? = nil + + /// Search `carbAmount`s from `minCarbThreshold` to `maxCarbThreshold` in 5-gram increments, + /// seeing if the deviation is at least `carbAmount` of carbs + for carbAmount in stride(from: UAMSettings.minCarbThreshold, through: UAMSettings.maxCarbThreshold, by: 5) { + if + let modeledCarbEffect = effectThreshold(mealStart: mealtime, carbsInGrams: carbAmount), + unexpectedDeviation >= modeledCarbEffect + { + mealCarbs = carbAmount + } + } + + return mealCarbs + } + + private func effectThreshold(mealStart: Date, carbsInGrams: Double) -> Double? { + do { + return try carbStore.glucoseEffects( + of: [NewCarbEntry(quantity: HKQuantity(unit: .gram(), + doubleValue: carbsInGrams), + startDate: mealStart, + foodType: nil, + absorptionTime: nil) + ], + startingAt: mealStart, + endingAt: nil, + effectVelocities: nil + ) + .last? + .quantity.doubleValue(for: HKUnit.milligramsPerDeciliter) + } catch let error { + self.log.error("Error fetching carb glucose effects: %{public}@", String(describing: error)) + } + + return nil + } + + // MARK: Logging + + /// Generates a diagnostic report about the current state + /// + /// - parameter completionHandler: A closure called once the report has been generated. The closure takes a single argument of the report string. + public func generateDiagnosticReport(_ completionHandler: @escaping (_ report: String) -> Void) { + let report = [ + "## MealDetectionManager", + "", + "* lastEvaluatedUnannouncedMealTimeline:", + lastEvaluatedUamTimeline.reduce(into: "", { (entries, entry) in + entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") + }), + "* lastDetectedUnannouncedMealTimeline:", + lastDetectedUamTimeline.reduce(into: "", { (entries, entry) in + entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") + }) + ] + + completionHandler(report.joined(separator: "\n")) + } +} diff --git a/Loop/Managers/Missed Meal Detection/UAMSettings.swift b/Loop/Managers/Missed Meal Detection/UAMSettings.swift new file mode 100644 index 0000000000..6998146571 --- /dev/null +++ b/Loop/Managers/Missed Meal Detection/UAMSettings.swift @@ -0,0 +1,25 @@ +// +// UAMSettings.swift +// Loop +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct UAMSettings { + /// Minimum grams of unannounced carbs that must be detected for a notification to be delivered + public static let minCarbThreshold: Double = 20 // grams + /// Maximum grams of unannounced carbs that the algorithm will search for + public static let maxCarbThreshold: Double = 80 // grams + /// Minimum threshold for glucose rise over the detection window + static let glucoseRiseThreshold = 2.0 // mg/dL/m + /// Minimum time from now that must have passed for the meal to be detected + public static let minRecency = TimeInterval(minutes: 25) + /// Maximum time from now that a meal can be detected + public static let maxRecency = TimeInterval(hours: 2) + /// Maximum delay allowed in missed meal notification time to avoid + /// notifying the user during an autobolus + public static let maxNotificationDelay = TimeInterval(minutes: 4) +} diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index a1db69d0c3..41308cc24e 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -60,9 +60,6 @@ protocol CarbStoreProtocol: AnyObject { func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) - - // MARK: Unannounced Meal Detection - func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) } extension CarbStore: CarbStoreProtocol { } diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift new file mode 100644 index 0000000000..7c1947672d --- /dev/null +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -0,0 +1,328 @@ +// +// MealDetectionManagerTests.swift +// LoopTests +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +@testable import Loop + +enum UAMTestType { + private static var dateFormatter = ISO8601DateFormatter.localTimeDate() + + /// No meal is present + case noMeal + /// No meal is present, but if the counteraction effects aren't clamped properly it will look like there's a UAM + case noMealCounteractionEffectsNeedClamping + // No meal is present and there is COB + case noMealWithCOB + /// UAM with no carbs on board + case unannouncedMealNoCOB + /// UAM with carbs logged prior to it + case unannouncedMealWithCOB + /// There is a meal, but it's announced and not unannounced + case announcedMeal + /// CGM data is noisy, but no meal is present + case noisyCGM + /// Realistic counteraction effects with multiple meals + case manyMeals + /// Test case to test dynamic computation of missed meal carb amount + case dynamicCarbAutofill +} + +extension UAMTestType { + var counteractionEffectFixture: String { + switch self { + case .unannouncedMealNoCOB, .noMealWithCOB: + return "uam_counteraction_effect" + case .noMeal, .announcedMeal: + return "long_interval_counteraction_effect" + case .noMealCounteractionEffectsNeedClamping: + return "needs_clamping_counteraction_effect" + case .noisyCGM: + return "noisy_cgm_counteraction_effect" + case .manyMeals, .unannouncedMealWithCOB: + return "realistic_report_counteraction_effect" + case .dynamicCarbAutofill: + return "dynamic_autofill_counteraction_effect" + } + } + + var currentDate: Date { + switch self { + case .unannouncedMealNoCOB, .noMealWithCOB: + return Self.dateFormatter.date(from: "2022-10-17T23:28:45")! + case .noMeal, .noMealCounteractionEffectsNeedClamping, .announcedMeal: + return Self.dateFormatter.date(from: "2022-10-17T02:49:16")! + case .noisyCGM: + return Self.dateFormatter.date(from: "2022-10-19T20:46:23")! + case .unannouncedMealWithCOB: + return Self.dateFormatter.date(from: "2022-10-19T19:50:15")! + case .manyMeals: + return Self.dateFormatter.date(from: "2022-10-19T21:50:15")! + case .dynamicCarbAutofill: + return Self.dateFormatter.date(from: "2022-10-17T07:51:09")! + } + } + + var uamDate: Date? { + switch self { + case .unannouncedMealNoCOB: + return Self.dateFormatter.date(from: "2022-10-17T22:10:00") + case .unannouncedMealWithCOB: + return Self.dateFormatter.date(from: "2022-10-19T19:15:00") + case .dynamicCarbAutofill: + return Self.dateFormatter.date(from: "2022-10-17T07:20:00")! + default: + return nil + } + } + + var carbEntries: [NewCarbEntry] { + switch self { + case .unannouncedMealWithCOB: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 10), + startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, + foodType: nil, + absorptionTime: nil) + ] + case .announcedMeal: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 40), + startDate: Self.dateFormatter.date(from: "2022-10-17T01:06:52")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1), + startDate: Self.dateFormatter.date(from: "2022-10-17T02:15:00")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-17T02:35:00")!, + foodType: nil, + absorptionTime: nil), + ] + case .noMealWithCOB: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-17T22:40:00")!, + foodType: nil, + absorptionTime: nil) + ] + case .manyMeals: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 10), + startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 40), + startDate: Self.dateFormatter.date(from: "2022-10-19T19:11:43")!, + foodType: nil, + absorptionTime: nil) + ] + default: + return [] + } + } + + var carbSchedule: CarbRatioSchedule { + CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 15.0), + ], + timeZone: .utcTimeZone + )! + } + + var insulinSensitivitySchedule: InsulinSensitivitySchedule { + InsulinSensitivitySchedule( + unit: HKUnit.milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 50.0) + ], + timeZone: .utcTimeZone + )! + } +} + +class MealDetectionManagerTests { + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + var mealDetectionManager: MealDetectionManager! + + func setUp(for testType: UAMTestType) -> [GlucoseEffectVelocity] { + let healthStore = HKHealthStoreMock() + + let carbStore = CarbStore( + healthStore: healthStore, + cacheStore: PersistenceController(directoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString, isDirectory: true)), + cacheLength: .hours(24), + defaultAbsorptionTimes: (fast: .minutes(30), medium: .hours(3), slow: .hours(5)), + observationInterval: 0, + overrideHistory: TemporaryScheduleOverrideHistory(), + provenanceIdentifier: Bundle.main.bundleIdentifier!, + test_currentDate: testType.currentDate) + + // Set up schedules + carbStore.carbRatioSchedule = testType.carbSchedule + carbStore.insulinSensitivitySchedule = testType.insulinSensitivitySchedule + + // Add any needed carb entries to the carb store + let updateGroup = DispatchGroup() + testType.carbEntries.forEach { carbEntry in + updateGroup.enter() + carbStore.addCarbEntry(carbEntry) { result in + if case .failure(_) = result { + XCTFail("Failed to add carb entry to carb store") + } + + updateGroup.leave() + } + } + _ = updateGroup.wait(timeout: .now() + .seconds(5)) + + mealDetectionManager = MealDetectionManager(carbStore: carbStore, test_currentDate: testType.currentDate) + + // Fetch & return the counteraction effects for the test + return counteractionEffects(for: testType) + } + + private func counteractionEffects(for testType: UAMTestType) -> [GlucoseEffectVelocity] { + let fixture: [JSONDictionary] = loadFixture(testType.counteractionEffectFixture) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + GlucoseEffectVelocity(startDate: dateFormatter.date(from: $0["startDate"] as! String)!, + endDate: dateFormatter.date(from: $0["endDate"] as! String)!, + quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), + doubleValue:$0["value"] as! Double)) + } + } + + func tearDown() { + mealDetectionManager = nil + } + + func testNoUnannouncedMeal() { + let counteractionEffects = setUp(for: .noMeal) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noUnannouncedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testNoUnannouncedMeal_WithCOB() { + let counteractionEffects = setUp(for: .noMealWithCOB) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noUnannouncedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testUnannouncedMeal_NoCarbEntry() { + let testType = UAMTestType.unannouncedMealNoCOB + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .hasUnannouncedMeal(startTime: testType.uamDate!, carbAmount: 55)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testDynamicCarbAutofill() { + let testType = UAMTestType.dynamicCarbAutofill + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .hasUnannouncedMeal(startTime: testType.uamDate!, carbAmount: 25)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testUnannouncedMeal_UAMAndCOB() { + let testType = UAMTestType.unannouncedMealWithCOB + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .hasUnannouncedMeal(startTime: testType.uamDate!, carbAmount: 50)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testNoUnannouncedMeal_AnnouncedMealPresent() { + let counteractionEffects = setUp(for: .announcedMeal) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noUnannouncedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testNoisyCGM() { + let counteractionEffects = setUp(for: .noisyCGM) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noUnannouncedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testManyMeals() { + let counteractionEffects = setUp(for: .manyMeals) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noUnannouncedMeal) + updateGroup.leave() + } + updateGroup.wait() + } +} + +extension MealDetectionManagerTests { + public var bundle: Bundle { + return Bundle(for: type(of: self)) + } + + public func loadFixture(_ resourceName: String) -> T { + let path = bundle.path(forResource: resourceName, ofType: "json")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T + } +} diff --git a/LoopTests/Mock Stores/HKHealthStoreMock.swift b/LoopTests/Mock Stores/HKHealthStoreMock.swift new file mode 100644 index 0000000000..6f8127d3e4 --- /dev/null +++ b/LoopTests/Mock Stores/HKHealthStoreMock.swift @@ -0,0 +1,82 @@ +// +// HKHealthStoreMock.swift +// LoopTests +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import HealthKit +import Foundation +import LoopKit + + +class HKHealthStoreMock: HKHealthStore { + var saveError: Error? + var deleteError: Error? + var queryResults: (samples: [HKSample]?, error: Error?)? + var lastQuery: HKQuery? + var authorizationStatus: HKAuthorizationStatus? + + private var saveHandler: ((_ objects: [HKObject], _ success: Bool, _ error: Error?) -> Void)? + private var deleteObjectsHandler: ((_ objectType: HKObjectType, _ predicate: NSPredicate, _ success: Bool, _ count: Int, _ error: Error?) -> Void)? + + let queue = DispatchQueue(label: "HKHealthStoreMock") + + override func save(_ object: HKObject, withCompletion completion: @escaping (Bool, Error?) -> Void) { + queue.async { + self.saveHandler?([object], self.saveError == nil, self.saveError) + completion(self.saveError == nil, self.saveError) + } + } + + override func save(_ objects: [HKObject], withCompletion completion: @escaping (Bool, Error?) -> Void) { + queue.async { + self.saveHandler?(objects, self.saveError == nil, self.saveError) + completion(self.saveError == nil, self.saveError) + } + } + + override func delete(_ objects: [HKObject], withCompletion completion: @escaping (Bool, Error?) -> Void) { + queue.async { + completion(self.deleteError == nil, self.deleteError) + } + } + + override func deleteObjects(of objectType: HKObjectType, predicate: NSPredicate, withCompletion completion: @escaping (Bool, Int, Error?) -> Void) { + queue.async { + self.deleteObjectsHandler?(objectType, predicate, self.deleteError == nil, 0, self.deleteError) + completion(self.deleteError == nil, 0, self.deleteError) + } + } + + func setSaveHandler(_ saveHandler: ((_ objects: [HKObject], _ success: Bool, _ error: Error?) -> Void)?) { + queue.sync { + self.saveHandler = saveHandler + } + } + + override func requestAuthorization(toShare typesToShare: Set?, read typesToRead: Set?, completion: @escaping (Bool, Error?) -> Void) { + DispatchQueue.main.async { + completion(true, nil) + } + } + + override func authorizationStatus(for type: HKObjectType) -> HKAuthorizationStatus { + return authorizationStatus ?? .notDetermined + } + + func setDeletedObjectsHandler(_ deleteObjectsHandler: ((_ objectType: HKObjectType, _ predicate: NSPredicate, _ success: Bool, _ count: Int, _ error: Error?) -> Void)?) { + queue.sync { + self.deleteObjectsHandler = deleteObjectsHandler + } + } +} + +extension HKHealthStoreMock { + + override func execute(_ query: HKQuery) { + self.lastQuery = query + } +} + diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 527f03c950..f61fcd70ed 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -106,10 +106,6 @@ class MockCarbStore: CarbStoreProtocol { return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) }))) } - - func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { - completion(.noUnannouncedMeal) - } } extension MockCarbStore { From a811c03f43ff55007b3b93ac5331998099f9edc4 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 29 Nov 2022 13:04:52 -0800 Subject: [PATCH 34/51] Move notification logic into `MealDetectionManager` --- Loop.xcodeproj/project.pbxproj | 4 - Loop/Managers/LoopDataManager.swift | 78 ++-------- .../MealDetectionManager.swift | 83 ++++++++++- .../Managers/LoopDataManagerUAMTests.swift | 134 ------------------ .../Managers/MealDetectionManagerTests.swift | 133 ++++++++++++++++- 5 files changed, 220 insertions(+), 212 deletions(-) delete mode 100644 LoopTests/Managers/LoopDataManagerUAMTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 7ba10cd14c..733fef8a36 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -575,7 +575,6 @@ E942DE9F253BE6A900AC532D /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; E942DF34253BF87F00AC532D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 43785E9B2120E7060057DED1 /* Intents.intentdefinition */; }; E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */; }; - E950CA9329002E1A00B5B692 /* LoopDataManagerUAMTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E950CA9229002E1A00B5B692 /* LoopDataManagerUAMTests.swift */; }; E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */; }; E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */; }; E95D380524EADF78005E2F50 /* GlucoseStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */; }; @@ -1541,7 +1540,6 @@ E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_momentum_effect.json; sourceTree = ""; }; E942DE6D253BE5E100AC532D /* Loop Intent Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Intent Extension.entitlements"; sourceTree = ""; }; E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManagerDosingTests.swift; sourceTree = ""; }; - E950CA9229002E1A00B5B692 /* LoopDataManagerUAMTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManagerUAMTests.swift; sourceTree = ""; }; E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseStoreProtocol.swift; sourceTree = ""; }; E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbStoreProtocol.swift; sourceTree = ""; }; E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStoreProtocol.swift; sourceTree = ""; }; @@ -1780,7 +1778,6 @@ C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */, - E950CA9229002E1A00B5B692 /* LoopDataManagerUAMTests.swift */, E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */, 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */, A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */, @@ -3953,7 +3950,6 @@ A9BD28E7272226B40071DF15 /* TestLocalizedError.swift in Sources */, A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */, E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */, - E950CA9329002E1A00B5B692 /* LoopDataManagerUAMTests.swift in Sources */, E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */, B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 32fee390e7..fe22ee0c9d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -107,7 +107,7 @@ final class LoopDataManager { self.now = now self.latestStoredSettingsProvider = latestStoredSettingsProvider - self.mealDetectionManager = MealDetectionManager(carbStore: carbStore) + self.mealDetectionManager = MealDetectionManager(carbStore: carbStore, maximumBolus: settings.maximumBolus) self.lockedPumpInsulinType = Locked(pumpInsulinType) @@ -274,6 +274,11 @@ final class LoopDataManager { invalidateCachedEffects = true analyticsServicesManager.didChangeInsulinModel() } + + // ANNA TODO: add test for this + if newValue.maximumBolus != oldValue.maximumBolus { + mealDetectionManager.maximumBolus = newValue.maximumBolus + } if invalidateCachedEffects { dataAccessQueue.async { @@ -402,14 +407,6 @@ final class LoopDataManager { // Confined to dataAccessQueue private var retrospectiveCorrection: RetrospectiveCorrection - - /// The last unannounced meal notification that was sent - /// Internal for unit testing - internal var lastUAMNotification: UAMNotification? = UserDefaults.standard.lastUAMNotification { - didSet { - UserDefaults.standard.lastUAMNotification = lastUAMNotification - } - } // MARK: - Background task management @@ -1448,58 +1445,6 @@ extension LoopDataManager { volumeRounder: volumeRounder ) } - - public func generateUnannouncedMealNotificationIfNeeded(using insulinCounteractionEffects: [GlucoseEffectVelocity], pendingAutobolusUnits: Double? = nil) { - mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in - self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits) - } - } - - // Internal for unit testing - internal func manageMealNotifications(for status: UnannouncedMealStatus, pendingAutobolusUnits: Double? = nil) { - // We should remove expired notifications regardless of whether or not there was a meal - NotificationManager.removeExpiredMealNotifications() - - // Figure out if we should deliver a notification - let now = now() - let notificationTimeTooRecent = now.timeIntervalSince(lastUAMNotification?.deliveryTime ?? .distantPast) < (UAMSettings.maxRecency - UAMSettings.minRecency) - - guard - case .hasUnannouncedMeal(let startTime, let carbAmount) = status, - !notificationTimeTooRecent, - UserDefaults.standard.unannouncedMealNotificationsEnabled - else { - // No notification needed! - return - } - - var clampedCarbAmount = carbAmount - if - let maxBolus = settings.maximumBolus, - let currentCarbRatio = settings.carbRatioSchedule?.quantity(at: now).doubleValue(for: .gram()) - { - let maxAllowedCarbAutofill = maxBolus * currentCarbRatio - clampedCarbAmount = min(clampedCarbAmount, maxAllowedCarbAutofill) - } - - logger.debug("Delivering a missed meal notification") - - /// Coordinate the unannounced meal notification time with any pending autoboluses that `update` may have started - /// so that the user doesn't have to cancel the current autobolus to bolus in response to the missed meal notification - if - let pendingAutobolusUnits, - pendingAutobolusUnits > 0, - let estimatedBolusDuration = delegate?.loopDataManager(self, estimateBolusDuration: pendingAutobolusUnits), - estimatedBolusDuration < UAMSettings.maxNotificationDelay - { - NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) - lastUAMNotification = UAMNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), - carbAmount: clampedCarbAmount) - } else { - NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount) - lastUAMNotification = UAMNotification(deliveryTime: now, carbAmount: clampedCarbAmount) - } - } /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. /// @@ -1726,8 +1671,13 @@ extension LoopDataManager { private func enactRecommendedAutomaticDose() -> LoopError? { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - generateUnannouncedMealNotificationIfNeeded(using: insulinCounteractionEffects, - pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits) + mealDetectionManager.generateUnannouncedMealNotificationIfNeeded( + using: insulinCounteractionEffects, + pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, + bolusDurationEstimator: { [unowned self] bolusAmount in + return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) + } + ) guard let recommendedDose = self.recommendedAutomaticDose else { return nil @@ -2089,8 +2039,6 @@ extension LoopDataManager { "recommendedAutomaticDose: \(String(describing: state.recommendedAutomaticDose))", "lastBolus: \(String(describing: manager.lastRequestedBolus))", "lastLoopCompleted: \(String(describing: manager.lastLoopCompleted))", - "lastUnannouncedMealNotificationTime: \(String(describing: self.lastUAMNotification?.deliveryTime))", - "lastUnannouncedMealCarbEstimate: \(String(describing: self.lastUAMNotification?.carbAmount))", "basalDeliveryState: \(String(describing: manager.basalDeliveryState))", "carbsOnBoard: \(String(describing: state.carbsOnBoard))", "error: \(String(describing: state.error))", diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index c9623de137..0b6092108b 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import OSLog +import LoopCore import LoopKit enum UnannouncedMealStatus: Equatable { @@ -18,7 +19,19 @@ enum UnannouncedMealStatus: Equatable { class MealDetectionManager { private let log = OSLog(category: "MealDetectionManager") - + + public var maximumBolus: Double? + + /// The last unannounced meal notification that was sent + /// Internal for unit testing + var lastUAMNotification: UAMNotification? = UserDefaults.standard.lastUAMNotification { + didSet { + UserDefaults.standard.lastUAMNotification = lastUAMNotification + } + } + + private var carbStore: CarbStoreProtocol + /// Debug info for UAM /// Timeline from the most recent check for unannounced meals private var lastEvaluatedUamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] @@ -26,8 +39,6 @@ class MealDetectionManager { /// Timeline from the most recent detection of an unannounced meal private var lastDetectedUamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] - private var carbStore: CarbStoreProtocol - /// Allows for controlling uses of the system date in unit testing internal var test_currentDate: Date? @@ -42,14 +53,16 @@ class MealDetectionManager { public init( carbStore: CarbStoreProtocol, + maximumBolus: Double?, test_currentDate: Date? = nil ) { self.carbStore = carbStore + self.maximumBolus = maximumBolus self.test_currentDate = test_currentDate } // MARK: Meal Detection - public func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { + func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { let delta = TimeInterval(minutes: 5) let intervalStart = currentDate(timeIntervalSinceNow: -UAMSettings.maxRecency) @@ -214,15 +227,75 @@ class MealDetectionManager { return nil } + // MARK: Notification Generation + func generateUnannouncedMealNotificationIfNeeded( + using insulinCounteractionEffects: [GlucoseEffectVelocity], + pendingAutobolusUnits: Double? = nil, + bolusDurationEstimator: @escaping (Double) -> TimeInterval? + ) { + hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in + self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits, bolusDurationEstimator: bolusDurationEstimator) + } + } + + + // Internal for unit testing + func manageMealNotifications(for status: UnannouncedMealStatus, pendingAutobolusUnits: Double? = nil, bolusDurationEstimator getBolusDuration: (Double) -> TimeInterval?) { + // We should remove expired notifications regardless of whether or not there was a meal + NotificationManager.removeExpiredMealNotifications() + + // Figure out if we should deliver a notification + let now = self.currentDate + let notificationTimeTooRecent = now.timeIntervalSince(lastUAMNotification?.deliveryTime ?? .distantPast) < (UAMSettings.maxRecency - UAMSettings.minRecency) + + guard + case .hasUnannouncedMeal(let startTime, let carbAmount) = status, + !notificationTimeTooRecent, + UserDefaults.standard.unannouncedMealNotificationsEnabled + else { + // No notification needed! + return + } + + var clampedCarbAmount = carbAmount + if + let maxBolus = maximumBolus, + let currentCarbRatio = carbStore.carbRatioSchedule?.quantity(at: now).doubleValue(for: .gram()) + { + let maxAllowedCarbAutofill = maxBolus * currentCarbRatio + clampedCarbAmount = min(clampedCarbAmount, maxAllowedCarbAutofill) + } + + log.debug("Delivering a missed meal notification") + + /// Coordinate the unannounced meal notification time with any pending autoboluses that `update` may have started + /// so that the user doesn't have to cancel the current autobolus to bolus in response to the missed meal notification + if + let pendingAutobolusUnits, + pendingAutobolusUnits > 0, + let estimatedBolusDuration = getBolusDuration(pendingAutobolusUnits), + estimatedBolusDuration < UAMSettings.maxNotificationDelay + { + NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) + lastUAMNotification = UAMNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), + carbAmount: clampedCarbAmount) + } else { + NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount) + lastUAMNotification = UAMNotification(deliveryTime: now, carbAmount: clampedCarbAmount) + } + } + // MARK: Logging /// Generates a diagnostic report about the current state /// /// - parameter completionHandler: A closure called once the report has been generated. The closure takes a single argument of the report string. - public func generateDiagnosticReport(_ completionHandler: @escaping (_ report: String) -> Void) { + func generateDiagnosticReport(_ completionHandler: @escaping (_ report: String) -> Void) { let report = [ "## MealDetectionManager", "", + "* lastUnannouncedMealNotificationTime: \(String(describing: lastUAMNotification?.deliveryTime))", + "* lastUnannouncedMealCarbEstimate: \(String(describing: lastUAMNotification?.carbAmount))", "* lastEvaluatedUnannouncedMealTimeline:", lastEvaluatedUamTimeline.reduce(into: "", { (entries, entry) in entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") diff --git a/LoopTests/Managers/LoopDataManagerUAMTests.swift b/LoopTests/Managers/LoopDataManagerUAMTests.swift deleted file mode 100644 index 8141dba1f0..0000000000 --- a/LoopTests/Managers/LoopDataManagerUAMTests.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// LoopDataManagerUAMTests.swift -// LoopTests -// -// Created by Anna Quinlan on 10/19/22. -// Copyright © 2022 LoopKit Authors. All rights reserved. -// - -import XCTest -import HealthKit -import LoopKit -@testable import LoopCore -@testable import Loop - -class LoopDataManagerUAMTests: LoopDataManagerTests { - // MARK: Testing Utilities - override func tearDownWithError() throws { - loopDataManager.lastUAMNotification = nil - UserDefaults.standard.unannouncedMealNotificationsEnabled = false - try super.tearDownWithError() - } - - // MARK: Tests - func testNoUnannouncedMealLastNotificationTime() { - setUp(for: .highAndRisingWithCOB) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.noUnannouncedMeal - loopDataManager.manageMealNotifications(for: status) - - XCTAssertNil(loopDataManager.lastUAMNotification) - } - - func testUnannouncedMealUpdatesLastNotificationTime() { - setUp(for: .highAndRisingWithCOB) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) - loopDataManager.manageMealNotifications(for: status) - - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 40) - } - - func testUnannouncedMealWithoutNotificationsEnabled() { - setUp(for: .highAndRisingWithCOB) - UserDefaults.standard.unannouncedMealNotificationsEnabled = false - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) - loopDataManager.manageMealNotifications(for: status) - - XCTAssertNil(loopDataManager.lastUAMNotification) - } - - func testUnannouncedMealWithTooRecentNotificationTime() { - setUp(for: .highAndRisingWithCOB) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let oldTime = now.addingTimeInterval(.hours(1)) - let oldNotification = UAMNotification(deliveryTime: oldTime, carbAmount: 40) - loopDataManager.lastUAMNotification = oldNotification - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: UAMSettings.minCarbThreshold) - loopDataManager.manageMealNotifications(for: status) - - XCTAssertEqual(loopDataManager.lastUAMNotification, oldNotification) - } - - func testUnannouncedMealCarbClamping() { - setUp(for: .highAndRisingWithCOB) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 120) - loopDataManager.manageMealNotifications(for: status) - - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 100) - } - - func testUnannouncedMealNoPendingBolus() { - setUp(for: .highAndRisingWithCOB) - - let delegate = MockDelegate() - loopDataManager.delegate = delegate - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) - loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 0) - - /// The bolus units time delegate should never be called if there are 0 pending units - XCTAssertNil(delegate.bolusUnits) - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 40) - } - - func testUnannouncedMealLongPendingBolus() { - setUp(for: .highAndRisingWithCOB) - - let delegate = MockDelegate() - loopDataManager.delegate = delegate - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) - loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10) - - XCTAssertEqual(delegate.bolusUnits, 10) - /// There shouldn't be a delay in delivering notification, since the autobolus will take the length of the notification window to deliver - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 40) - } - - func testNoUnannouncedMealShortPendingBolus_DelaysNotificationTime() { - setUp(for: .highAndRisingWithCOB) - - let delegate = MockDelegate() - loopDataManager.delegate = delegate - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 30) - loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 2) - - let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(80)) - XCTAssertEqual(delegate.bolusUnits, 2) - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime) - - loopDataManager.lastUAMNotification = nil - loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5) - - let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) - XCTAssertEqual(delegate.bolusUnits, 4.5) - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime2) - } -} - diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 7c1947672d..191001c843 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -8,6 +8,7 @@ import XCTest import HealthKit +import LoopCore import LoopKit @testable import Loop @@ -32,12 +33,14 @@ enum UAMTestType { case manyMeals /// Test case to test dynamic computation of missed meal carb amount case dynamicCarbAutofill + /// Test case for purely testing the notifications (not the algorithm) + case notificationTest } extension UAMTestType { var counteractionEffectFixture: String { switch self { - case .unannouncedMealNoCOB, .noMealWithCOB: + case .unannouncedMealNoCOB, .noMealWithCOB, .notificationTest: return "uam_counteraction_effect" case .noMeal, .announcedMeal: return "long_interval_counteraction_effect" @@ -54,7 +57,7 @@ extension UAMTestType { var currentDate: Date { switch self { - case .unannouncedMealNoCOB, .noMealWithCOB: + case .unannouncedMealNoCOB, .noMealWithCOB, .notificationTest: return Self.dateFormatter.date(from: "2022-10-17T23:28:45")! case .noMeal, .noMealCounteractionEffectsNeedClamping, .announcedMeal: return Self.dateFormatter.date(from: "2022-10-17T02:49:16")! @@ -160,10 +163,18 @@ extension UAMTestType { class MealDetectionManagerTests { let dateFormatter = ISO8601DateFormatter.localTimeDate() + let pumpManager = MockPumpManager() var mealDetectionManager: MealDetectionManager! - func setUp(for testType: UAMTestType) -> [GlucoseEffectVelocity] { + var now: Date { + mealDetectionManager.test_currentDate! + } + + var bolusUnits: Double? + var bolusDurationEstimator: ((Double) -> TimeInterval?)! + + @discardableResult func setUp(for testType: UAMTestType) -> [GlucoseEffectVelocity] { let healthStore = HKHealthStoreMock() let carbStore = CarbStore( @@ -194,7 +205,16 @@ class MealDetectionManagerTests { } _ = updateGroup.wait(timeout: .now() + .seconds(5)) - mealDetectionManager = MealDetectionManager(carbStore: carbStore, test_currentDate: testType.currentDate) + mealDetectionManager = MealDetectionManager( + carbStore: carbStore, + maximumBolus: 5, + test_currentDate: testType.currentDate + ) + + bolusDurationEstimator = { units in + self.bolusUnits = units + return self.pumpManager.estimatedDuration(toBolus: units) + } // Fetch & return the counteraction effects for the test return counteractionEffects(for: testType) @@ -213,9 +233,12 @@ class MealDetectionManagerTests { } func tearDown() { + mealDetectionManager.lastUAMNotification = nil mealDetectionManager = nil + UserDefaults.standard.unannouncedMealNotificationsEnabled = false } + // MARK: - Algorithm Tests func testNoUnannouncedMeal() { let counteractionEffects = setUp(for: .noMeal) @@ -314,6 +337,108 @@ class MealDetectionManagerTests { } updateGroup.wait() } + + // MARK: - Notification Tests + func testNoUnannouncedMealLastNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.noUnannouncedMeal + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertNil(mealDetectionManager.lastUAMNotification) + } + + func testUnannouncedMealUpdatesLastNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 40) + } + + func testUnannouncedMealWithoutNotificationsEnabled() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = false + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertNil(mealDetectionManager.lastUAMNotification) + } + + func testUnannouncedMealWithTooRecentNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let oldTime = now.addingTimeInterval(.hours(1)) + let oldNotification = UAMNotification(deliveryTime: oldTime, carbAmount: 40) + mealDetectionManager.lastUAMNotification = oldNotification + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: UAMSettings.minCarbThreshold) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastUAMNotification, oldNotification) + } + + func testUnannouncedMealCarbClamping() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 120) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 100) + } + + func testUnannouncedMealNoPendingBolus() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 0, bolusDurationEstimator: bolusDurationEstimator) + + /// The bolus units time delegate should never be called if there are 0 pending units + XCTAssertNil(bolusUnits) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 40) + } + + func testUnannouncedMealLongPendingBolus() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10, bolusDurationEstimator: bolusDurationEstimator) + + XCTAssertEqual(bolusUnits, 10) + /// There shouldn't be a delay in delivering notification, since the autobolus will take the length of the notification window to deliver + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 40) + } + + func testNoUnannouncedMealShortPendingBolus_DelaysNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 30) + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 2, bolusDurationEstimator: bolusDurationEstimator) + + let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(80)) + XCTAssertEqual(bolusUnits, 2) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime) + + mealDetectionManager.lastUAMNotification = nil + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5, bolusDurationEstimator: bolusDurationEstimator) + + let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) + XCTAssertEqual(bolusUnits, 4.5) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime2) + } } extension MealDetectionManagerTests { From 72a5ab36e5926ef67d4598b6bb37f7f6dece570e Mon Sep 17 00:00:00 2001 From: Anna Quinlan <31571514+novalegra@users.noreply.github.com> Date: Tue, 29 Nov 2022 19:31:26 -0800 Subject: [PATCH 35/51] Updates based on feedback for UAM PR (#7) * Create `MealDetectionManager` from old UAM functions in `CarbStore` * Move notification logic into `MealDetectionManager` --- Loop.xcodeproj/project.pbxproj | 38 +- Loop/Managers/LoopDataManager.swift | 94 +--- .../MealDetectionManager.swift | 311 ++++++++++++ .../Missed Meal Detection/UAMSettings.swift | 25 + .../Store Protocols/CarbStoreProtocol.swift | 3 - .../Managers/LoopDataManagerUAMTests.swift | 134 ------ .../Managers/MealDetectionManagerTests.swift | 453 ++++++++++++++++++ LoopTests/Mock Stores/HKHealthStoreMock.swift | 82 ++++ LoopTests/Mock Stores/MockCarbStore.swift | 4 - 9 files changed, 925 insertions(+), 219 deletions(-) create mode 100644 Loop/Managers/Missed Meal Detection/MealDetectionManager.swift create mode 100644 Loop/Managers/Missed Meal Detection/UAMSettings.swift delete mode 100644 LoopTests/Managers/LoopDataManagerUAMTests.swift create mode 100644 LoopTests/Managers/MealDetectionManagerTests.swift create mode 100644 LoopTests/Mock Stores/HKHealthStoreMock.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 6d570366a2..733fef8a36 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -575,7 +575,6 @@ E942DE9F253BE6A900AC532D /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; E942DF34253BF87F00AC532D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 43785E9B2120E7060057DED1 /* Intents.intentdefinition */; }; E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */; }; - E950CA9329002E1A00B5B692 /* LoopDataManagerUAMTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E950CA9229002E1A00B5B692 /* LoopDataManagerUAMTests.swift */; }; E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */; }; E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */; }; E95D380524EADF78005E2F50 /* GlucoseStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */; }; @@ -593,8 +592,12 @@ E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */; }; - E9B3551D292844950076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; - E9B3551E292844B90076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; + E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552129358C440076AB04 /* MealDetectionManager.swift */; }; + E9B355292935919E0076AB04 /* UAMSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B35525293590980076AB04 /* UAMSettings.swift */; }; + E9B3552A293591E70076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; + E9B3552B293591E70076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; + E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */; }; + E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */; }; E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BB27AA23B85C3500FB4987 /* SleepStore.swift */; }; E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EEF24C620EF00628F35 /* LoopSettings.swift */; }; E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EEF24C620EF00628F35 /* LoopSettings.swift */; }; @@ -1537,7 +1540,6 @@ E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_momentum_effect.json; sourceTree = ""; }; E942DE6D253BE5E100AC532D /* Loop Intent Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Intent Extension.entitlements"; sourceTree = ""; }; E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManagerDosingTests.swift; sourceTree = ""; }; - E950CA9229002E1A00B5B692 /* LoopDataManagerUAMTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManagerUAMTests.swift; sourceTree = ""; }; E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseStoreProtocol.swift; sourceTree = ""; }; E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbStoreProtocol.swift; sourceTree = ""; }; E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStoreProtocol.swift; sourceTree = ""; }; @@ -1556,6 +1558,10 @@ E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+LoopIntents.swift"; sourceTree = ""; }; E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentExtensionInfo.swift; sourceTree = ""; }; E9B3551B292844010076AB04 /* UAMNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UAMNotification.swift; sourceTree = ""; }; + E9B3552129358C440076AB04 /* MealDetectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetectionManager.swift; sourceTree = ""; }; + E9B35525293590980076AB04 /* UAMSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UAMSettings.swift; sourceTree = ""; }; + E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetectionManagerTests.swift; sourceTree = ""; }; + E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKHealthStoreMock.swift; sourceTree = ""; }; E9BB27AA23B85C3500FB4987 /* SleepStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStore.swift; sourceTree = ""; }; E9C00EEF24C620EF00628F35 /* LoopSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettings.swift; sourceTree = ""; }; E9C00EF424C623EF00628F35 /* LoopSettings+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopSettings+Loop.swift"; sourceTree = ""; }; @@ -1772,7 +1778,7 @@ C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */, - E950CA9229002E1A00B5B692 /* LoopDataManagerUAMTests.swift */, + E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */, 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */, A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */, ); @@ -2073,7 +2079,6 @@ children = ( C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */, 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, - E9B3551B292844010076AB04 /* UAMNotification.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, @@ -2084,6 +2089,7 @@ 43D9FFD221EAE05D00AF44BF /* Info.plist */, E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, C16575742539FD60004AE16E /* LoopCoreConstants.swift */, + E9B3551B292844010076AB04 /* UAMNotification.swift */, ); path = LoopCore; sourceTree = ""; @@ -2248,6 +2254,7 @@ 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */, 1DA6499D2441266400F61E75 /* Alerts */, E95D37FF24EADE68005E2F50 /* Store Protocols */, + E9B355232935906B0076AB04 /* Missed Meal Detection */, C1F2075B26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift */, A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, ); @@ -2747,6 +2754,7 @@ E93E86B124DDE21D00FF40C8 /* MockCarbStore.swift */, E98A55F024EDD85E0008715D /* MockDosingDecisionStore.swift */, E98A55F224EDD9530008715D /* MockSettingsStore.swift */, + E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */, ); path = "Mock Stores"; sourceTree = ""; @@ -2805,6 +2813,15 @@ path = "Loop Intent Extension"; sourceTree = ""; }; + E9B355232935906B0076AB04 /* Missed Meal Detection */ = { + isa = PBXGroup; + children = ( + E9B3552129358C440076AB04 /* MealDetectionManager.swift */, + E9B35525293590980076AB04 /* UAMSettings.swift */, + ); + path = "Missed Meal Detection"; + sourceTree = ""; + }; E9C58A7624DB510500487A17 /* Fixtures */ = { isa = PBXGroup; children = ( @@ -3568,6 +3585,7 @@ C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */, A9B996F227238705002DC09C /* DosingDecisionStore.swift in Sources */, 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, + E9B355292935919E0076AB04 /* UAMSettings.swift in Sources */, 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, 4372E48B213CB5F00068E043 /* Double.swift in Sources */, @@ -3680,6 +3698,7 @@ 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */, A9CBE45C248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift in Sources */, 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, + E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */, C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */, 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */, @@ -3837,7 +3856,7 @@ A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */, 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, - E9B3551E292844B90076AB04 /* UAMNotification.swift in Sources */, + E9B3552B293591E70076AB04 /* UAMNotification.swift in Sources */, 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3889,7 +3908,7 @@ 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */, 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, - E9B3551D292844950076AB04 /* UAMNotification.swift in Sources */, + E9B3552A293591E70076AB04 /* UAMNotification.swift in Sources */, 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3930,7 +3949,7 @@ 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */, A9BD28E7272226B40071DF15 /* TestLocalizedError.swift in Sources */, A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */, - E950CA9329002E1A00B5B692 /* LoopDataManagerUAMTests.swift in Sources */, + E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */, E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */, B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */, @@ -3945,6 +3964,7 @@ E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */, A9E8A80528A7CAC000C0A8A4 /* RemoteCommandTests.swift in Sources */, 1DFE9E172447B6270082C280 /* UserNotificationAlertIssuerTests.swift in Sources */, + E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */, B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */, C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */, A9C1719725366F780053BCBD /* WatchHistoricalGlucoseTest.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index c00fdbd1da..fe22ee0c9d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -30,6 +30,8 @@ final class LoopDataManager { static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" private let carbStore: CarbStoreProtocol + + private let mealDetectionManager: MealDetectionManager private let doseStore: DoseStoreProtocol @@ -105,6 +107,7 @@ final class LoopDataManager { self.now = now self.latestStoredSettingsProvider = latestStoredSettingsProvider + self.mealDetectionManager = MealDetectionManager(carbStore: carbStore, maximumBolus: settings.maximumBolus) self.lockedPumpInsulinType = Locked(pumpInsulinType) @@ -271,6 +274,11 @@ final class LoopDataManager { invalidateCachedEffects = true analyticsServicesManager.didChangeInsulinModel() } + + // ANNA TODO: add test for this + if newValue.maximumBolus != oldValue.maximumBolus { + mealDetectionManager.maximumBolus = newValue.maximumBolus + } if invalidateCachedEffects { dataAccessQueue.async { @@ -399,14 +407,6 @@ final class LoopDataManager { // Confined to dataAccessQueue private var retrospectiveCorrection: RetrospectiveCorrection - - /// The last unannounced meal notification that was sent - /// Internal for unit testing - internal var lastUAMNotification: UAMNotification? = UserDefaults.standard.lastUAMNotification { - didSet { - UserDefaults.standard.lastUAMNotification = lastUAMNotification - } - } // MARK: - Background task management @@ -1445,58 +1445,6 @@ extension LoopDataManager { volumeRounder: volumeRounder ) } - - public func generateUnannouncedMealNotificationIfNeeded(using insulinCounteractionEffects: [GlucoseEffectVelocity], pendingAutobolusUnits: Double? = nil) { - carbStore.hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in - self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits) - } - } - - // Internal for unit testing - internal func manageMealNotifications(for status: UnannouncedMealStatus, pendingAutobolusUnits: Double? = nil) { - // We should remove expired notifications regardless of whether or not there was a meal - NotificationManager.removeExpiredMealNotifications() - - // Figure out if we should deliver a notification - let now = now() - let notificationTimeTooRecent = now.timeIntervalSince(lastUAMNotification?.deliveryTime ?? .distantPast) < (UAMSettings.maxRecency - UAMSettings.minRecency) - - guard - case .hasUnannouncedMeal(let startTime, let carbAmount) = status, - !notificationTimeTooRecent, - UserDefaults.standard.unannouncedMealNotificationsEnabled - else { - // No notification needed! - return - } - - var clampedCarbAmount = carbAmount - if - let maxBolus = settings.maximumBolus, - let currentCarbRatio = settings.carbRatioSchedule?.quantity(at: now).doubleValue(for: .gram()) - { - let maxAllowedCarbAutofill = maxBolus * currentCarbRatio - clampedCarbAmount = min(clampedCarbAmount, maxAllowedCarbAutofill) - } - - logger.debug("Delivering a missed meal notification") - - /// Coordinate the unannounced meal notification time with any pending autoboluses that `update` may have started - /// so that the user doesn't have to cancel the current autobolus to bolus in response to the missed meal notification - if - let pendingAutobolusUnits, - pendingAutobolusUnits > 0, - let estimatedBolusDuration = delegate?.loopDataManager(self, estimateBolusDuration: pendingAutobolusUnits), - estimatedBolusDuration < UAMSettings.maxNotificationDelay - { - NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) - lastUAMNotification = UAMNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), - carbAmount: clampedCarbAmount) - } else { - NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount) - lastUAMNotification = UAMNotification(deliveryTime: now, carbAmount: clampedCarbAmount) - } - } /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. /// @@ -1723,8 +1671,13 @@ extension LoopDataManager { private func enactRecommendedAutomaticDose() -> LoopError? { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - generateUnannouncedMealNotificationIfNeeded(using: insulinCounteractionEffects, - pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits) + mealDetectionManager.generateUnannouncedMealNotificationIfNeeded( + using: insulinCounteractionEffects, + pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, + bolusDurationEstimator: { [unowned self] bolusAmount in + return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) + } + ) guard let recommendedDose = self.recommendedAutomaticDose else { return nil @@ -2086,8 +2039,6 @@ extension LoopDataManager { "recommendedAutomaticDose: \(String(describing: state.recommendedAutomaticDose))", "lastBolus: \(String(describing: manager.lastRequestedBolus))", "lastLoopCompleted: \(String(describing: manager.lastLoopCompleted))", - "lastUnannouncedMealNotificationTime: \(String(describing: self.lastUAMNotification?.deliveryTime))", - "lastUnannouncedMealCarbEstimate: \(String(describing: self.lastUAMNotification?.carbAmount))", "basalDeliveryState: \(String(describing: manager.basalDeliveryState))", "carbsOnBoard: \(String(describing: state.carbsOnBoard))", "error: \(String(describing: state.error))", @@ -2108,16 +2059,21 @@ extension LoopDataManager { self.doseStore.generateDiagnosticReport { (report) in entries.append(report) entries.append("") - - UNUserNotificationCenter.current().generateDiagnosticReport { (report) in + + self.mealDetectionManager.generateDiagnosticReport { report in entries.append(report) entries.append("") - - UIDevice.current.generateDiagnosticReport { (report) in + + UNUserNotificationCenter.current().generateDiagnosticReport { (report) in entries.append(report) entries.append("") - completion(entries.joined(separator: "\n")) + UIDevice.current.generateDiagnosticReport { (report) in + entries.append(report) + entries.append("") + + completion(entries.joined(separator: "\n")) + } } } } diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift new file mode 100644 index 0000000000..0b6092108b --- /dev/null +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -0,0 +1,311 @@ +// +// MealDetectionManager.swift +// Loop +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import OSLog +import LoopCore +import LoopKit + +enum UnannouncedMealStatus: Equatable { + case hasUnannouncedMeal(startTime: Date, carbAmount: Double) + case noUnannouncedMeal +} + +class MealDetectionManager { + private let log = OSLog(category: "MealDetectionManager") + + public var maximumBolus: Double? + + /// The last unannounced meal notification that was sent + /// Internal for unit testing + var lastUAMNotification: UAMNotification? = UserDefaults.standard.lastUAMNotification { + didSet { + UserDefaults.standard.lastUAMNotification = lastUAMNotification + } + } + + private var carbStore: CarbStoreProtocol + + /// Debug info for UAM + /// Timeline from the most recent check for unannounced meals + private var lastEvaluatedUamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + + /// Timeline from the most recent detection of an unannounced meal + private var lastDetectedUamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + + /// Allows for controlling uses of the system date in unit testing + internal var test_currentDate: Date? + + /// Current date. Will return the unit-test configured date if set, or the current date otherwise. + internal var currentDate: Date { + test_currentDate ?? Date() + } + + internal func currentDate(timeIntervalSinceNow: TimeInterval = 0) -> Date { + return currentDate.addingTimeInterval(timeIntervalSinceNow) + } + + public init( + carbStore: CarbStoreProtocol, + maximumBolus: Double?, + test_currentDate: Date? = nil + ) { + self.carbStore = carbStore + self.maximumBolus = maximumBolus + self.test_currentDate = test_currentDate + } + + // MARK: Meal Detection + func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { + let delta = TimeInterval(minutes: 5) + + let intervalStart = currentDate(timeIntervalSinceNow: -UAMSettings.maxRecency) + let intervalEnd = currentDate(timeIntervalSinceNow: -UAMSettings.minRecency) + let now = self.currentDate + + carbStore.getGlucoseEffects(start: intervalStart, end: now, effectVelocities: insulinCounteractionEffects) {[weak self] result in + guard + let self = self, + case .success((let carbEntries, let carbEffects)) = result + else { + if case .failure(let error) = result { + self?.log.error("Failed to fetch glucose effects to check for missed meal: %{public}@", String(describing: error)) + } + + completion(.noUnannouncedMeal) + return + } + + /// Compute how much of the ICE effect we can't explain via our entered carbs + /// Effect caching inspired by `LoopMath.predictGlucose` + var effectValueCache: [Date: Double] = [:] + let unit = HKUnit.milligramsPerDeciliter + + /// Carb effects are cumulative, so we have to subtract the previous effect value + var previousEffectValue: Double = carbEffects.first?.quantity.doubleValue(for: unit) ?? 0 + + /// Counteraction effects only take insulin into account, so we need to account for the carb effects when computing the unexpected deviations + for effect in carbEffects { + let value = effect.quantity.doubleValue(for: unit) + /// We do `-1 * (value - previousEffectValue)` because this will compute the carb _counteraction_ effect + effectValueCache[effect.startDate] = (effectValueCache[effect.startDate] ?? 0) + -1 * (value - previousEffectValue) + previousEffectValue = value + } + + let processedICE = insulinCounteractionEffects + .filterDateRange(intervalStart, now) + .compactMap { + /// Clamp starts & ends to `intervalStart...now` since our algorithm assumes all effects occur within that interval + let start = max($0.startDate, intervalStart) + let end = min($0.endDate, now) + + guard let effect = $0.effect(from: start, to: end) else { + let item: GlucoseEffect? = nil // FIXME: we get a compiler error if we try to return `nil` directly + return item + } + + return GlucoseEffect(startDate: effect.startDate.dateFlooredToTimeInterval(delta), + quantity: effect.quantity) + } + + for effect in processedICE { + let value = effect.quantity.doubleValue(for: unit) + effectValueCache[effect.startDate] = (effectValueCache[effect.startDate] ?? 0) + value + } + + var unexpectedDeviation: Double = 0 + var mealTime = now + + /// Dates the algorithm uses when computing effects + /// Have the range go from newest -> oldest time + let summationRange = LoopMath.simulationDateRange(from: intervalStart, + to: now, + delta: delta) + .reversed() + + /// Dates the algorithm is allowed to check for the presence of a UAM + let dateSearchRange = Set(LoopMath.simulationDateRange(from: intervalStart, + to: intervalEnd, + delta: delta)) + + /// Timeline used for debug purposes + var uamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + + for pastTime in summationRange { + guard + let unexpectedEffect = effectValueCache[pastTime], + !carbEntries.contains(where: { $0.startDate >= pastTime }) + else { + uamTimeline.append((pastTime, nil, nil, nil)) + continue + } + + unexpectedDeviation += unexpectedEffect + + guard dateSearchRange.contains(pastTime) else { + /// This time is too recent to check for a UAM + uamTimeline.append((pastTime, unexpectedDeviation, nil, nil)) + continue + } + + /// Find the threshold based on a minimum of `unannouncedMealGlucoseRiseThreshold` of change per minute + let minutesAgo = now.timeIntervalSince(pastTime).minutes + let deviationChangeThreshold = UAMSettings.glucoseRiseThreshold * minutesAgo + + /// Find the total effect we'd expect to see for a meal with `carbThreshold`-worth of carbs that started at `pastTime` + guard let modeledMealEffectThreshold = self.effectThreshold(mealStart: pastTime, carbsInGrams: UAMSettings.minCarbThreshold) else { + continue + } + + uamTimeline.append((pastTime, unexpectedDeviation, modeledMealEffectThreshold, deviationChangeThreshold)) + + /// Use the higher of the 2 thresholds to ensure noisy CGM data doesn't cause false-positives for more recent times + let effectThreshold = max(deviationChangeThreshold, modeledMealEffectThreshold) + + if unexpectedDeviation >= effectThreshold { + mealTime = pastTime + } + } + + self.lastEvaluatedUamTimeline = uamTimeline.reversed() + + let mealTimeTooRecent = now.timeIntervalSince(mealTime) < UAMSettings.minRecency + guard !mealTimeTooRecent else { + completion(.noUnannouncedMeal) + return + } + + self.lastDetectedUamTimeline = uamTimeline.reversed() + + let carbAmount = self.determineCarbs(mealtime: mealTime, unexpectedDeviation: unexpectedDeviation) + completion(.hasUnannouncedMeal(startTime: mealTime, carbAmount: carbAmount ?? UAMSettings.minCarbThreshold)) + } + } + + private func determineCarbs(mealtime: Date, unexpectedDeviation: Double) -> Double? { + var mealCarbs: Double? = nil + + /// Search `carbAmount`s from `minCarbThreshold` to `maxCarbThreshold` in 5-gram increments, + /// seeing if the deviation is at least `carbAmount` of carbs + for carbAmount in stride(from: UAMSettings.minCarbThreshold, through: UAMSettings.maxCarbThreshold, by: 5) { + if + let modeledCarbEffect = effectThreshold(mealStart: mealtime, carbsInGrams: carbAmount), + unexpectedDeviation >= modeledCarbEffect + { + mealCarbs = carbAmount + } + } + + return mealCarbs + } + + private func effectThreshold(mealStart: Date, carbsInGrams: Double) -> Double? { + do { + return try carbStore.glucoseEffects( + of: [NewCarbEntry(quantity: HKQuantity(unit: .gram(), + doubleValue: carbsInGrams), + startDate: mealStart, + foodType: nil, + absorptionTime: nil) + ], + startingAt: mealStart, + endingAt: nil, + effectVelocities: nil + ) + .last? + .quantity.doubleValue(for: HKUnit.milligramsPerDeciliter) + } catch let error { + self.log.error("Error fetching carb glucose effects: %{public}@", String(describing: error)) + } + + return nil + } + + // MARK: Notification Generation + func generateUnannouncedMealNotificationIfNeeded( + using insulinCounteractionEffects: [GlucoseEffectVelocity], + pendingAutobolusUnits: Double? = nil, + bolusDurationEstimator: @escaping (Double) -> TimeInterval? + ) { + hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in + self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits, bolusDurationEstimator: bolusDurationEstimator) + } + } + + + // Internal for unit testing + func manageMealNotifications(for status: UnannouncedMealStatus, pendingAutobolusUnits: Double? = nil, bolusDurationEstimator getBolusDuration: (Double) -> TimeInterval?) { + // We should remove expired notifications regardless of whether or not there was a meal + NotificationManager.removeExpiredMealNotifications() + + // Figure out if we should deliver a notification + let now = self.currentDate + let notificationTimeTooRecent = now.timeIntervalSince(lastUAMNotification?.deliveryTime ?? .distantPast) < (UAMSettings.maxRecency - UAMSettings.minRecency) + + guard + case .hasUnannouncedMeal(let startTime, let carbAmount) = status, + !notificationTimeTooRecent, + UserDefaults.standard.unannouncedMealNotificationsEnabled + else { + // No notification needed! + return + } + + var clampedCarbAmount = carbAmount + if + let maxBolus = maximumBolus, + let currentCarbRatio = carbStore.carbRatioSchedule?.quantity(at: now).doubleValue(for: .gram()) + { + let maxAllowedCarbAutofill = maxBolus * currentCarbRatio + clampedCarbAmount = min(clampedCarbAmount, maxAllowedCarbAutofill) + } + + log.debug("Delivering a missed meal notification") + + /// Coordinate the unannounced meal notification time with any pending autoboluses that `update` may have started + /// so that the user doesn't have to cancel the current autobolus to bolus in response to the missed meal notification + if + let pendingAutobolusUnits, + pendingAutobolusUnits > 0, + let estimatedBolusDuration = getBolusDuration(pendingAutobolusUnits), + estimatedBolusDuration < UAMSettings.maxNotificationDelay + { + NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) + lastUAMNotification = UAMNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), + carbAmount: clampedCarbAmount) + } else { + NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount) + lastUAMNotification = UAMNotification(deliveryTime: now, carbAmount: clampedCarbAmount) + } + } + + // MARK: Logging + + /// Generates a diagnostic report about the current state + /// + /// - parameter completionHandler: A closure called once the report has been generated. The closure takes a single argument of the report string. + func generateDiagnosticReport(_ completionHandler: @escaping (_ report: String) -> Void) { + let report = [ + "## MealDetectionManager", + "", + "* lastUnannouncedMealNotificationTime: \(String(describing: lastUAMNotification?.deliveryTime))", + "* lastUnannouncedMealCarbEstimate: \(String(describing: lastUAMNotification?.carbAmount))", + "* lastEvaluatedUnannouncedMealTimeline:", + lastEvaluatedUamTimeline.reduce(into: "", { (entries, entry) in + entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") + }), + "* lastDetectedUnannouncedMealTimeline:", + lastDetectedUamTimeline.reduce(into: "", { (entries, entry) in + entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") + }) + ] + + completionHandler(report.joined(separator: "\n")) + } +} diff --git a/Loop/Managers/Missed Meal Detection/UAMSettings.swift b/Loop/Managers/Missed Meal Detection/UAMSettings.swift new file mode 100644 index 0000000000..6998146571 --- /dev/null +++ b/Loop/Managers/Missed Meal Detection/UAMSettings.swift @@ -0,0 +1,25 @@ +// +// UAMSettings.swift +// Loop +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct UAMSettings { + /// Minimum grams of unannounced carbs that must be detected for a notification to be delivered + public static let minCarbThreshold: Double = 20 // grams + /// Maximum grams of unannounced carbs that the algorithm will search for + public static let maxCarbThreshold: Double = 80 // grams + /// Minimum threshold for glucose rise over the detection window + static let glucoseRiseThreshold = 2.0 // mg/dL/m + /// Minimum time from now that must have passed for the meal to be detected + public static let minRecency = TimeInterval(minutes: 25) + /// Maximum time from now that a meal can be detected + public static let maxRecency = TimeInterval(hours: 2) + /// Maximum delay allowed in missed meal notification time to avoid + /// notifying the user during an autobolus + public static let maxNotificationDelay = TimeInterval(minutes: 4) +} diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index a1db69d0c3..41308cc24e 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -60,9 +60,6 @@ protocol CarbStoreProtocol: AnyObject { func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) - - // MARK: Unannounced Meal Detection - func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) } extension CarbStore: CarbStoreProtocol { } diff --git a/LoopTests/Managers/LoopDataManagerUAMTests.swift b/LoopTests/Managers/LoopDataManagerUAMTests.swift deleted file mode 100644 index 8141dba1f0..0000000000 --- a/LoopTests/Managers/LoopDataManagerUAMTests.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// LoopDataManagerUAMTests.swift -// LoopTests -// -// Created by Anna Quinlan on 10/19/22. -// Copyright © 2022 LoopKit Authors. All rights reserved. -// - -import XCTest -import HealthKit -import LoopKit -@testable import LoopCore -@testable import Loop - -class LoopDataManagerUAMTests: LoopDataManagerTests { - // MARK: Testing Utilities - override func tearDownWithError() throws { - loopDataManager.lastUAMNotification = nil - UserDefaults.standard.unannouncedMealNotificationsEnabled = false - try super.tearDownWithError() - } - - // MARK: Tests - func testNoUnannouncedMealLastNotificationTime() { - setUp(for: .highAndRisingWithCOB) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.noUnannouncedMeal - loopDataManager.manageMealNotifications(for: status) - - XCTAssertNil(loopDataManager.lastUAMNotification) - } - - func testUnannouncedMealUpdatesLastNotificationTime() { - setUp(for: .highAndRisingWithCOB) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) - loopDataManager.manageMealNotifications(for: status) - - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 40) - } - - func testUnannouncedMealWithoutNotificationsEnabled() { - setUp(for: .highAndRisingWithCOB) - UserDefaults.standard.unannouncedMealNotificationsEnabled = false - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) - loopDataManager.manageMealNotifications(for: status) - - XCTAssertNil(loopDataManager.lastUAMNotification) - } - - func testUnannouncedMealWithTooRecentNotificationTime() { - setUp(for: .highAndRisingWithCOB) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let oldTime = now.addingTimeInterval(.hours(1)) - let oldNotification = UAMNotification(deliveryTime: oldTime, carbAmount: 40) - loopDataManager.lastUAMNotification = oldNotification - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: UAMSettings.minCarbThreshold) - loopDataManager.manageMealNotifications(for: status) - - XCTAssertEqual(loopDataManager.lastUAMNotification, oldNotification) - } - - func testUnannouncedMealCarbClamping() { - setUp(for: .highAndRisingWithCOB) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 120) - loopDataManager.manageMealNotifications(for: status) - - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 100) - } - - func testUnannouncedMealNoPendingBolus() { - setUp(for: .highAndRisingWithCOB) - - let delegate = MockDelegate() - loopDataManager.delegate = delegate - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) - loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 0) - - /// The bolus units time delegate should never be called if there are 0 pending units - XCTAssertNil(delegate.bolusUnits) - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 40) - } - - func testUnannouncedMealLongPendingBolus() { - setUp(for: .highAndRisingWithCOB) - - let delegate = MockDelegate() - loopDataManager.delegate = delegate - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) - loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10) - - XCTAssertEqual(delegate.bolusUnits, 10) - /// There shouldn't be a delay in delivering notification, since the autobolus will take the length of the notification window to deliver - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(loopDataManager.lastUAMNotification?.carbAmount, 40) - } - - func testNoUnannouncedMealShortPendingBolus_DelaysNotificationTime() { - setUp(for: .highAndRisingWithCOB) - - let delegate = MockDelegate() - loopDataManager.delegate = delegate - UserDefaults.standard.unannouncedMealNotificationsEnabled = true - - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 30) - loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 2) - - let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(80)) - XCTAssertEqual(delegate.bolusUnits, 2) - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime) - - loopDataManager.lastUAMNotification = nil - loopDataManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5) - - let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) - XCTAssertEqual(delegate.bolusUnits, 4.5) - XCTAssertEqual(loopDataManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime2) - } -} - diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift new file mode 100644 index 0000000000..191001c843 --- /dev/null +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -0,0 +1,453 @@ +// +// MealDetectionManagerTests.swift +// LoopTests +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopCore +import LoopKit +@testable import Loop + +enum UAMTestType { + private static var dateFormatter = ISO8601DateFormatter.localTimeDate() + + /// No meal is present + case noMeal + /// No meal is present, but if the counteraction effects aren't clamped properly it will look like there's a UAM + case noMealCounteractionEffectsNeedClamping + // No meal is present and there is COB + case noMealWithCOB + /// UAM with no carbs on board + case unannouncedMealNoCOB + /// UAM with carbs logged prior to it + case unannouncedMealWithCOB + /// There is a meal, but it's announced and not unannounced + case announcedMeal + /// CGM data is noisy, but no meal is present + case noisyCGM + /// Realistic counteraction effects with multiple meals + case manyMeals + /// Test case to test dynamic computation of missed meal carb amount + case dynamicCarbAutofill + /// Test case for purely testing the notifications (not the algorithm) + case notificationTest +} + +extension UAMTestType { + var counteractionEffectFixture: String { + switch self { + case .unannouncedMealNoCOB, .noMealWithCOB, .notificationTest: + return "uam_counteraction_effect" + case .noMeal, .announcedMeal: + return "long_interval_counteraction_effect" + case .noMealCounteractionEffectsNeedClamping: + return "needs_clamping_counteraction_effect" + case .noisyCGM: + return "noisy_cgm_counteraction_effect" + case .manyMeals, .unannouncedMealWithCOB: + return "realistic_report_counteraction_effect" + case .dynamicCarbAutofill: + return "dynamic_autofill_counteraction_effect" + } + } + + var currentDate: Date { + switch self { + case .unannouncedMealNoCOB, .noMealWithCOB, .notificationTest: + return Self.dateFormatter.date(from: "2022-10-17T23:28:45")! + case .noMeal, .noMealCounteractionEffectsNeedClamping, .announcedMeal: + return Self.dateFormatter.date(from: "2022-10-17T02:49:16")! + case .noisyCGM: + return Self.dateFormatter.date(from: "2022-10-19T20:46:23")! + case .unannouncedMealWithCOB: + return Self.dateFormatter.date(from: "2022-10-19T19:50:15")! + case .manyMeals: + return Self.dateFormatter.date(from: "2022-10-19T21:50:15")! + case .dynamicCarbAutofill: + return Self.dateFormatter.date(from: "2022-10-17T07:51:09")! + } + } + + var uamDate: Date? { + switch self { + case .unannouncedMealNoCOB: + return Self.dateFormatter.date(from: "2022-10-17T22:10:00") + case .unannouncedMealWithCOB: + return Self.dateFormatter.date(from: "2022-10-19T19:15:00") + case .dynamicCarbAutofill: + return Self.dateFormatter.date(from: "2022-10-17T07:20:00")! + default: + return nil + } + } + + var carbEntries: [NewCarbEntry] { + switch self { + case .unannouncedMealWithCOB: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 10), + startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, + foodType: nil, + absorptionTime: nil) + ] + case .announcedMeal: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 40), + startDate: Self.dateFormatter.date(from: "2022-10-17T01:06:52")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1), + startDate: Self.dateFormatter.date(from: "2022-10-17T02:15:00")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-17T02:35:00")!, + foodType: nil, + absorptionTime: nil), + ] + case .noMealWithCOB: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-17T22:40:00")!, + foodType: nil, + absorptionTime: nil) + ] + case .manyMeals: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 10), + startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 40), + startDate: Self.dateFormatter.date(from: "2022-10-19T19:11:43")!, + foodType: nil, + absorptionTime: nil) + ] + default: + return [] + } + } + + var carbSchedule: CarbRatioSchedule { + CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 15.0), + ], + timeZone: .utcTimeZone + )! + } + + var insulinSensitivitySchedule: InsulinSensitivitySchedule { + InsulinSensitivitySchedule( + unit: HKUnit.milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 50.0) + ], + timeZone: .utcTimeZone + )! + } +} + +class MealDetectionManagerTests { + let dateFormatter = ISO8601DateFormatter.localTimeDate() + let pumpManager = MockPumpManager() + + var mealDetectionManager: MealDetectionManager! + + var now: Date { + mealDetectionManager.test_currentDate! + } + + var bolusUnits: Double? + var bolusDurationEstimator: ((Double) -> TimeInterval?)! + + @discardableResult func setUp(for testType: UAMTestType) -> [GlucoseEffectVelocity] { + let healthStore = HKHealthStoreMock() + + let carbStore = CarbStore( + healthStore: healthStore, + cacheStore: PersistenceController(directoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString, isDirectory: true)), + cacheLength: .hours(24), + defaultAbsorptionTimes: (fast: .minutes(30), medium: .hours(3), slow: .hours(5)), + observationInterval: 0, + overrideHistory: TemporaryScheduleOverrideHistory(), + provenanceIdentifier: Bundle.main.bundleIdentifier!, + test_currentDate: testType.currentDate) + + // Set up schedules + carbStore.carbRatioSchedule = testType.carbSchedule + carbStore.insulinSensitivitySchedule = testType.insulinSensitivitySchedule + + // Add any needed carb entries to the carb store + let updateGroup = DispatchGroup() + testType.carbEntries.forEach { carbEntry in + updateGroup.enter() + carbStore.addCarbEntry(carbEntry) { result in + if case .failure(_) = result { + XCTFail("Failed to add carb entry to carb store") + } + + updateGroup.leave() + } + } + _ = updateGroup.wait(timeout: .now() + .seconds(5)) + + mealDetectionManager = MealDetectionManager( + carbStore: carbStore, + maximumBolus: 5, + test_currentDate: testType.currentDate + ) + + bolusDurationEstimator = { units in + self.bolusUnits = units + return self.pumpManager.estimatedDuration(toBolus: units) + } + + // Fetch & return the counteraction effects for the test + return counteractionEffects(for: testType) + } + + private func counteractionEffects(for testType: UAMTestType) -> [GlucoseEffectVelocity] { + let fixture: [JSONDictionary] = loadFixture(testType.counteractionEffectFixture) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + GlucoseEffectVelocity(startDate: dateFormatter.date(from: $0["startDate"] as! String)!, + endDate: dateFormatter.date(from: $0["endDate"] as! String)!, + quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), + doubleValue:$0["value"] as! Double)) + } + } + + func tearDown() { + mealDetectionManager.lastUAMNotification = nil + mealDetectionManager = nil + UserDefaults.standard.unannouncedMealNotificationsEnabled = false + } + + // MARK: - Algorithm Tests + func testNoUnannouncedMeal() { + let counteractionEffects = setUp(for: .noMeal) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noUnannouncedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testNoUnannouncedMeal_WithCOB() { + let counteractionEffects = setUp(for: .noMealWithCOB) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noUnannouncedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testUnannouncedMeal_NoCarbEntry() { + let testType = UAMTestType.unannouncedMealNoCOB + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .hasUnannouncedMeal(startTime: testType.uamDate!, carbAmount: 55)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testDynamicCarbAutofill() { + let testType = UAMTestType.dynamicCarbAutofill + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .hasUnannouncedMeal(startTime: testType.uamDate!, carbAmount: 25)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testUnannouncedMeal_UAMAndCOB() { + let testType = UAMTestType.unannouncedMealWithCOB + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .hasUnannouncedMeal(startTime: testType.uamDate!, carbAmount: 50)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testNoUnannouncedMeal_AnnouncedMealPresent() { + let counteractionEffects = setUp(for: .announcedMeal) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noUnannouncedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testNoisyCGM() { + let counteractionEffects = setUp(for: .noisyCGM) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noUnannouncedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testManyMeals() { + let counteractionEffects = setUp(for: .manyMeals) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noUnannouncedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + // MARK: - Notification Tests + func testNoUnannouncedMealLastNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.noUnannouncedMeal + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertNil(mealDetectionManager.lastUAMNotification) + } + + func testUnannouncedMealUpdatesLastNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 40) + } + + func testUnannouncedMealWithoutNotificationsEnabled() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = false + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertNil(mealDetectionManager.lastUAMNotification) + } + + func testUnannouncedMealWithTooRecentNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let oldTime = now.addingTimeInterval(.hours(1)) + let oldNotification = UAMNotification(deliveryTime: oldTime, carbAmount: 40) + mealDetectionManager.lastUAMNotification = oldNotification + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: UAMSettings.minCarbThreshold) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastUAMNotification, oldNotification) + } + + func testUnannouncedMealCarbClamping() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 120) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 100) + } + + func testUnannouncedMealNoPendingBolus() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 0, bolusDurationEstimator: bolusDurationEstimator) + + /// The bolus units time delegate should never be called if there are 0 pending units + XCTAssertNil(bolusUnits) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 40) + } + + func testUnannouncedMealLongPendingBolus() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10, bolusDurationEstimator: bolusDurationEstimator) + + XCTAssertEqual(bolusUnits, 10) + /// There shouldn't be a delay in delivering notification, since the autobolus will take the length of the notification window to deliver + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 40) + } + + func testNoUnannouncedMealShortPendingBolus_DelaysNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.unannouncedMealNotificationsEnabled = true + + let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 30) + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 2, bolusDurationEstimator: bolusDurationEstimator) + + let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(80)) + XCTAssertEqual(bolusUnits, 2) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime) + + mealDetectionManager.lastUAMNotification = nil + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5, bolusDurationEstimator: bolusDurationEstimator) + + let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) + XCTAssertEqual(bolusUnits, 4.5) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime2) + } +} + +extension MealDetectionManagerTests { + public var bundle: Bundle { + return Bundle(for: type(of: self)) + } + + public func loadFixture(_ resourceName: String) -> T { + let path = bundle.path(forResource: resourceName, ofType: "json")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T + } +} diff --git a/LoopTests/Mock Stores/HKHealthStoreMock.swift b/LoopTests/Mock Stores/HKHealthStoreMock.swift new file mode 100644 index 0000000000..6f8127d3e4 --- /dev/null +++ b/LoopTests/Mock Stores/HKHealthStoreMock.swift @@ -0,0 +1,82 @@ +// +// HKHealthStoreMock.swift +// LoopTests +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import HealthKit +import Foundation +import LoopKit + + +class HKHealthStoreMock: HKHealthStore { + var saveError: Error? + var deleteError: Error? + var queryResults: (samples: [HKSample]?, error: Error?)? + var lastQuery: HKQuery? + var authorizationStatus: HKAuthorizationStatus? + + private var saveHandler: ((_ objects: [HKObject], _ success: Bool, _ error: Error?) -> Void)? + private var deleteObjectsHandler: ((_ objectType: HKObjectType, _ predicate: NSPredicate, _ success: Bool, _ count: Int, _ error: Error?) -> Void)? + + let queue = DispatchQueue(label: "HKHealthStoreMock") + + override func save(_ object: HKObject, withCompletion completion: @escaping (Bool, Error?) -> Void) { + queue.async { + self.saveHandler?([object], self.saveError == nil, self.saveError) + completion(self.saveError == nil, self.saveError) + } + } + + override func save(_ objects: [HKObject], withCompletion completion: @escaping (Bool, Error?) -> Void) { + queue.async { + self.saveHandler?(objects, self.saveError == nil, self.saveError) + completion(self.saveError == nil, self.saveError) + } + } + + override func delete(_ objects: [HKObject], withCompletion completion: @escaping (Bool, Error?) -> Void) { + queue.async { + completion(self.deleteError == nil, self.deleteError) + } + } + + override func deleteObjects(of objectType: HKObjectType, predicate: NSPredicate, withCompletion completion: @escaping (Bool, Int, Error?) -> Void) { + queue.async { + self.deleteObjectsHandler?(objectType, predicate, self.deleteError == nil, 0, self.deleteError) + completion(self.deleteError == nil, 0, self.deleteError) + } + } + + func setSaveHandler(_ saveHandler: ((_ objects: [HKObject], _ success: Bool, _ error: Error?) -> Void)?) { + queue.sync { + self.saveHandler = saveHandler + } + } + + override func requestAuthorization(toShare typesToShare: Set?, read typesToRead: Set?, completion: @escaping (Bool, Error?) -> Void) { + DispatchQueue.main.async { + completion(true, nil) + } + } + + override func authorizationStatus(for type: HKObjectType) -> HKAuthorizationStatus { + return authorizationStatus ?? .notDetermined + } + + func setDeletedObjectsHandler(_ deleteObjectsHandler: ((_ objectType: HKObjectType, _ predicate: NSPredicate, _ success: Bool, _ count: Int, _ error: Error?) -> Void)?) { + queue.sync { + self.deleteObjectsHandler = deleteObjectsHandler + } + } +} + +extension HKHealthStoreMock { + + override func execute(_ query: HKQuery) { + self.lastQuery = query + } +} + diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 527f03c950..f61fcd70ed 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -106,10 +106,6 @@ class MockCarbStore: CarbStoreProtocol { return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) }))) } - - func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { - completion(.noUnannouncedMeal) - } } extension MockCarbStore { From 686bf70b3c67cef876ef7f310d054a92ef0b4c85 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 29 Nov 2022 19:39:09 -0800 Subject: [PATCH 36/51] Move UAM test fixtures from LoopKit to Loop --- Loop.xcodeproj/project.pbxproj | 56 +- ...dynamic_autofill_counteraction_effect.json | 1730 +++++++++++++++++ .../long_interval_counteraction_effect.json | 14 + .../needs_clamping_counteraction_effect.json | 14 + .../noisy_cgm_counteraction_effect.json | 386 ++++ ...realistic_report_counteraction_effect.json | 1724 ++++++++++++++++ .../uam_counteraction_effect.json | 122 ++ 7 files changed, 4034 insertions(+), 12 deletions(-) create mode 100644 LoopTests/Fixtures/meal_detection/dynamic_autofill_counteraction_effect.json create mode 100644 LoopTests/Fixtures/meal_detection/long_interval_counteraction_effect.json create mode 100644 LoopTests/Fixtures/meal_detection/needs_clamping_counteraction_effect.json create mode 100644 LoopTests/Fixtures/meal_detection/noisy_cgm_counteraction_effect.json create mode 100644 LoopTests/Fixtures/meal_detection/realistic_report_counteraction_effect.json create mode 100644 LoopTests/Fixtures/meal_detection/uam_counteraction_effect.json diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 733fef8a36..365f6a9ed2 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -598,6 +598,12 @@ E9B3552B293591E70076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */; }; E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */; }; + E9B35538293706CB0076AB04 /* needs_clamping_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */; }; + E9B35539293706CB0076AB04 /* dynamic_autofill_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */; }; + E9B3553A293706CB0076AB04 /* uam_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35534293706CB0076AB04 /* uam_counteraction_effect.json */; }; + E9B3553B293706CB0076AB04 /* noisy_cgm_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35535293706CB0076AB04 /* noisy_cgm_counteraction_effect.json */; }; + E9B3553C293706CB0076AB04 /* realistic_report_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35536293706CB0076AB04 /* realistic_report_counteraction_effect.json */; }; + E9B3553D293706CB0076AB04 /* long_interval_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35537293706CB0076AB04 /* long_interval_counteraction_effect.json */; }; E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BB27AA23B85C3500FB4987 /* SleepStore.swift */; }; E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EEF24C620EF00628F35 /* LoopSettings.swift */; }; E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EEF24C620EF00628F35 /* LoopSettings.swift */; }; @@ -1562,6 +1568,12 @@ E9B35525293590980076AB04 /* UAMSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UAMSettings.swift; sourceTree = ""; }; E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetectionManagerTests.swift; sourceTree = ""; }; E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKHealthStoreMock.swift; sourceTree = ""; }; + E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = needs_clamping_counteraction_effect.json; sourceTree = ""; }; + E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = dynamic_autofill_counteraction_effect.json; sourceTree = ""; }; + E9B35534293706CB0076AB04 /* uam_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = uam_counteraction_effect.json; sourceTree = ""; }; + E9B35535293706CB0076AB04 /* noisy_cgm_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = noisy_cgm_counteraction_effect.json; sourceTree = ""; }; + E9B35536293706CB0076AB04 /* realistic_report_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = realistic_report_counteraction_effect.json; sourceTree = ""; }; + E9B35537293706CB0076AB04 /* long_interval_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = long_interval_counteraction_effect.json; sourceTree = ""; }; E9BB27AA23B85C3500FB4987 /* SleepStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStore.swift; sourceTree = ""; }; E9C00EEF24C620EF00628F35 /* LoopSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettings.swift; sourceTree = ""; }; E9C00EF424C623EF00628F35 /* LoopSettings+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopSettings+Loop.swift"; sourceTree = ""; }; @@ -2822,9 +2834,23 @@ path = "Missed Meal Detection"; sourceTree = ""; }; + E9B355312937068A0076AB04 /* meal_detection */ = { + isa = PBXGroup; + children = ( + E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */, + E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */, + E9B35534293706CB0076AB04 /* uam_counteraction_effect.json */, + E9B35535293706CB0076AB04 /* noisy_cgm_counteraction_effect.json */, + E9B35537293706CB0076AB04 /* long_interval_counteraction_effect.json */, + E9B35536293706CB0076AB04 /* realistic_report_counteraction_effect.json */, + ); + path = meal_detection; + sourceTree = ""; + }; E9C58A7624DB510500487A17 /* Fixtures */ = { isa = PBXGroup; children = ( + E9B355312937068A0076AB04 /* meal_detection */, E90909EC24E35B3400F963D2 /* high_and_falling */, E90909E124E352C300F963D2 /* low_with_low_treatment */, E90909D624E34EC200F963D2 /* low_and_falling */, @@ -3395,6 +3421,9 @@ E93E865424DB6CBA00FF40C8 /* retrospective_output.json in Resources */, E9C58A7F24DB529A00487A17 /* counteraction_effect_falling_glucose.json in Resources */, E93E865624DB731900FF40C8 /* predicted_glucose_without_retrospective.json in Resources */, + E9B3553D293706CB0076AB04 /* long_interval_counteraction_effect.json in Resources */, + E9B3553A293706CB0076AB04 /* uam_counteraction_effect.json in Resources */, + E9B35538293706CB0076AB04 /* needs_clamping_counteraction_effect.json in Resources */, E90909D424E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json in Resources */, E93E86CC24E2E02200FF40C8 /* high_and_stable_predicted_glucose.json in Resources */, E90909DC24E34F1600F963D2 /* low_and_falling_predicted_glucose.json in Resources */, @@ -3409,10 +3438,13 @@ E90909DF24E34F1600F963D2 /* low_and_falling_insulin_effect.json in Resources */, E93E86BE24E1FDC400FF40C8 /* flat_and_stable_carb_effect.json in Resources */, E90909E824E3530200F963D2 /* low_with_low_treatment_insulin_effect.json in Resources */, + E9B3553B293706CB0076AB04 /* noisy_cgm_counteraction_effect.json in Resources */, + E9B3553C293706CB0076AB04 /* realistic_report_counteraction_effect.json in Resources */, E93E865824DB75BE00FF40C8 /* predicted_glucose_very_negative.json in Resources */, E93E86BC24E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json in Resources */, E90909EB24E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json in Resources */, E90909F424E35B4D00F963D2 /* high_and_falling_insulin_effect.json in Resources */, + E9B35539293706CB0076AB04 /* dynamic_autofill_counteraction_effect.json in Resources */, E90909F624E35B7C00F963D2 /* high_and_falling_momentum_effect.json in Resources */, E93E86BA24E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json in Resources */, E90909E724E3530200F963D2 /* low_with_low_treatment_carb_effect.json in Resources */, @@ -4569,7 +4601,7 @@ CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; CURRENT_PROJECT_VERSION = 101; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GENERATE_INFOPLIST_FILE = NO; @@ -4615,7 +4647,7 @@ CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; CURRENT_PROJECT_VERSION = 101; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = StatusWidget/Info.plist; @@ -4870,7 +4902,7 @@ CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4895,7 +4927,7 @@ CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4917,7 +4949,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -4944,7 +4976,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -4971,7 +5003,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -4995,7 +5027,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -5215,7 +5247,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5237,7 +5269,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5299,7 +5331,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; @@ -5322,7 +5354,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = T93DJEUU9W; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; diff --git a/LoopTests/Fixtures/meal_detection/dynamic_autofill_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/dynamic_autofill_counteraction_effect.json new file mode 100644 index 0000000000..6f979e79cc --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/dynamic_autofill_counteraction_effect.json @@ -0,0 +1,1730 @@ +[ + { + "startDate": "2022-10-16T20:30:55", + "endDate": "2022-10-16T20:35:55", + "unit": "mg\/min·dL", + "value": -0.2845291412399927 + }, + { + "startDate": "2022-10-16T20:35:55", + "endDate": "2022-10-16T20:40:55", + "unit": "mg\/min·dL", + "value": -0.720332507317587 + }, + { + "startDate": "2022-10-16T20:40:55", + "endDate": "2022-10-16T20:45:55", + "unit": "mg\/min·dL", + "value": 0.0420715382145751 + }, + { + "startDate": "2022-10-16T20:45:55", + "endDate": "2022-10-16T20:50:55", + "unit": "mg\/min·dL", + "value": 0.0038033636146233146 + }, + { + "startDate": "2022-10-16T20:50:55", + "endDate": "2022-10-16T20:55:55", + "unit": "mg\/min·dL", + "value": 0.36479459077661286 + }, + { + "startDate": "2022-10-16T20:55:55", + "endDate": "2022-10-16T21:00:55", + "unit": "mg\/min·dL", + "value": 0.12560308127768485 + }, + { + "startDate": "2022-10-16T21:00:55", + "endDate": "2022-10-16T21:05:55", + "unit": "mg\/min·dL", + "value": 0.2871269278384104 + }, + { + "startDate": "2022-10-16T21:05:55", + "endDate": "2022-10-16T21:10:55", + "unit": "mg\/min·dL", + "value": 0.24698121433301015 + }, + { + "startDate": "2022-10-16T21:10:55", + "endDate": "2022-10-16T21:15:55", + "unit": "mg\/min·dL", + "value": 0.4090910045267783 + }, + { + "startDate": "2022-10-16T21:15:55", + "endDate": "2022-10-16T21:20:56", + "unit": "mg\/min·dL", + "value": 0.36970428418744017 + }, + { + "startDate": "2022-10-16T21:20:56", + "endDate": "2022-10-16T21:25:55", + "unit": "mg\/min·dL", + "value": 0.9356144093998734 + }, + { + "startDate": "2022-10-16T21:25:55", + "endDate": "2022-10-16T21:30:55", + "unit": "mg\/min·dL", + "value": 0.3045237460699238 + }, + { + "startDate": "2022-10-16T21:30:55", + "endDate": "2022-10-16T21:35:55", + "unit": "mg\/min·dL", + "value": -0.12222077929078642 + }, + { + "startDate": "2022-10-16T21:35:55", + "endDate": "2022-10-16T21:40:55", + "unit": "mg\/min·dL", + "value": 1.8610345585630323 + }, + { + "startDate": "2022-10-16T21:40:55", + "endDate": "2022-10-16T21:45:56", + "unit": "mg\/min·dL", + "value": 1.0401503514922685 + }, + { + "startDate": "2022-10-16T21:45:56", + "endDate": "2022-10-16T21:50:55", + "unit": "mg\/min·dL", + "value": 1.2329955169598097 + }, + { + "startDate": "2022-10-16T21:50:55", + "endDate": "2022-10-16T21:55:55", + "unit": "mg\/min·dL", + "value": -0.372691168979093 + }, + { + "startDate": "2022-10-16T21:55:55", + "endDate": "2022-10-16T22:00:56", + "unit": "mg\/min·dL", + "value": 1.0320426981004522 + }, + { + "startDate": "2022-10-16T22:00:56", + "endDate": "2022-10-16T22:05:55", + "unit": "mg\/min·dL", + "value": 1.2446535840823372 + }, + { + "startDate": "2022-10-16T22:05:55", + "endDate": "2022-10-16T22:10:55", + "unit": "mg\/min·dL", + "value": 1.4513990428468961 + }, + { + "startDate": "2022-10-16T22:10:55", + "endDate": "2022-10-16T22:15:55", + "unit": "mg\/min·dL", + "value": 1.662878682899968 + }, + { + "startDate": "2022-10-16T22:15:55", + "endDate": "2022-10-16T22:20:55", + "unit": "mg\/min·dL", + "value": 1.6776600731029174 + }, + { + "startDate": "2022-10-16T22:20:55", + "endDate": "2022-10-16T22:25:55", + "unit": "mg\/min·dL", + "value": 2.0945857017085485 + }, + { + "startDate": "2022-10-16T22:25:55", + "endDate": "2022-10-16T22:30:56", + "unit": "mg\/min·dL", + "value": 2.3253439979429293 + }, + { + "startDate": "2022-10-16T22:30:56", + "endDate": "2022-10-16T22:35:55", + "unit": "mg\/min·dL", + "value": 2.762596869393967 + }, + { + "startDate": "2022-10-16T22:35:55", + "endDate": "2022-10-16T22:40:55", + "unit": "mg\/min·dL", + "value": 2.8086729434998707 + }, + { + "startDate": "2022-10-16T22:40:55", + "endDate": "2022-10-16T22:45:56", + "unit": "mg\/min·dL", + "value": 2.252477346032686 + }, + { + "startDate": "2022-10-16T22:45:56", + "endDate": "2022-10-16T22:50:55", + "unit": "mg\/min·dL", + "value": 1.5612654702448618 + }, + { + "startDate": "2022-10-16T22:50:55", + "endDate": "2022-10-16T22:55:55", + "unit": "mg\/min·dL", + "value": 1.4645801980357367 + }, + { + "startDate": "2022-10-16T22:55:55", + "endDate": "2022-10-16T23:00:55", + "unit": "mg\/min·dL", + "value": 0.754887677850649 + }, + { + "startDate": "2022-10-16T23:00:55", + "endDate": "2022-10-16T23:05:55", + "unit": "mg\/min·dL", + "value": 0.6300240922287167 + }, + { + "startDate": "2022-10-16T23:05:55", + "endDate": "2022-10-16T23:10:55", + "unit": "mg\/min·dL", + "value": 0.687012158407236 + }, + { + "startDate": "2022-10-16T23:10:55", + "endDate": "2022-10-16T23:15:55", + "unit": "mg\/min·dL", + "value": 0.9327353913928134 + }, + { + "startDate": "2022-10-16T23:15:55", + "endDate": "2022-10-16T23:20:55", + "unit": "mg\/min·dL", + "value": 1.567929156289032 + }, + { + "startDate": "2022-10-16T23:20:55", + "endDate": "2022-10-16T23:25:55", + "unit": "mg\/min·dL", + "value": 0.5902090457631002 + }, + { + "startDate": "2022-10-16T23:25:55", + "endDate": "2022-10-16T23:30:56", + "unit": "mg\/min·dL", + "value": 0.0041194999268927 + }, + { + "startDate": "2022-10-16T23:30:56", + "endDate": "2022-10-16T23:35:55", + "unit": "mg\/min·dL", + "value": -0.5903810118195975 + }, + { + "startDate": "2022-10-16T23:35:55", + "endDate": "2022-10-16T23:40:56", + "unit": "mg\/min·dL", + "value": -0.7888201851294322 + }, + { + "startDate": "2022-10-16T23:40:56", + "endDate": "2022-10-16T23:45:55", + "unit": "mg\/min·dL", + "value": -0.19719045325679185 + }, + { + "startDate": "2022-10-16T23:45:55", + "endDate": "2022-10-16T23:50:55", + "unit": "mg\/min·dL", + "value": 1.391481925699727 + }, + { + "startDate": "2022-10-16T23:50:55", + "endDate": "2022-10-16T23:55:55", + "unit": "mg\/min·dL", + "value": 0.7714860788525394 + }, + { + "startDate": "2022-10-16T23:55:55", + "endDate": "2022-10-17T00:00:55", + "unit": "mg\/min·dL", + "value": 1.148931071940478 + }, + { + "startDate": "2022-10-17T00:00:55", + "endDate": "2022-10-17T00:05:55", + "unit": "mg\/min·dL", + "value": 0.7236798330387322 + }, + { + "startDate": "2022-10-17T00:05:55", + "endDate": "2022-10-17T00:10:55", + "unit": "mg\/min·dL", + "value": 0.8960938670797415 + }, + { + "startDate": "2022-10-17T00:10:55", + "endDate": "2022-10-17T00:15:55", + "unit": "mg\/min·dL", + "value": 0.8719274942897706 + }, + { + "startDate": "2022-10-17T00:15:55", + "endDate": "2022-10-17T00:20:55", + "unit": "mg\/min·dL", + "value": 1.4509978874832035 + }, + { + "startDate": "2022-10-17T00:20:55", + "endDate": "2022-10-17T00:25:55", + "unit": "mg\/min·dL", + "value": 3.633991893395394 + }, + { + "startDate": "2022-10-17T00:25:55", + "endDate": "2022-10-17T00:30:55", + "unit": "mg\/min·dL", + "value": 5.219533814878678 + }, + { + "startDate": "2022-10-17T00:30:55", + "endDate": "2022-10-17T00:35:56", + "unit": "mg\/min·dL", + "value": -0.7755871939106258 + }, + { + "startDate": "2022-10-17T00:35:56", + "endDate": "2022-10-17T00:40:56", + "unit": "mg\/min·dL", + "value": 1.6450492189675925 + }, + { + "startDate": "2022-10-17T00:40:56", + "endDate": "2022-10-17T00:45:55", + "unit": "mg\/min·dL", + "value": 2.2932552815173004 + }, + { + "startDate": "2022-10-17T00:45:55", + "endDate": "2022-10-17T00:50:56", + "unit": "mg\/min·dL", + "value": 1.5190954507889283 + }, + { + "startDate": "2022-10-17T00:50:56", + "endDate": "2022-10-17T00:55:56", + "unit": "mg\/min·dL", + "value": 2.9516354434782834 + }, + { + "startDate": "2022-10-17T00:55:56", + "endDate": "2022-10-17T01:00:56", + "unit": "mg\/min·dL", + "value": 0.5732859864418134 + }, + { + "startDate": "2022-10-17T01:00:56", + "endDate": "2022-10-17T01:05:55", + "unit": "mg\/min·dL", + "value": -1.20756468395372 + }, + { + "startDate": "2022-10-17T01:05:55", + "endDate": "2022-10-17T01:10:55", + "unit": "mg\/min·dL", + "value": -0.7867304872657194 + }, + { + "startDate": "2022-10-17T01:10:55", + "endDate": "2022-10-17T01:15:56", + "unit": "mg\/min·dL", + "value": -1.5694673963202832 + }, + { + "startDate": "2022-10-17T01:15:56", + "endDate": "2022-10-17T01:20:55", + "unit": "mg\/min·dL", + "value": -0.1643639957386131 + }, + { + "startDate": "2022-10-17T01:20:55", + "endDate": "2022-10-17T01:25:56", + "unit": "mg\/min·dL", + "value": -2.3577500064743524 + }, + { + "startDate": "2022-10-17T01:25:56", + "endDate": "2022-10-17T01:30:56", + "unit": "mg\/min·dL", + "value": 0.8323428726747981 + }, + { + "startDate": "2022-10-17T01:30:56", + "endDate": "2022-10-17T01:35:56", + "unit": "mg\/min·dL", + "value": 1.2228081822030008 + }, + { + "startDate": "2022-10-17T01:35:56", + "endDate": "2022-10-17T01:40:55", + "unit": "mg\/min·dL", + "value": 0.8048056766676048 + }, + { + "startDate": "2022-10-17T01:40:55", + "endDate": "2022-10-17T01:45:55", + "unit": "mg\/min·dL", + "value": 0.38224475602652913 + }, + { + "startDate": "2022-10-17T01:45:55", + "endDate": "2022-10-17T01:50:55", + "unit": "mg\/min·dL", + "value": -0.4440735677784057 + }, + { + "startDate": "2022-10-17T01:50:55", + "endDate": "2022-10-17T01:55:56", + "unit": "mg\/min·dL", + "value": 0.5243983931271272 + }, + { + "startDate": "2022-10-17T01:55:56", + "endDate": "2022-10-17T02:00:55", + "unit": "mg\/min·dL", + "value": 0.2931345766919252 + }, + { + "startDate": "2022-10-17T02:00:55", + "endDate": "2022-10-17T02:05:56", + "unit": "mg\/min·dL", + "value": 0.4561831643005171 + }, + { + "startDate": "2022-10-17T02:05:56", + "endDate": "2022-10-17T02:10:55", + "unit": "mg\/min·dL", + "value": 4.632973330858247 + }, + { + "startDate": "2022-10-17T02:10:55", + "endDate": "2022-10-17T02:15:55", + "unit": "mg\/min·dL", + "value": 3.3923410537792185 + }, + { + "startDate": "2022-10-17T02:15:55", + "endDate": "2022-10-17T02:20:55", + "unit": "mg\/min·dL", + "value": 2.7692455985225015 + }, + { + "startDate": "2022-10-17T02:20:55", + "endDate": "2022-10-17T02:25:56", + "unit": "mg\/min·dL", + "value": 0.7675601718208241 + }, + { + "startDate": "2022-10-17T02:25:56", + "endDate": "2022-10-17T02:30:55", + "unit": "mg\/min·dL", + "value": 1.0000691322446786 + }, + { + "startDate": "2022-10-17T02:30:55", + "endDate": "2022-10-17T02:35:55", + "unit": "mg\/min·dL", + "value": 0.6611668014658957 + }, + { + "startDate": "2022-10-17T02:35:55", + "endDate": "2022-10-17T02:40:56", + "unit": "mg\/min·dL", + "value": -0.08596328117482305 + }, + { + "startDate": "2022-10-17T02:40:56", + "endDate": "2022-10-17T02:45:56", + "unit": "mg\/min·dL", + "value": 0.7540608960491663 + }, + { + "startDate": "2022-10-17T02:45:56", + "endDate": "2022-10-17T02:50:55", + "unit": "mg\/min·dL", + "value": -0.818006344776246 + }, + { + "startDate": "2022-10-17T02:50:55", + "endDate": "2022-10-17T02:55:56", + "unit": "mg\/min·dL", + "value": 2.39887981869481 + }, + { + "startDate": "2022-10-17T02:55:56", + "endDate": "2022-10-17T03:00:56", + "unit": "mg\/min·dL", + "value": 0.20930108912543072 + }, + { + "startDate": "2022-10-17T03:00:56", + "endDate": "2022-10-17T03:05:56", + "unit": "mg\/min·dL", + "value": 0.8097746905008025 + }, + { + "startDate": "2022-10-17T03:05:56", + "endDate": "2022-10-17T03:10:56", + "unit": "mg\/min·dL", + "value": 0.6033947886970124 + }, + { + "startDate": "2022-10-17T03:10:56", + "endDate": "2022-10-17T03:15:56", + "unit": "mg\/min·dL", + "value": 0.9898220114011889 + }, + { + "startDate": "2022-10-17T03:15:56", + "endDate": "2022-10-17T03:20:56", + "unit": "mg\/min·dL", + "value": -0.22832265899703647 + }, + { + "startDate": "2022-10-17T03:20:56", + "endDate": "2022-10-17T03:25:56", + "unit": "mg\/min·dL", + "value": -0.4511116066129434 + }, + { + "startDate": "2022-10-17T03:25:56", + "endDate": "2022-10-17T03:30:55", + "unit": "mg\/min·dL", + "value": -2.477086162741612 + }, + { + "startDate": "2022-10-17T03:30:55", + "endDate": "2022-10-17T03:35:56", + "unit": "mg\/min·dL", + "value": -1.3028794753131472 + }, + { + "startDate": "2022-10-17T03:35:56", + "endDate": "2022-10-17T03:40:56", + "unit": "mg\/min·dL", + "value": 0.6617447275509728 + }, + { + "startDate": "2022-10-17T03:40:56", + "endDate": "2022-10-17T03:45:56", + "unit": "mg\/min·dL", + "value": 1.0257874848496134 + }, + { + "startDate": "2022-10-17T03:45:56", + "endDate": "2022-10-17T03:50:56", + "unit": "mg\/min·dL", + "value": 1.5904120793516692 + }, + { + "startDate": "2022-10-17T03:50:56", + "endDate": "2022-10-17T03:55:56", + "unit": "mg\/min·dL", + "value": 1.350173285305319 + }, + { + "startDate": "2022-10-17T03:55:56", + "endDate": "2022-10-17T04:00:56", + "unit": "mg\/min·dL", + "value": 2.313304809362078 + }, + { + "startDate": "2022-10-17T04:00:56", + "endDate": "2022-10-17T04:05:56", + "unit": "mg\/min·dL", + "value": 1.472727094038384 + }, + { + "startDate": "2022-10-17T04:05:56", + "endDate": "2022-10-17T04:10:56", + "unit": "mg\/min·dL", + "value": 1.2459875080276552 + }, + { + "startDate": "2022-10-17T04:10:56", + "endDate": "2022-10-17T04:15:56", + "unit": "mg\/min·dL", + "value": 0.4290145455866161 + }, + { + "startDate": "2022-10-17T04:15:56", + "endDate": "2022-10-17T04:20:56", + "unit": "mg\/min·dL", + "value": 0.6263357321101345 + }, + { + "startDate": "2022-10-17T04:20:56", + "endDate": "2022-10-17T04:25:56", + "unit": "mg\/min·dL", + "value": -0.36841711164706026 + }, + { + "startDate": "2022-10-17T04:25:56", + "endDate": "2022-10-17T04:30:56", + "unit": "mg\/min·dL", + "value": 1.0381399131596578 + }, + { + "startDate": "2022-10-17T04:30:56", + "endDate": "2022-10-17T04:35:56", + "unit": "mg\/min·dL", + "value": 2.449160211321216 + }, + { + "startDate": "2022-10-17T04:35:56", + "endDate": "2022-10-17T04:40:56", + "unit": "mg\/min·dL", + "value": 0.8519932553148932 + }, + { + "startDate": "2022-10-17T04:40:56", + "endDate": "2022-10-17T04:45:56", + "unit": "mg\/min·dL", + "value": 0.25545807211354093 + }, + { + "startDate": "2022-10-17T04:45:56", + "endDate": "2022-10-17T04:50:56", + "unit": "mg\/min·dL", + "value": 0.2672233629275522 + }, + { + "startDate": "2022-10-17T04:50:56", + "endDate": "2022-10-17T04:55:56", + "unit": "mg\/min·dL", + "value": -0.3192664353132631 + }, + { + "startDate": "2022-10-17T04:55:56", + "endDate": "2022-10-17T05:00:56", + "unit": "mg\/min·dL", + "value": -1.513791537638646 + }, + { + "startDate": "2022-10-17T05:00:56", + "endDate": "2022-10-17T05:05:56", + "unit": "mg\/min·dL", + "value": -1.7044380808946273 + }, + { + "startDate": "2022-10-17T05:05:56", + "endDate": "2022-10-17T05:10:56", + "unit": "mg\/min·dL", + "value": -1.712644832803029 + }, + { + "startDate": "2022-10-17T05:10:56", + "endDate": "2022-10-17T05:15:56", + "unit": "mg\/min·dL", + "value": -0.9190525555156 + }, + { + "startDate": "2022-10-17T05:15:56", + "endDate": "2022-10-17T05:20:56", + "unit": "mg\/min·dL", + "value": -0.3311164563982576 + }, + { + "startDate": "2022-10-17T05:20:56", + "endDate": "2022-10-17T05:25:56", + "unit": "mg\/min·dL", + "value": 0.6520001664415103 + }, + { + "startDate": "2022-10-17T05:25:56", + "endDate": "2022-10-17T05:30:56", + "unit": "mg\/min·dL", + "value": 1.233062198641944 + }, + { + "startDate": "2022-10-17T05:30:56", + "endDate": "2022-10-17T05:35:56", + "unit": "mg\/min·dL", + "value": 1.2076048576203615 + }, + { + "startDate": "2022-10-17T05:35:56", + "endDate": "2022-10-17T05:40:56", + "unit": "mg\/min·dL", + "value": 1.18294662356514 + }, + { + "startDate": "2022-10-17T05:40:56", + "endDate": "2022-10-17T05:45:56", + "unit": "mg\/min·dL", + "value": 0.9530329092590353 + }, + { + "startDate": "2022-10-17T05:45:56", + "endDate": "2022-10-17T05:50:56", + "unit": "mg\/min·dL", + "value": 0.32623717759217646 + }, + { + "startDate": "2022-10-17T05:50:56", + "endDate": "2022-10-17T05:55:56", + "unit": "mg\/min·dL", + "value": 0.8983368506804443 + }, + { + "startDate": "2022-10-17T05:55:56", + "endDate": "2022-10-17T06:00:56", + "unit": "mg\/min·dL", + "value": 0.47971971019911824 + }, + { + "startDate": "2022-10-17T06:00:56", + "endDate": "2022-10-17T06:05:56", + "unit": "mg\/min·dL", + "value": 0.26323063620005377 + }, + { + "startDate": "2022-10-17T06:05:56", + "endDate": "2022-10-17T06:10:56", + "unit": "mg\/min·dL", + "value": 0.2527988070184891 + }, + { + "startDate": "2022-10-17T06:10:56", + "endDate": "2022-10-17T06:15:56", + "unit": "mg\/min·dL", + "value": 0.44391960463529634 + }, + { + "startDate": "2022-10-17T06:15:56", + "endDate": "2022-10-17T06:20:56", + "unit": "mg\/min·dL", + "value": 0.0338159426958477 + }, + { + "startDate": "2022-10-17T06:20:56", + "endDate": "2022-10-17T06:25:56", + "unit": "mg\/min·dL", + "value": 0.42080301326849073 + }, + { + "startDate": "2022-10-17T06:25:56", + "endDate": "2022-10-17T06:30:56", + "unit": "mg\/min·dL", + "value": 0.009200834959802624 + }, + { + "startDate": "2022-10-17T06:30:56", + "endDate": "2022-10-17T06:35:56", + "unit": "mg\/min·dL", + "value": -0.0034976034396666947 + }, + { + "startDate": "2022-10-17T06:35:56", + "endDate": "2022-10-17T06:40:56", + "unit": "mg\/min·dL", + "value": -0.01574338703246741 + }, + { + "startDate": "2022-10-17T06:40:56", + "endDate": "2022-10-17T06:45:56", + "unit": "mg\/min·dL", + "value": -0.028328548453654148 + }, + { + "startDate": "2022-10-17T06:45:56", + "endDate": "2022-10-17T06:50:56", + "unit": "mg\/min·dL", + "value": -0.042803942449195886 + }, + { + "startDate": "2022-10-17T06:50:56", + "endDate": "2022-10-17T06:55:56", + "unit": "mg\/min·dL", + "value": -0.05933842985149843 + }, + { + "startDate": "2022-10-17T06:55:56", + "endDate": "2022-10-17T07:00:56", + "unit": "mg\/min·dL", + "value": -0.07734206261505443 + }, + { + "startDate": "2022-10-17T07:00:56", + "endDate": "2022-10-17T07:05:57", + "unit": "mg\/min·dL", + "value": 0.10354020565314719 + }, + { + "startDate": "2022-10-17T07:05:57", + "endDate": "2022-10-17T07:10:56", + "unit": "mg\/min·dL", + "value": 0.2848604801395987 + }, + { + "startDate": "2022-10-17T07:10:56", + "endDate": "2022-10-17T07:15:56", + "unit": "mg\/min·dL", + "value": -0.5351997683038484 + }, + { + "startDate": "2022-10-17T07:15:56", + "endDate": "2022-10-17T07:20:56", + "unit": "mg\/min·dL", + "value": -0.5561162558548638 + }, + { + "startDate": "2022-10-17T07:20:56", + "endDate": "2022-10-17T07:25:56", + "unit": "mg\/min·dL", + "value": 1.026446015181327 + }, + { + "startDate": "2022-10-17T07:25:56", + "endDate": "2022-10-17T07:30:56", + "unit": "mg\/min·dL", + "value": -1.3889750203453213 + }, + { + "startDate": "2022-10-17T07:30:56", + "endDate": "2022-10-17T07:35:56", + "unit": "mg\/min·dL", + "value": 3.5926136152071413 + }, + { + "startDate": "2022-10-17T07:35:56", + "endDate": "2022-10-17T07:40:56", + "unit": "mg\/min·dL", + "value": 2.7749093320580926 + }, + { + "startDate": "2022-10-17T07:40:56", + "endDate": "2022-10-17T07:45:57", + "unit": "mg\/min·dL", + "value": 3.746086706283121 + }, + { + "startDate": "2022-10-17T07:45:57", + "endDate": "2022-10-17T07:50:57", + "unit": "mg\/min·dL", + "value": 4.955723649353499 + }, + { + "startDate": "2022-10-17T07:50:57", + "endDate": "2022-10-17T07:55:56", + "unit": "mg\/min·dL", + "value": 4.996640987612763 + }, + { + "startDate": "2022-10-17T07:55:56", + "endDate": "2022-10-17T08:00:57", + "unit": "mg\/min·dL", + "value": 3.0304810395539663 + }, + { + "startDate": "2022-10-17T08:00:57", + "endDate": "2022-10-17T08:05:56", + "unit": "mg\/min·dL", + "value": 4.332986252787241 + }, + { + "startDate": "2022-10-17T08:05:56", + "endDate": "2022-10-17T08:10:57", + "unit": "mg\/min·dL", + "value": 3.7965717213147436 + }, + { + "startDate": "2022-10-17T08:10:57", + "endDate": "2022-10-17T08:15:56", + "unit": "mg\/min·dL", + "value": 0.6920745985592388 + }, + { + "startDate": "2022-10-17T08:15:56", + "endDate": "2022-10-17T08:20:57", + "unit": "mg\/min·dL", + "value": 1.380230886893775 + }, + { + "startDate": "2022-10-17T08:20:57", + "endDate": "2022-10-17T08:25:57", + "unit": "mg\/min·dL", + "value": -0.31528831809019864 + }, + { + "startDate": "2022-10-17T08:25:57", + "endDate": "2022-10-17T08:30:56", + "unit": "mg\/min·dL", + "value": 0.18164411041348966 + }, + { + "startDate": "2022-10-17T08:30:56", + "endDate": "2022-10-17T08:35:56", + "unit": "mg\/min·dL", + "value": 0.06938282997424218 + }, + { + "startDate": "2022-10-17T08:35:56", + "endDate": "2022-10-17T08:40:57", + "unit": "mg\/min·dL", + "value": -0.6569780599920586 + }, + { + "startDate": "2022-10-17T08:40:57", + "endDate": "2022-10-17T08:45:56", + "unit": "mg\/min·dL", + "value": -2.002245300154777 + }, + { + "startDate": "2022-10-17T08:45:56", + "endDate": "2022-10-17T08:50:57", + "unit": "mg\/min·dL", + "value": -1.549451452214076 + }, + { + "startDate": "2022-10-17T08:50:57", + "endDate": "2022-10-17T08:55:56", + "unit": "mg\/min·dL", + "value": -1.322155355994713 + }, + { + "startDate": "2022-10-17T08:55:56", + "endDate": "2022-10-17T09:00:56", + "unit": "mg\/min·dL", + "value": -0.6943400893385767 + }, + { + "startDate": "2022-10-17T09:00:56", + "endDate": "2022-10-17T09:05:56", + "unit": "mg\/min·dL", + "value": -0.48121967301796426 + }, + { + "startDate": "2022-10-17T09:05:56", + "endDate": "2022-10-17T09:10:56", + "unit": "mg\/min·dL", + "value": -0.27611861679495037 + }, + { + "startDate": "2022-10-17T09:10:56", + "endDate": "2022-10-17T09:15:57", + "unit": "mg\/min·dL", + "value": 0.12006679946502295 + }, + { + "startDate": "2022-10-17T09:15:57", + "endDate": "2022-10-17T09:20:57", + "unit": "mg\/min·dL", + "value": 0.9113005453156893 + }, + { + "startDate": "2022-10-17T09:20:57", + "endDate": "2022-10-17T09:25:56", + "unit": "mg\/min·dL", + "value": 0.09388318724059296 + }, + { + "startDate": "2022-10-17T09:25:56", + "endDate": "2022-10-17T09:30:56", + "unit": "mg\/min·dL", + "value": 0.871487757074061 + }, + { + "startDate": "2022-10-17T09:30:56", + "endDate": "2022-10-17T09:35:57", + "unit": "mg\/min·dL", + "value": 1.2446560973111296 + }, + { + "startDate": "2022-10-17T09:35:57", + "endDate": "2022-10-17T09:40:56", + "unit": "mg\/min·dL", + "value": 1.0166924505981447 + }, + { + "startDate": "2022-10-17T09:40:56", + "endDate": "2022-10-17T09:45:57", + "unit": "mg\/min·dL", + "value": 0.5813199722905042 + }, + { + "startDate": "2022-10-17T09:45:57", + "endDate": "2022-10-17T09:50:56", + "unit": "mg\/min·dL", + "value": 0.9470217340786014 + }, + { + "startDate": "2022-10-17T09:50:56", + "endDate": "2022-10-17T09:55:57", + "unit": "mg\/min·dL", + "value": 1.1063805177477521 + }, + { + "startDate": "2022-10-17T09:55:57", + "endDate": "2022-10-17T10:00:57", + "unit": "mg\/min·dL", + "value": 1.0678400294991015 + }, + { + "startDate": "2022-10-17T10:00:57", + "endDate": "2022-10-17T10:05:56", + "unit": "mg\/min·dL", + "value": 0.6343386087427577 + }, + { + "startDate": "2022-10-17T10:05:56", + "endDate": "2022-10-17T10:10:56", + "unit": "mg\/min·dL", + "value": 0.8006679951794808 + }, + { + "startDate": "2022-10-17T10:10:56", + "endDate": "2022-10-17T10:15:57", + "unit": "mg\/min·dL", + "value": 1.1757271031846188 + }, + { + "startDate": "2022-10-17T10:15:57", + "endDate": "2022-10-17T10:20:57", + "unit": "mg\/min·dL", + "value": 0.9549561105429651 + }, + { + "startDate": "2022-10-17T10:20:57", + "endDate": "2022-10-17T10:25:56", + "unit": "mg\/min·dL", + "value": 0.9395422101128353 + }, + { + "startDate": "2022-10-17T10:25:56", + "endDate": "2022-10-17T10:30:57", + "unit": "mg\/min·dL", + "value": 0.7213247424132531 + }, + { + "startDate": "2022-10-17T10:30:57", + "endDate": "2022-10-17T10:35:57", + "unit": "mg\/min·dL", + "value": 0.7122028044445942 + }, + { + "startDate": "2022-10-17T10:35:57", + "endDate": "2022-10-17T10:40:57", + "unit": "mg\/min·dL", + "value": 0.9052517663340723 + }, + { + "startDate": "2022-10-17T10:40:57", + "endDate": "2022-10-17T10:45:56", + "unit": "mg\/min·dL", + "value": 0.699122847987222 + }, + { + "startDate": "2022-10-17T10:45:56", + "endDate": "2022-10-17T10:50:56", + "unit": "mg\/min·dL", + "value": 0.6913335747487636 + }, + { + "startDate": "2022-10-17T10:50:56", + "endDate": "2022-10-17T10:55:56", + "unit": "mg\/min·dL", + "value": 0.8872653413930243 + }, + { + "startDate": "2022-10-17T10:55:56", + "endDate": "2022-10-17T11:00:56", + "unit": "mg\/min·dL", + "value": 0.8821124122832216 + }, + { + "startDate": "2022-10-17T11:00:56", + "endDate": "2022-10-17T11:05:57", + "unit": "mg\/min·dL", + "value": 1.0762823125902266 + }, + { + "startDate": "2022-10-17T11:05:57", + "endDate": "2022-10-17T11:10:57", + "unit": "mg\/min·dL", + "value": 0.8705356061523262 + }, + { + "startDate": "2022-10-17T11:10:57", + "endDate": "2022-10-17T11:15:57", + "unit": "mg\/min·dL", + "value": 2.270378271296252 + }, + { + "startDate": "2022-10-17T11:15:57", + "endDate": "2022-10-17T11:20:57", + "unit": "mg\/min·dL", + "value": 2.0675398966404512 + }, + { + "startDate": "2022-10-17T11:20:57", + "endDate": "2022-10-17T11:25:57", + "unit": "mg\/min·dL", + "value": 1.672835991153337 + }, + { + "startDate": "2022-10-17T11:25:57", + "endDate": "2022-10-17T11:30:57", + "unit": "mg\/min·dL", + "value": 1.282134118615466 + }, + { + "startDate": "2022-10-17T11:30:57", + "endDate": "2022-10-17T11:35:57", + "unit": "mg\/min·dL", + "value": 1.1042760922545358 + }, + { + "startDate": "2022-10-17T11:35:57", + "endDate": "2022-10-17T11:40:57", + "unit": "mg\/min·dL", + "value": -0.0715803708831969 + }, + { + "startDate": "2022-10-17T11:40:57", + "endDate": "2022-10-17T11:45:57", + "unit": "mg\/min·dL", + "value": 0.5562508834174531 + }, + { + "startDate": "2022-10-17T11:45:57", + "endDate": "2022-10-17T11:50:57", + "unit": "mg\/min·dL", + "value": 1.3791757347276055 + }, + { + "startDate": "2022-10-17T11:50:57", + "endDate": "2022-10-17T11:55:57", + "unit": "mg\/min·dL", + "value": 0.7975595824531685 + }, + { + "startDate": "2022-10-17T11:55:57", + "endDate": "2022-10-17T12:00:56", + "unit": "mg\/min·dL", + "value": 1.0107065963916195 + }, + { + "startDate": "2022-10-17T12:00:56", + "endDate": "2022-10-17T12:05:57", + "unit": "mg\/min·dL", + "value": 1.619041638687975 + }, + { + "startDate": "2022-10-17T12:05:57", + "endDate": "2022-10-17T12:10:56", + "unit": "mg\/min·dL", + "value": 0.2289011705408734 + }, + { + "startDate": "2022-10-17T12:10:56", + "endDate": "2022-10-17T12:15:57", + "unit": "mg\/min·dL", + "value": 0.8341745845319312 + }, + { + "startDate": "2022-10-17T12:15:57", + "endDate": "2022-10-17T12:20:57", + "unit": "mg\/min·dL", + "value": 1.043486724815214 + }, + { + "startDate": "2022-10-17T12:20:57", + "endDate": "2022-10-17T12:25:57", + "unit": "mg\/min·dL", + "value": 0.6465113919254944 + }, + { + "startDate": "2022-10-17T12:25:57", + "endDate": "2022-10-17T12:30:57", + "unit": "mg\/min·dL", + "value": 0.8497079852663679 + }, + { + "startDate": "2022-10-17T12:30:57", + "endDate": "2022-10-17T12:35:57", + "unit": "mg\/min·dL", + "value": 0.8516497402932928 + }, + { + "startDate": "2022-10-17T12:35:57", + "endDate": "2022-10-17T12:40:57", + "unit": "mg\/min·dL", + "value": 0.4544982669966845 + }, + { + "startDate": "2022-10-17T12:40:57", + "endDate": "2022-10-17T12:45:57", + "unit": "mg\/min·dL", + "value": 0.45502813749310494 + }, + { + "startDate": "2022-10-17T12:45:57", + "endDate": "2022-10-17T12:50:57", + "unit": "mg\/min·dL", + "value": 0.053607228109501304 + }, + { + "startDate": "2022-10-17T12:50:57", + "endDate": "2022-10-17T12:55:57", + "unit": "mg\/min·dL", + "value": 3.850030747446343 + }, + { + "startDate": "2022-10-17T12:55:57", + "endDate": "2022-10-17T13:00:57", + "unit": "mg\/min·dL", + "value": -2.5605776759549843 + }, + { + "startDate": "2022-10-17T13:00:57", + "endDate": "2022-10-17T13:05:58", + "unit": "mg\/min·dL", + "value": -0.16929980570474054 + }, + { + "startDate": "2022-10-17T13:05:58", + "endDate": "2022-10-17T13:10:57", + "unit": "mg\/min·dL", + "value": -0.17024253181035856 + }, + { + "startDate": "2022-10-17T13:10:57", + "endDate": "2022-10-17T13:15:57", + "unit": "mg\/min·dL", + "value": 0.22396575670759422 + }, + { + "startDate": "2022-10-17T13:15:57", + "endDate": "2022-10-17T13:20:57", + "unit": "mg\/min·dL", + "value": 0.2131383127213337 + }, + { + "startDate": "2022-10-17T13:20:57", + "endDate": "2022-10-17T13:25:57", + "unit": "mg\/min·dL", + "value": -0.0036422184008190975 + }, + { + "startDate": "2022-10-17T13:25:57", + "endDate": "2022-10-17T13:30:57", + "unit": "mg\/min·dL", + "value": -0.024307494393551208 + }, + { + "startDate": "2022-10-17T13:30:57", + "endDate": "2022-10-17T13:35:57", + "unit": "mg\/min·dL", + "value": 0.7504265623640302 + }, + { + "startDate": "2022-10-17T13:35:57", + "endDate": "2022-10-17T13:40:57", + "unit": "mg\/min·dL", + "value": 0.12395887249486975 + }, + { + "startDate": "2022-10-17T13:40:57", + "endDate": "2022-10-17T13:45:57", + "unit": "mg\/min·dL", + "value": -0.5054765171613136 + }, + { + "startDate": "2022-10-17T13:45:57", + "endDate": "2022-10-17T13:50:57", + "unit": "mg\/min·dL", + "value": 0.2650315439906281 + }, + { + "startDate": "2022-10-17T13:50:57", + "endDate": "2022-10-17T13:55:57", + "unit": "mg\/min·dL", + "value": 0.235027270256946 + }, + { + "startDate": "2022-10-17T13:55:57", + "endDate": "2022-10-17T14:00:57", + "unit": "mg\/min·dL", + "value": 0.004825228772659867 + }, + { + "startDate": "2022-10-17T14:00:57", + "endDate": "2022-10-17T14:05:57", + "unit": "mg\/min·dL", + "value": 0.17631247206302156 + }, + { + "startDate": "2022-10-17T14:05:57", + "endDate": "2022-10-17T14:10:57", + "unit": "mg\/min·dL", + "value": 0.3494460554759167 + }, + { + "startDate": "2022-10-17T14:10:57", + "endDate": "2022-10-17T14:15:57", + "unit": "mg\/min·dL", + "value": -0.07881975737358604 + }, + { + "startDate": "2022-10-17T14:15:57", + "endDate": "2022-10-17T14:20:57", + "unit": "mg\/min·dL", + "value": 0.09501914368510557 + }, + { + "startDate": "2022-10-17T14:20:57", + "endDate": "2022-10-17T14:25:57", + "unit": "mg\/min·dL", + "value": 0.26962224455049527 + }, + { + "startDate": "2022-10-17T14:25:57", + "endDate": "2022-10-17T14:30:58", + "unit": "mg\/min·dL", + "value": 1.0461918213992094 + }, + { + "startDate": "2022-10-17T14:30:58", + "endDate": "2022-10-17T14:35:58", + "unit": "mg\/min·dL", + "value": 0.2233686770059514 + }, + { + "startDate": "2022-10-17T14:35:58", + "endDate": "2022-10-17T14:40:57", + "unit": "mg\/min·dL", + "value": -0.7968131225503261 + }, + { + "startDate": "2022-10-17T14:40:57", + "endDate": "2022-10-17T14:45:57", + "unit": "mg\/min·dL", + "value": -0.00755583436261384 + }, + { + "startDate": "2022-10-17T14:45:57", + "endDate": "2022-10-17T14:50:58", + "unit": "mg\/min·dL", + "value": -0.017988670741366602 + }, + { + "startDate": "2022-10-17T14:50:58", + "endDate": "2022-10-17T14:55:57", + "unit": "mg\/min·dL", + "value": 0.16860756482113998 + }, + { + "startDate": "2022-10-17T14:55:57", + "endDate": "2022-10-17T15:00:57", + "unit": "mg\/min·dL", + "value": 0.15258695408591513 + }, + { + "startDate": "2022-10-17T15:00:57", + "endDate": "2022-10-17T15:05:57", + "unit": "mg\/min·dL", + "value": 0.3366705688171287 + }, + { + "startDate": "2022-10-17T15:05:57", + "endDate": "2022-10-17T15:10:57", + "unit": "mg\/min·dL", + "value": 0.12225745161360409 + }, + { + "startDate": "2022-10-17T15:10:57", + "endDate": "2022-10-17T15:15:57", + "unit": "mg\/min·dL", + "value": 0.3057494415391212 + }, + { + "startDate": "2022-10-17T15:15:57", + "endDate": "2022-10-17T15:20:57", + "unit": "mg\/min·dL", + "value": 1.6878262062492664 + }, + { + "startDate": "2022-10-17T15:20:57", + "endDate": "2022-10-17T15:25:57", + "unit": "mg\/min·dL", + "value": -0.12540003293832777 + }, + { + "startDate": "2022-10-17T15:25:57", + "endDate": "2022-10-17T15:30:58", + "unit": "mg\/min·dL", + "value": 3.2579639609054944 + }, + { + "startDate": "2022-10-17T15:30:58", + "endDate": "2022-10-17T15:35:57", + "unit": "mg\/min·dL", + "value": -2.33882331116317 + }, + { + "startDate": "2022-10-17T15:35:57", + "endDate": "2022-10-17T15:40:57", + "unit": "mg\/min·dL", + "value": -1.3337321869891747 + }, + { + "startDate": "2022-10-17T15:40:57", + "endDate": "2022-10-17T15:45:57", + "unit": "mg\/min·dL", + "value": -0.717298126861785 + }, + { + "startDate": "2022-10-17T15:45:57", + "endDate": "2022-10-17T15:50:57", + "unit": "mg\/min·dL", + "value": -1.3071661542366684 + }, + { + "startDate": "2022-10-17T15:50:57", + "endDate": "2022-10-17T15:55:57", + "unit": "mg\/min·dL", + "value": -1.4985287049008267 + }, + { + "startDate": "2022-10-17T15:55:57", + "endDate": "2022-10-17T16:00:57", + "unit": "mg\/min·dL", + "value": -0.29630881245414115 + }, + { + "startDate": "2022-10-17T16:00:57", + "endDate": "2022-10-17T16:05:57", + "unit": "mg\/min·dL", + "value": -0.2973617987728701 + }, + { + "startDate": "2022-10-17T16:05:57", + "endDate": "2022-10-17T16:10:58", + "unit": "mg\/min·dL", + "value": -0.10233458926668869 + }, + { + "startDate": "2022-10-17T16:10:58", + "endDate": "2022-10-17T16:15:58", + "unit": "mg\/min·dL", + "value": 0.08971198167566777 + }, + { + "startDate": "2022-10-17T16:15:58", + "endDate": "2022-10-17T16:20:57", + "unit": "mg\/min·dL", + "value": -0.32128769588079303 + }, + { + "startDate": "2022-10-17T16:20:57", + "endDate": "2022-10-17T16:25:58", + "unit": "mg\/min·dL", + "value": -0.1333543684979505 + }, + { + "startDate": "2022-10-17T16:25:58", + "endDate": "2022-10-17T16:30:57", + "unit": "mg\/min·dL", + "value": 0.05179539206684855 + }, + { + "startDate": "2022-10-17T16:30:57", + "endDate": "2022-10-17T16:35:57", + "unit": "mg\/min·dL", + "value": 0.03525249389131893 + }, + { + "startDate": "2022-10-17T16:35:57", + "endDate": "2022-10-17T16:40:57", + "unit": "mg\/min·dL", + "value": 0.21730726337856762 + }, + { + "startDate": "2022-10-17T16:40:57", + "endDate": "2022-10-17T16:45:58", + "unit": "mg\/min·dL", + "value": 1.3965937622299813 + }, + { + "startDate": "2022-10-17T16:45:58", + "endDate": "2022-10-17T16:50:57", + "unit": "mg\/min·dL", + "value": 1.580064623999398 + }, + { + "startDate": "2022-10-17T16:50:57", + "endDate": "2022-10-17T16:55:58", + "unit": "mg\/min·dL", + "value": 0.7568514241319295 + }, + { + "startDate": "2022-10-17T16:55:58", + "endDate": "2022-10-17T17:00:58", + "unit": "mg\/min·dL", + "value": 1.1430376620243732 + }, + { + "startDate": "2022-10-17T17:00:58", + "endDate": "2022-10-17T17:05:57", + "unit": "mg\/min·dL", + "value": 0.7341003445506363 + }, + { + "startDate": "2022-10-17T17:05:57", + "endDate": "2022-10-17T17:10:58", + "unit": "mg\/min·dL", + "value": 1.1268541295706538 + }, + { + "startDate": "2022-10-17T17:10:58", + "endDate": "2022-10-17T17:15:57", + "unit": "mg\/min·dL", + "value": 0.9347439999943791 + }, + { + "startDate": "2022-10-17T17:15:57", + "endDate": "2022-10-17T17:20:57", + "unit": "mg\/min·dL", + "value": -0.05849178159961313 + }, + { + "startDate": "2022-10-17T17:20:57", + "endDate": "2022-10-17T17:25:58", + "unit": "mg\/min·dL", + "value": -0.043980150922760856 + }, + { + "startDate": "2022-10-17T17:25:58", + "endDate": "2022-10-17T17:30:58", + "unit": "mg\/min·dL", + "value": 1.172267021770458 + }, + { + "startDate": "2022-10-17T17:30:58", + "endDate": "2022-10-17T17:35:58", + "unit": "mg\/min·dL", + "value": 0.9867370663288156 + }, + { + "startDate": "2022-10-17T17:35:58", + "endDate": "2022-10-17T17:40:58", + "unit": "mg\/min·dL", + "value": 0.5971550847739795 + }, + { + "startDate": "2022-10-17T17:40:58", + "endDate": "2022-10-17T17:45:57", + "unit": "mg\/min·dL", + "value": 0.007334399026725797 + }, + { + "startDate": "2022-10-17T17:45:57", + "endDate": "2022-10-17T17:50:57", + "unit": "mg\/min·dL", + "value": 0.4211816970126617 + }, + { + "startDate": "2022-10-17T17:50:57", + "endDate": "2022-10-17T17:55:58", + "unit": "mg\/min·dL", + "value": 1.0317341149283863 + }, + { + "startDate": "2022-10-17T17:55:58", + "endDate": "2022-10-17T18:00:58", + "unit": "mg\/min·dL", + "value": 1.4451839309800605 + }, + { + "startDate": "2022-10-17T18:00:58", + "endDate": "2022-10-17T18:05:58", + "unit": "mg\/min·dL", + "value": 1.6521747464530774 + }, + { + "startDate": "2022-10-17T18:05:58", + "endDate": "2022-10-17T18:10:57", + "unit": "mg\/min·dL", + "value": 1.6670375773385095 + }, + { + "startDate": "2022-10-17T18:10:57", + "endDate": "2022-10-17T18:15:58", + "unit": "mg\/min·dL", + "value": 1.6815570963307955 + }, + { + "startDate": "2022-10-17T18:15:58", + "endDate": "2022-10-17T18:20:58", + "unit": "mg\/min·dL", + "value": 1.9055447248731132 + }, + { + "startDate": "2022-10-17T18:20:58", + "endDate": "2022-10-17T18:25:57", + "unit": "mg\/min·dL", + "value": 1.7396687349251063 + }, + { + "startDate": "2022-10-17T18:25:57", + "endDate": "2022-10-17T18:30:57", + "unit": "mg\/min·dL", + "value": 1.176224616460157 + }, + { + "startDate": "2022-10-17T18:30:57", + "endDate": "2022-10-17T18:35:58", + "unit": "mg\/min·dL", + "value": 0.8161964326183754 + }, + { + "startDate": "2022-10-17T18:35:58", + "endDate": "2022-10-17T18:40:57", + "unit": "mg\/min·dL", + "value": 1.069405878974623 + }, + { + "startDate": "2022-10-17T18:40:57", + "endDate": "2022-10-17T18:45:57", + "unit": "mg\/min·dL", + "value": 0.7149637327769314 + }, + { + "startDate": "2022-10-17T18:45:57", + "endDate": "2022-10-17T18:50:58", + "unit": "mg\/min·dL", + "value": 0.7580055677633227 + }, + { + "startDate": "2022-10-17T18:50:58", + "endDate": "2022-10-17T18:55:57", + "unit": "mg\/min·dL", + "value": 0.39954773081158734 + }, + { + "startDate": "2022-10-17T18:55:57", + "endDate": "2022-10-17T19:00:57", + "unit": "mg\/min·dL", + "value": 0.6322656375672243 + }, + { + "startDate": "2022-10-17T19:00:57", + "endDate": "2022-10-17T19:05:58", + "unit": "mg\/min·dL", + "value": 0.6601567890512666 + }, + { + "startDate": "2022-10-17T19:05:58", + "endDate": "2022-10-17T19:10:58", + "unit": "mg\/min·dL", + "value": 0.28254788027999844 + }, + { + "startDate": "2022-10-17T19:10:58", + "endDate": "2022-10-17T19:15:57", + "unit": "mg\/min·dL", + "value": 0.29810083959368033 + }, + { + "startDate": "2022-10-17T19:15:57", + "endDate": "2022-10-17T19:20:58", + "unit": "mg\/min·dL", + "value": 0.50675343533354 + }, + { + "startDate": "2022-10-17T19:20:58", + "endDate": "2022-10-17T19:25:58", + "unit": "mg\/min·dL", + "value": 0.5137649628645453 + }, + { + "startDate": "2022-10-17T19:25:58", + "endDate": "2022-10-17T19:30:58", + "unit": "mg\/min·dL", + "value": 0.5169966515232866 + }, + { + "startDate": "2022-10-17T19:30:58", + "endDate": "2022-10-17T19:35:58", + "unit": "mg\/min·dL", + "value": 0.7146717742568007 + }, + { + "startDate": "2022-10-17T19:35:58", + "endDate": "2022-10-17T19:40:57", + "unit": "mg\/min·dL", + "value": 0.9088903732995701 + }, + { + "startDate": "2022-10-17T19:40:57", + "endDate": "2022-10-17T19:45:58", + "unit": "mg\/min·dL", + "value": 1.100742556000779 + }, + { + "startDate": "2022-10-17T19:45:58", + "endDate": "2022-10-17T19:50:58", + "unit": "mg\/min·dL", + "value": 1.292926565538361 + }, + { + "startDate": "2022-10-17T19:50:58", + "endDate": "2022-10-17T19:55:58", + "unit": "mg\/min·dL", + "value": 1.0930916176029168 + }, + { + "startDate": "2022-10-17T19:55:58", + "endDate": "2022-10-17T20:00:58", + "unit": "mg\/min·dL", + "value": 1.0936289317791132 + }, + { + "startDate": "2022-10-17T20:00:58", + "endDate": "2022-10-17T20:05:57", + "unit": "mg\/min·dL", + "value": 0.8987065716926492 + }, + { + "startDate": "2022-10-17T20:05:57", + "endDate": "2022-10-17T20:10:58", + "unit": "mg\/min·dL", + "value": 0.7075247469377013 + }, + { + "startDate": "2022-10-17T20:10:58", + "endDate": "2022-10-17T20:15:58", + "unit": "mg\/min·dL", + "value": 0.5191499097513893 + }, + { + "startDate": "2022-10-17T20:15:58", + "endDate": "2022-10-17T20:20:58", + "unit": "mg\/min·dL", + "value": 0.3324377735152427 + }, + { + "startDate": "2022-10-17T20:20:58", + "endDate": "2022-10-17T20:25:58", + "unit": "mg\/min·dL", + "value": 0.3452106927143431 + }, + { + "startDate": "2022-10-17T20:25:58", + "endDate": "2022-10-17T20:30:58", + "unit": "mg\/min·dL", + "value": 0.5526530110663183 + } +] diff --git a/LoopTests/Fixtures/meal_detection/long_interval_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/long_interval_counteraction_effect.json new file mode 100644 index 0000000000..fd52dc5698 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/long_interval_counteraction_effect.json @@ -0,0 +1,14 @@ +[ + { + "startDate": "2022-10-17T01:06:47", + "endDate": "2022-10-17T02:49:16", + "unit": "mg\/min·dL", + "value": 1.0556978820387204 + }, + { + "startDate": "2022-10-17T02:49:16", + "endDate": "2022-10-17T02:57:50", + "unit": "mg\/min·dL", + "value": 2.0566560442527893 + } +] diff --git a/LoopTests/Fixtures/meal_detection/needs_clamping_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/needs_clamping_counteraction_effect.json new file mode 100644 index 0000000000..44ab9719b6 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/needs_clamping_counteraction_effect.json @@ -0,0 +1,14 @@ +[ + { + "startDate": "2022-10-16T01:06:47", + "endDate": "2022-10-17T02:49:16", + "unit": "mg\/min·dL", + "value": 0.5556978820387204 + }, + { + "startDate": "2022-10-17T02:49:16", + "endDate": "2022-10-20T02:57:50", + "unit": "mg\/min·dL", + "value": 0.0566560442527893 + } +] diff --git a/LoopTests/Fixtures/meal_detection/noisy_cgm_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/noisy_cgm_counteraction_effect.json new file mode 100644 index 0000000000..63cb6285f0 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/noisy_cgm_counteraction_effect.json @@ -0,0 +1,386 @@ +[ + { + "startDate": "2022-10-18T20:43:08", + "endDate": "2022-10-18T20:50:42", + "unit": "mg\/min·dL", + "value": 2.1413880378038854 + }, + { + "startDate": "2022-10-18T20:50:42", + "endDate": "2022-10-18T20:58:27", + "unit": "mg\/min·dL", + "value": 2.4011098101277946 + }, + { + "startDate": "2022-10-18T20:58:27", + "endDate": "2022-10-18T21:14:25", + "unit": "mg\/min·dL", + "value": 2.057214771814263 + }, + { + "startDate": "2022-10-18T21:14:25", + "endDate": "2022-10-18T21:22:05", + "unit": "mg\/min·dL", + "value": 1.991425926715544 + }, + { + "startDate": "2022-10-18T21:22:05", + "endDate": "2022-10-18T21:42:38", + "unit": "mg\/min·dL", + "value": 2.0051308533580032 + }, + { + "startDate": "2022-10-18T21:42:38", + "endDate": "2022-10-18T22:43:11", + "unit": "mg\/min·dL", + "value": 2.180566858455272 + }, + { + "startDate": "2022-10-18T22:43:11", + "endDate": "2022-10-18T23:13:29", + "unit": "mg\/min·dL", + "value": 1.9197677207835058 + }, + { + "startDate": "2022-10-18T23:13:29", + "endDate": "2022-10-18T23:23:44", + "unit": "mg\/min·dL", + "value": 1.6582944320917057 + }, + { + "startDate": "2022-10-18T23:23:44", + "endDate": "2022-10-18T23:31:29", + "unit": "mg\/min·dL", + "value": 2.9251517652457917 + }, + { + "startDate": "2022-10-18T23:31:29", + "endDate": "2022-10-18T23:39:02", + "unit": "mg\/min·dL", + "value": 0.16546279064227673 + }, + { + "startDate": "2022-10-18T23:39:02", + "endDate": "2022-10-18T23:46:51", + "unit": "mg\/min·dL", + "value": 3.5246556156903477 + }, + { + "startDate": "2022-10-18T23:46:51", + "endDate": "2022-10-19T00:01:55", + "unit": "mg\/min·dL", + "value": 1.6837264254514583 + }, + { + "startDate": "2022-10-19T00:01:55", + "endDate": "2022-10-19T00:09:24", + "unit": "mg\/min·dL", + "value": 0.6255424707941111 + }, + { + "startDate": "2022-10-19T00:09:24", + "endDate": "2022-10-19T00:17:11", + "unit": "mg\/min·dL", + "value": 3.5624118067540476 + }, + { + "startDate": "2022-10-19T00:17:11", + "endDate": "2022-10-19T00:26:34", + "unit": "mg\/min·dL", + "value": 3.4181695387179754 + }, + { + "startDate": "2022-10-19T00:26:34", + "endDate": "2022-10-19T00:34:20", + "unit": "mg\/min·dL", + "value": -0.3706991177681455 + }, + { + "startDate": "2022-10-19T00:34:20", + "endDate": "2022-10-19T00:42:23", + "unit": "mg\/min·dL", + "value": 2.408865149934943 + }, + { + "startDate": "2022-10-19T00:42:23", + "endDate": "2022-10-19T00:50:58", + "unit": "mg\/min·dL", + "value": 1.5845764542225527 + }, + { + "startDate": "2022-10-19T00:50:58", + "endDate": "2022-10-19T01:07:52", + "unit": "mg\/min·dL", + "value": 2.0292268456298372 + }, + { + "startDate": "2022-10-19T01:07:52", + "endDate": "2022-10-19T01:15:47", + "unit": "mg\/min·dL", + "value": 2.402478504248885 + }, + { + "startDate": "2022-10-19T01:15:47", + "endDate": "2022-10-19T01:23:21", + "unit": "mg\/min·dL", + "value": 1.9234931834453135 + }, + { + "startDate": "2022-10-19T01:23:21", + "endDate": "2022-10-19T01:31:11", + "unit": "mg\/min·dL", + "value": 2.557351249850377 + }, + { + "startDate": "2022-10-19T01:31:11", + "endDate": "2022-10-19T01:38:53", + "unit": "mg\/min·dL", + "value": 1.8865201976400663 + }, + { + "startDate": "2022-10-19T01:38:53", + "endDate": "2022-10-19T01:46:38", + "unit": "mg\/min·dL", + "value": 2.188515989682273 + }, + { + "startDate": "2022-10-19T01:46:38", + "endDate": "2022-10-19T01:55:13", + "unit": "mg\/min·dL", + "value": 2.446288202801156 + }, + { + "startDate": "2022-10-19T01:55:13", + "endDate": "2022-10-19T02:02:55", + "unit": "mg\/min·dL", + "value": 1.8874025096295566 + }, + { + "startDate": "2022-10-19T02:02:55", + "endDate": "2022-10-19T03:03:30", + "unit": "mg\/min·dL", + "value": 1.9901048084934858 + }, + { + "startDate": "2022-10-19T03:03:30", + "endDate": "2022-10-19T03:44:04", + "unit": "mg\/min·dL", + "value": 1.7104947217909385 + }, + { + "startDate": "2022-10-19T03:44:04", + "endDate": "2022-10-19T04:24:37", + "unit": "mg\/min·dL", + "value": 2.0313009513772373 + }, + { + "startDate": "2022-10-19T04:24:37", + "endDate": "2022-10-19T14:58:20", + "unit": "mg\/min·dL", + "value": 0.6779229200420821 + }, + { + "startDate": "2022-10-19T17:47:43", + "endDate": "2022-10-19T17:52:43", + "unit": "mg\/min·dL", + "value": -1.764725420239631 + }, + { + "startDate": "2022-10-19T17:52:43", + "endDate": "2022-10-19T18:02:43", + "unit": "mg\/min·dL", + "value": -0.2854672410290561 + }, + { + "startDate": "2022-10-19T18:02:43", + "endDate": "2022-10-19T18:07:43", + "unit": "mg\/min·dL", + "value": 1.423225171907336 + }, + { + "startDate": "2022-10-19T18:07:43", + "endDate": "2022-10-19T18:12:43", + "unit": "mg\/min·dL", + "value": -3.18226150417708 + }, + { + "startDate": "2022-10-19T18:12:43", + "endDate": "2022-10-19T18:17:43", + "unit": "mg\/min·dL", + "value": -4.787369366471273 + }, + { + "startDate": "2022-10-19T18:17:43", + "endDate": "2022-10-19T18:22:43", + "unit": "mg\/min·dL", + "value": 3.6083669362007353 + }, + { + "startDate": "2022-10-19T18:22:43", + "endDate": "2022-10-19T18:27:43", + "unit": "mg\/min·dL", + "value": -0.3949565747393592 + }, + { + "startDate": "2022-10-19T18:27:43", + "endDate": "2022-10-19T18:32:43", + "unit": "mg\/min·dL", + "value": -0.3973978843060308 + }, + { + "startDate": "2022-10-19T18:32:43", + "endDate": "2022-10-19T18:37:43", + "unit": "mg\/min·dL", + "value": 2.6009873496056284 + }, + { + "startDate": "2022-10-19T18:37:43", + "endDate": "2022-10-19T18:42:43", + "unit": "mg\/min·dL", + "value": -4.199854242276523 + }, + { + "startDate": "2022-10-19T18:42:43", + "endDate": "2022-10-19T18:47:43", + "unit": "mg\/min·dL", + "value": 3.199988677140936 + }, + { + "startDate": "2022-10-19T18:47:43", + "endDate": "2022-10-19T18:52:43", + "unit": "mg\/min·dL", + "value": 1.7999880977313012 + }, + { + "startDate": "2022-10-19T18:52:43", + "endDate": "2022-10-19T18:57:43", + "unit": "mg\/min·dL", + "value": -0.8000123609487502 + }, + { + "startDate": "2022-10-19T18:57:43", + "endDate": "2022-10-19T19:02:43", + "unit": "mg\/min·dL", + "value": -2.400012711019661 + }, + { + "startDate": "2022-10-19T19:02:43", + "endDate": "2022-10-19T19:07:43", + "unit": "mg\/min·dL", + "value": 5.199987036372721 + }, + { + "startDate": "2022-10-19T19:07:43", + "endDate": "2022-10-19T19:12:43", + "unit": "mg\/min·dL", + "value": -4.60001312901278 + }, + { + "startDate": "2022-10-19T19:12:43", + "endDate": "2022-10-19T19:22:43", + "unit": "mg\/min·dL", + "value": 2.299993391711052 + }, + { + "startDate": "2022-10-19T19:22:43", + "endDate": "2022-10-19T19:27:43", + "unit": "mg\/min·dL", + "value": -1.000013234945743 + }, + { + "startDate": "2022-10-19T19:27:43", + "endDate": "2022-10-19T19:32:43", + "unit": "mg\/min·dL", + "value": -2.600013192017644 + }, + { + "startDate": "2022-10-19T19:32:43", + "endDate": "2022-10-19T19:37:43", + "unit": "mg\/min·dL", + "value": 2.3999869049740195 + }, + { + "startDate": "2022-10-19T19:37:43", + "endDate": "2022-10-19T19:42:43", + "unit": "mg\/min·dL", + "value": -2.2000129505836123 + }, + { + "startDate": "2022-10-19T19:42:43", + "endDate": "2022-10-19T19:47:43", + "unit": "mg\/min·dL", + "value": 2.9999872352701686 + }, + { + "startDate": "2022-10-19T19:47:43", + "endDate": "2022-10-19T19:52:43", + "unit": "mg\/min·dL", + "value": -1.8000125429732121 + }, + { + "startDate": "2022-10-19T19:52:43", + "endDate": "2022-10-19T19:57:43", + "unit": "mg\/min·dL", + "value": -1.000012290331557 + }, + { + "startDate": "2022-10-19T19:57:43", + "endDate": "2022-10-19T20:02:43", + "unit": "mg\/min·dL", + "value": 0.1999879886309827 + }, + { + "startDate": "2022-10-19T20:02:43", + "endDate": "2022-10-19T20:07:43", + "unit": "mg\/min·dL", + "value": -0.8000117102307285 + }, + { + "startDate": "2022-10-19T20:07:43", + "endDate": "2022-10-19T20:12:43", + "unit": "mg\/min·dL", + "value": 2.3999886093249985 + }, + { + "startDate": "2022-10-19T20:12:43", + "endDate": "2022-10-19T20:17:43", + "unit": "mg\/min·dL", + "value": -2.2000110561032553 + }, + { + "startDate": "2022-10-19T20:17:43", + "endDate": "2022-10-19T20:22:43", + "unit": "mg\/min·dL", + "value": 0.39998929041208836 + }, + { + "startDate": "2022-10-19T20:22:43", + "endDate": "2022-10-19T20:27:43", + "unit": "mg\/min·dL", + "value": 2.19998964610175 + }, + { + "startDate": "2022-10-19T20:27:43", + "endDate": "2022-10-19T20:32:43", + "unit": "mg\/min·dL", + "value": -1.2000099915245013 + }, + { + "startDate": "2022-10-19T20:32:43", + "endDate": "2022-10-19T20:37:43", + "unit": "mg\/min·dL", + "value": 1.399990375299808 + }, + { + "startDate": "2022-10-19T20:37:43", + "endDate": "2022-10-19T20:42:43", + "unit": "mg\/min·dL", + "value": -0.8000092554229123 + }, + { + "startDate": "2022-10-19T20:42:43", + "endDate": "2022-10-19T20:47:43", + "unit": "mg\/min·dL", + "value": 3.799991114526437 + } +] diff --git a/LoopTests/Fixtures/meal_detection/realistic_report_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/realistic_report_counteraction_effect.json new file mode 100644 index 0000000000..c8e82f11f5 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/realistic_report_counteraction_effect.json @@ -0,0 +1,1724 @@ +[ + { + "startDate": "2022-10-18T21:40:00", + "endDate": "2022-10-18T21:45:00", + "unit": "mg\/min·dL", + "value": 0.9043847043689791 + }, + { + "startDate": "2022-10-18T21:45:00", + "endDate": "2022-10-18T21:50:01", + "unit": "mg\/min·dL", + "value": 0.8701737681583791 + }, + { + "startDate": "2022-10-18T21:50:01", + "endDate": "2022-10-18T21:55:01", + "unit": "mg\/min·dL", + "value": 0.44559430294201047 + }, + { + "startDate": "2022-10-18T21:55:01", + "endDate": "2022-10-18T22:00:00", + "unit": "mg\/min·dL", + "value": 0.41686359006411966 + }, + { + "startDate": "2022-10-18T22:00:00", + "endDate": "2022-10-18T22:05:01", + "unit": "mg\/min·dL", + "value": 0.38648385789866685 + }, + { + "startDate": "2022-10-18T22:05:01", + "endDate": "2022-10-18T22:10:01", + "unit": "mg\/min·dL", + "value": 0.3578544271635854 + }, + { + "startDate": "2022-10-18T22:10:01", + "endDate": "2022-10-18T22:15:01", + "unit": "mg\/min·dL", + "value": 0.32931115774406733 + }, + { + "startDate": "2022-10-18T22:15:01", + "endDate": "2022-10-18T22:20:01", + "unit": "mg\/min·dL", + "value": 0.10057626183015964 + }, + { + "startDate": "2022-10-18T22:20:01", + "endDate": "2022-10-18T22:25:00", + "unit": "mg\/min·dL", + "value": 0.4729780858945708 + }, + { + "startDate": "2022-10-18T22:25:00", + "endDate": "2022-10-18T22:30:00", + "unit": "mg\/min·dL", + "value": -0.3546781456753047 + }, + { + "startDate": "2022-10-18T22:30:00", + "endDate": "2022-10-18T22:35:01", + "unit": "mg\/min·dL", + "value": -0.18131076193035978 + }, + { + "startDate": "2022-10-18T22:35:01", + "endDate": "2022-10-18T22:40:00", + "unit": "mg\/min·dL", + "value": -1.010247144263618 + }, + { + "startDate": "2022-10-18T22:40:00", + "endDate": "2022-10-18T22:45:00", + "unit": "mg\/min·dL", + "value": -0.3668240075057755 + }, + { + "startDate": "2022-10-18T22:45:00", + "endDate": "2022-10-18T22:50:01", + "unit": "mg\/min·dL", + "value": 1.559394854618121 + }, + { + "startDate": "2022-10-18T22:50:01", + "endDate": "2022-10-18T22:55:00", + "unit": "mg\/min·dL", + "value": 2.071374628866257 + }, + { + "startDate": "2022-10-18T22:55:00", + "endDate": "2022-10-18T23:00:01", + "unit": "mg\/min·dL", + "value": 1.5603433296934301 + }, + { + "startDate": "2022-10-18T23:00:01", + "endDate": "2022-10-18T23:05:00", + "unit": "mg\/min·dL", + "value": 1.4440744498686167 + }, + { + "startDate": "2022-10-18T23:05:00", + "endDate": "2022-10-18T23:10:00", + "unit": "mg\/min·dL", + "value": 1.5156883860813217 + }, + { + "startDate": "2022-10-18T23:10:00", + "endDate": "2022-10-18T23:15:00", + "unit": "mg\/min·dL", + "value": 1.7727836419735754 + }, + { + "startDate": "2022-10-18T23:15:00", + "endDate": "2022-10-18T23:20:01", + "unit": "mg\/min·dL", + "value": 1.6191783680941592 + }, + { + "startDate": "2022-10-18T23:20:01", + "endDate": "2022-10-18T23:25:00", + "unit": "mg\/min·dL", + "value": 1.2614262583288534 + }, + { + "startDate": "2022-10-18T23:25:00", + "endDate": "2022-10-18T23:30:00", + "unit": "mg\/min·dL", + "value": 0.28656922575226085 + }, + { + "startDate": "2022-10-18T23:30:00", + "endDate": "2022-10-18T23:35:01", + "unit": "mg\/min·dL", + "value": 0.5040272887218451 + }, + { + "startDate": "2022-10-18T23:35:01", + "endDate": "2022-10-18T23:40:01", + "unit": "mg\/min·dL", + "value": -0.88797938985592 + }, + { + "startDate": "2022-10-18T23:40:01", + "endDate": "2022-10-18T23:45:00", + "unit": "mg\/min·dL", + "value": 0.3102981695282373 + }, + { + "startDate": "2022-10-18T23:45:00", + "endDate": "2022-10-18T23:50:00", + "unit": "mg\/min·dL", + "value": 0.2990145023827218 + }, + { + "startDate": "2022-10-18T23:50:00", + "endDate": "2022-10-18T23:55:00", + "unit": "mg\/min·dL", + "value": 0.2813549886870839 + }, + { + "startDate": "2022-10-18T23:55:00", + "endDate": "2022-10-19T00:00:00", + "unit": "mg\/min·dL", + "value": 0.25803373961516457 + }, + { + "startDate": "2022-10-19T00:00:00", + "endDate": "2022-10-19T00:05:00", + "unit": "mg\/min·dL", + "value": 0.029882256677400014 + }, + { + "startDate": "2022-10-19T00:05:00", + "endDate": "2022-10-19T00:10:00", + "unit": "mg\/min·dL", + "value": -0.0018539363122007967 + }, + { + "startDate": "2022-10-19T00:10:00", + "endDate": "2022-10-19T00:15:01", + "unit": "mg\/min·dL", + "value": -0.4362035671247494 + }, + { + "startDate": "2022-10-19T00:15:01", + "endDate": "2022-10-19T00:20:00", + "unit": "mg\/min·dL", + "value": 0.12697827245171217 + }, + { + "startDate": "2022-10-19T00:20:00", + "endDate": "2022-10-19T00:25:01", + "unit": "mg\/min·dL", + "value": -0.1137114895201975 + }, + { + "startDate": "2022-10-19T00:25:01", + "endDate": "2022-10-19T00:30:00", + "unit": "mg\/min·dL", + "value": 0.8404650492430689 + }, + { + "startDate": "2022-10-19T00:30:00", + "endDate": "2022-10-19T00:35:01", + "unit": "mg\/min·dL", + "value": -0.011742501835059939 + }, + { + "startDate": "2022-10-19T00:35:01", + "endDate": "2022-10-19T00:40:00", + "unit": "mg\/min·dL", + "value": 0.5345114838195776 + }, + { + "startDate": "2022-10-19T00:40:00", + "endDate": "2022-10-19T00:45:00", + "unit": "mg\/min·dL", + "value": 0.47586430649907935 + }, + { + "startDate": "2022-10-19T00:45:00", + "endDate": "2022-10-19T00:50:00", + "unit": "mg\/min·dL", + "value": 0.2178093373305446 + }, + { + "startDate": "2022-10-19T00:50:00", + "endDate": "2022-10-19T00:55:00", + "unit": "mg\/min·dL", + "value": 0.5578754706878531 + }, + { + "startDate": "2022-10-19T00:55:00", + "endDate": "2022-10-19T01:00:00", + "unit": "mg\/min·dL", + "value": -0.9011291440087709 + }, + { + "startDate": "2022-10-19T01:00:00", + "endDate": "2022-10-19T01:05:00", + "unit": "mg\/min·dL", + "value": 0.03906347621778584 + }, + { + "startDate": "2022-10-19T01:05:00", + "endDate": "2022-10-19T01:10:00", + "unit": "mg\/min·dL", + "value": -0.02020473697510492 + }, + { + "startDate": "2022-10-19T01:10:00", + "endDate": "2022-10-19T01:15:00", + "unit": "mg\/min·dL", + "value": -0.07837275714453532 + }, + { + "startDate": "2022-10-19T01:15:00", + "endDate": "2022-10-19T01:20:00", + "unit": "mg\/min·dL", + "value": 1.4649496777138962 + }, + { + "startDate": "2022-10-19T01:20:00", + "endDate": "2022-10-19T01:25:01", + "unit": "mg\/min·dL", + "value": 3.401845866546212 + }, + { + "startDate": "2022-10-19T01:25:01", + "endDate": "2022-10-19T01:30:01", + "unit": "mg\/min·dL", + "value": 3.3542079394357533 + }, + { + "startDate": "2022-10-19T01:30:01", + "endDate": "2022-10-19T01:35:01", + "unit": "mg\/min·dL", + "value": 2.107098415709067 + }, + { + "startDate": "2022-10-19T01:35:01", + "endDate": "2022-10-19T01:40:00", + "unit": "mg\/min·dL", + "value": 0.4658451108604793 + }, + { + "startDate": "2022-10-19T01:40:00", + "endDate": "2022-10-19T01:45:01", + "unit": "mg\/min·dL", + "value": -0.1632488253614695 + }, + { + "startDate": "2022-10-19T01:45:01", + "endDate": "2022-10-19T01:50:01", + "unit": "mg\/min·dL", + "value": -0.5885867519773967 + }, + { + "startDate": "2022-10-19T01:50:01", + "endDate": "2022-10-19T01:55:00", + "unit": "mg\/min·dL", + "value": -1.0062735825086297 + }, + { + "startDate": "2022-10-19T01:55:00", + "endDate": "2022-10-19T02:00:00", + "unit": "mg\/min·dL", + "value": -1.2185933300337954 + }, + { + "startDate": "2022-10-19T02:00:00", + "endDate": "2022-10-19T02:05:01", + "unit": "mg\/min·dL", + "value": -0.8326700677766216 + }, + { + "startDate": "2022-10-19T02:05:01", + "endDate": "2022-10-19T02:10:01", + "unit": "mg\/min·dL", + "value": -2.84257051980203 + }, + { + "startDate": "2022-10-19T02:10:01", + "endDate": "2022-10-19T02:15:00", + "unit": "mg\/min·dL", + "value": -0.8562035248873597 + }, + { + "startDate": "2022-10-19T02:15:00", + "endDate": "2022-10-19T02:20:01", + "unit": "mg\/min·dL", + "value": -0.26526876046429276 + }, + { + "startDate": "2022-10-19T02:20:01", + "endDate": "2022-10-19T02:25:01", + "unit": "mg\/min·dL", + "value": -0.27929377419252777 + }, + { + "startDate": "2022-10-19T02:25:01", + "endDate": "2022-10-19T02:30:00", + "unit": "mg\/min·dL", + "value": -0.09615878507465565 + }, + { + "startDate": "2022-10-19T02:30:00", + "endDate": "2022-10-19T02:35:00", + "unit": "mg\/min·dL", + "value": 0.2853641733897771 + }, + { + "startDate": "2022-10-19T02:35:00", + "endDate": "2022-10-19T02:40:01", + "unit": "mg\/min·dL", + "value": 2.2630294082591282 + }, + { + "startDate": "2022-10-19T02:40:01", + "endDate": "2022-10-19T02:45:00", + "unit": "mg\/min·dL", + "value": 2.935184435842075 + }, + { + "startDate": "2022-10-19T02:45:00", + "endDate": "2022-10-19T02:50:01", + "unit": "mg\/min·dL", + "value": 3.4054208562036465 + }, + { + "startDate": "2022-10-19T02:50:01", + "endDate": "2022-10-19T02:55:01", + "unit": "mg\/min·dL", + "value": 2.7032681066820055 + }, + { + "startDate": "2022-10-19T02:55:01", + "endDate": "2022-10-19T03:00:00", + "unit": "mg\/min·dL", + "value": 2.80018879273112 + }, + { + "startDate": "2022-10-19T03:00:00", + "endDate": "2022-10-19T03:05:01", + "unit": "mg\/min·dL", + "value": 2.4965292339587837 + }, + { + "startDate": "2022-10-19T03:05:01", + "endDate": "2022-10-19T03:10:01", + "unit": "mg\/min·dL", + "value": 1.6113117856204644 + }, + { + "startDate": "2022-10-19T03:10:01", + "endDate": "2022-10-19T03:15:01", + "unit": "mg\/min·dL", + "value": 1.1148901778931035 + }, + { + "startDate": "2022-10-19T03:15:01", + "endDate": "2022-10-19T03:25:01", + "unit": "mg\/min·dL", + "value": 1.2465705652221148 + }, + { + "startDate": "2022-10-19T03:25:01", + "endDate": "2022-10-19T03:30:00", + "unit": "mg\/min·dL", + "value": 0.5552565109650426 + }, + { + "startDate": "2022-10-19T03:30:00", + "endDate": "2022-10-19T03:35:01", + "unit": "mg\/min·dL", + "value": 0.4093372449598604 + }, + { + "startDate": "2022-10-19T03:35:01", + "endDate": "2022-10-19T03:40:01", + "unit": "mg\/min·dL", + "value": 0.6526956800529764 + }, + { + "startDate": "2022-10-19T03:40:01", + "endDate": "2022-10-19T03:45:00", + "unit": "mg\/min·dL", + "value": 0.2837328512839709 + }, + { + "startDate": "2022-10-19T03:45:00", + "endDate": "2022-10-19T03:50:01", + "unit": "mg\/min·dL", + "value": -0.2965329523104659 + }, + { + "startDate": "2022-10-19T03:50:01", + "endDate": "2022-10-19T03:55:01", + "unit": "mg\/min·dL", + "value": 0.11264296048927881 + }, + { + "startDate": "2022-10-19T03:55:01", + "endDate": "2022-10-19T04:00:01", + "unit": "mg\/min·dL", + "value": 0.11365480733176563 + }, + { + "startDate": "2022-10-19T04:00:01", + "endDate": "2022-10-19T04:05:01", + "unit": "mg\/min·dL", + "value": 0.7079504519365847 + }, + { + "startDate": "2022-10-19T04:05:01", + "endDate": "2022-10-19T04:10:00", + "unit": "mg\/min·dL", + "value": 0.29539031027196594 + }, + { + "startDate": "2022-10-19T04:10:00", + "endDate": "2022-10-19T04:15:00", + "unit": "mg\/min·dL", + "value": 0.4750368523506627 + }, + { + "startDate": "2022-10-19T04:15:00", + "endDate": "2022-10-19T04:20:01", + "unit": "mg\/min·dL", + "value": 0.2516481493765345 + }, + { + "startDate": "2022-10-19T04:20:01", + "endDate": "2022-10-19T04:25:00", + "unit": "mg\/min·dL", + "value": 0.42687884270523624 + }, + { + "startDate": "2022-10-19T04:25:00", + "endDate": "2022-10-19T04:30:01", + "unit": "mg\/min·dL", + "value": 0.1970294938484129 + }, + { + "startDate": "2022-10-19T04:30:01", + "endDate": "2022-10-19T04:35:01", + "unit": "mg\/min·dL", + "value": -0.6325663853295713 + }, + { + "startDate": "2022-10-19T04:35:01", + "endDate": "2022-10-19T04:40:00", + "unit": "mg\/min·dL", + "value": -1.6671946453873905 + }, + { + "startDate": "2022-10-19T04:40:00", + "endDate": "2022-10-19T04:45:01", + "unit": "mg\/min·dL", + "value": -1.095982136517308 + }, + { + "startDate": "2022-10-19T04:45:01", + "endDate": "2022-10-19T04:50:00", + "unit": "mg\/min·dL", + "value": -0.5411661721419859 + }, + { + "startDate": "2022-10-19T04:50:00", + "endDate": "2022-10-19T04:55:00", + "unit": "mg\/min·dL", + "value": 0.028358336047663885 + }, + { + "startDate": "2022-10-19T04:55:00", + "endDate": "2022-10-19T05:00:01", + "unit": "mg\/min·dL", + "value": -0.20955099234535207 + }, + { + "startDate": "2022-10-19T05:00:01", + "endDate": "2022-10-19T05:05:01", + "unit": "mg\/min·dL", + "value": 0.30207612513942395 + }, + { + "startDate": "2022-10-19T05:05:01", + "endDate": "2022-10-19T05:10:00", + "unit": "mg\/min·dL", + "value": 0.04103782125221336 + }, + { + "startDate": "2022-10-19T05:10:00", + "endDate": "2022-10-19T05:15:01", + "unit": "mg\/min·dL", + "value": 0.3809787987795194 + }, + { + "startDate": "2022-10-19T05:15:01", + "endDate": "2022-10-19T05:20:01", + "unit": "mg\/min·dL", + "value": -0.0772329850138329 + }, + { + "startDate": "2022-10-19T05:20:01", + "endDate": "2022-10-19T05:25:01", + "unit": "mg\/min·dL", + "value": 0.2837794247411642 + }, + { + "startDate": "2022-10-19T05:25:01", + "endDate": "2022-10-19T05:30:00", + "unit": "mg\/min·dL", + "value": 0.4091244566696956 + }, + { + "startDate": "2022-10-19T05:30:00", + "endDate": "2022-10-19T05:35:00", + "unit": "mg\/min·dL", + "value": -0.040777547570781135 + }, + { + "startDate": "2022-10-19T05:35:00", + "endDate": "2022-10-19T05:40:01", + "unit": "mg\/min·dL", + "value": -0.28963383865548703 + }, + { + "startDate": "2022-10-19T05:40:01", + "endDate": "2022-10-19T05:45:00", + "unit": "mg\/min·dL", + "value": 0.8815079785477252 + }, + { + "startDate": "2022-10-19T05:45:00", + "endDate": "2022-10-19T05:50:00", + "unit": "mg\/min·dL", + "value": 0.4431363092446005 + }, + { + "startDate": "2022-10-19T05:50:00", + "endDate": "2022-10-19T05:55:00", + "unit": "mg\/min·dL", + "value": 0.21446766128625064 + }, + { + "startDate": "2022-10-19T05:55:00", + "endDate": "2022-10-19T06:00:01", + "unit": "mg\/min·dL", + "value": 0.3745668250547948 + }, + { + "startDate": "2022-10-19T06:00:01", + "endDate": "2022-10-19T06:05:01", + "unit": "mg\/min·dL", + "value": 0.34009354076149223 + }, + { + "startDate": "2022-10-19T06:05:01", + "endDate": "2022-10-19T06:10:01", + "unit": "mg\/min·dL", + "value": 0.909838642145608 + }, + { + "startDate": "2022-10-19T06:10:01", + "endDate": "2022-10-19T06:15:01", + "unit": "mg\/min·dL", + "value": 0.6925767053834189 + }, + { + "startDate": "2022-10-19T06:15:01", + "endDate": "2022-10-19T06:20:01", + "unit": "mg\/min·dL", + "value": 0.48208611056098555 + }, + { + "startDate": "2022-10-19T06:20:01", + "endDate": "2022-10-19T06:25:00", + "unit": "mg\/min·dL", + "value": 0.2831925640399337 + }, + { + "startDate": "2022-10-19T06:25:00", + "endDate": "2022-10-19T06:30:01", + "unit": "mg\/min·dL", + "value": 0.29029277640990603 + }, + { + "startDate": "2022-10-19T06:30:01", + "endDate": "2022-10-19T06:35:01", + "unit": "mg\/min·dL", + "value": -0.2977661275897406 + }, + { + "startDate": "2022-10-19T06:35:01", + "endDate": "2022-10-19T06:40:01", + "unit": "mg\/min·dL", + "value": -1.4835473972248248 + }, + { + "startDate": "2022-10-19T06:40:01", + "endDate": "2022-10-19T06:45:01", + "unit": "mg\/min·dL", + "value": -0.06684185693201271 + }, + { + "startDate": "2022-10-19T06:45:01", + "endDate": "2022-10-19T06:50:01", + "unit": "mg\/min·dL", + "value": -0.05240734057580225 + }, + { + "startDate": "2022-10-19T06:50:01", + "endDate": "2022-10-19T06:55:00", + "unit": "mg\/min·dL", + "value": -0.24556517930204355 + }, + { + "startDate": "2022-10-19T06:55:00", + "endDate": "2022-10-19T07:00:00", + "unit": "mg\/min·dL", + "value": -0.44211478120218145 + }, + { + "startDate": "2022-10-19T07:00:00", + "endDate": "2022-10-19T07:05:01", + "unit": "mg\/min·dL", + "value": -1.03857881100502 + }, + { + "startDate": "2022-10-19T07:05:01", + "endDate": "2022-10-19T07:10:01", + "unit": "mg\/min·dL", + "value": -0.43890261619044063 + }, + { + "startDate": "2022-10-19T07:10:01", + "endDate": "2022-10-19T07:15:01", + "unit": "mg\/min·dL", + "value": -0.6439573640191639 + }, + { + "startDate": "2022-10-19T07:15:01", + "endDate": "2022-10-19T07:20:01", + "unit": "mg\/min·dL", + "value": -0.4532385550898397 + }, + { + "startDate": "2022-10-19T07:20:01", + "endDate": "2022-10-19T07:25:01", + "unit": "mg\/min·dL", + "value": -0.8660471979684273 + }, + { + "startDate": "2022-10-19T07:25:01", + "endDate": "2022-10-19T07:30:01", + "unit": "mg\/min·dL", + "value": -0.281574959738387 + }, + { + "startDate": "2022-10-19T07:30:01", + "endDate": "2022-10-19T07:35:01", + "unit": "mg\/min·dL", + "value": 0.30063941285933987 + }, + { + "startDate": "2022-10-19T07:35:01", + "endDate": "2022-10-19T07:40:01", + "unit": "mg\/min·dL", + "value": -0.3188112289597015 + }, + { + "startDate": "2022-10-19T07:40:01", + "endDate": "2022-10-19T07:45:00", + "unit": "mg\/min·dL", + "value": 0.062436338615094615 + }, + { + "startDate": "2022-10-19T07:45:00", + "endDate": "2022-10-19T07:50:00", + "unit": "mg\/min·dL", + "value": 0.4478691259722767 + }, + { + "startDate": "2022-10-19T07:50:00", + "endDate": "2022-10-19T07:55:01", + "unit": "mg\/min·dL", + "value": 0.8352780727089159 + }, + { + "startDate": "2022-10-19T07:55:01", + "endDate": "2022-10-19T08:00:01", + "unit": "mg\/min·dL", + "value": 0.22900532179424257 + }, + { + "startDate": "2022-10-19T08:00:01", + "endDate": "2022-10-19T08:05:01", + "unit": "mg\/min·dL", + "value": 0.02347733142306274 + }, + { + "startDate": "2022-10-19T08:05:01", + "endDate": "2022-10-19T08:10:01", + "unit": "mg\/min·dL", + "value": -0.7783748292358011 + }, + { + "startDate": "2022-10-19T08:10:01", + "endDate": "2022-10-19T08:15:01", + "unit": "mg\/min·dL", + "value": 0.6272885060509404 + }, + { + "startDate": "2022-10-19T08:15:01", + "endDate": "2022-10-19T08:20:01", + "unit": "mg\/min·dL", + "value": 0.23419734350722396 + }, + { + "startDate": "2022-10-19T08:20:01", + "endDate": "2022-10-19T08:25:00", + "unit": "mg\/min·dL", + "value": 0.2428650510584241 + }, + { + "startDate": "2022-10-19T08:25:00", + "endDate": "2022-10-19T08:30:01", + "unit": "mg\/min·dL", + "value": 0.2524272001329743 + }, + { + "startDate": "2022-10-19T08:30:01", + "endDate": "2022-10-19T08:35:01", + "unit": "mg\/min·dL", + "value": 0.06685058482744398 + }, + { + "startDate": "2022-10-19T08:35:01", + "endDate": "2022-10-19T08:40:01", + "unit": "mg\/min·dL", + "value": -0.11765785383167682 + }, + { + "startDate": "2022-10-19T08:40:01", + "endDate": "2022-10-19T08:45:01", + "unit": "mg\/min·dL", + "value": 0.2974449346156582 + }, + { + "startDate": "2022-10-19T08:45:01", + "endDate": "2022-10-19T08:50:01", + "unit": "mg\/min·dL", + "value": 0.11291581770004194 + }, + { + "startDate": "2022-10-19T08:50:01", + "endDate": "2022-10-19T08:55:01", + "unit": "mg\/min·dL", + "value": -0.07123242642898692 + }, + { + "startDate": "2022-10-19T08:55:01", + "endDate": "2022-10-19T09:00:01", + "unit": "mg\/min·dL", + "value": -0.05448916692972083 + }, + { + "startDate": "2022-10-19T09:00:01", + "endDate": "2022-10-19T09:05:01", + "unit": "mg\/min·dL", + "value": 0.16099146820008903 + }, + { + "startDate": "2022-10-19T09:05:01", + "endDate": "2022-10-19T09:10:01", + "unit": "mg\/min·dL", + "value": 0.17549047688591932 + }, + { + "startDate": "2022-10-19T09:10:01", + "endDate": "2022-10-19T09:15:01", + "unit": "mg\/min·dL", + "value": -0.21178831469440673 + }, + { + "startDate": "2022-10-19T09:15:01", + "endDate": "2022-10-19T09:20:00", + "unit": "mg\/min·dL", + "value": -0.4010107502180188 + }, + { + "startDate": "2022-10-19T09:20:00", + "endDate": "2022-10-19T09:25:01", + "unit": "mg\/min·dL", + "value": -0.38816196596823593 + }, + { + "startDate": "2022-10-19T09:25:01", + "endDate": "2022-10-19T09:30:01", + "unit": "mg\/min·dL", + "value": -0.3803371601566195 + }, + { + "startDate": "2022-10-19T09:30:01", + "endDate": "2022-10-19T09:35:01", + "unit": "mg\/min·dL", + "value": 0.027318177225786312 + }, + { + "startDate": "2022-10-19T09:35:01", + "endDate": "2022-10-19T09:40:01", + "unit": "mg\/min·dL", + "value": 0.23321816158317168 + }, + { + "startDate": "2022-10-19T09:40:01", + "endDate": "2022-10-19T09:45:01", + "unit": "mg\/min·dL", + "value": 0.03823915818460353 + }, + { + "startDate": "2022-10-19T09:45:01", + "endDate": "2022-10-19T09:50:01", + "unit": "mg\/min·dL", + "value": 0.04214613731129099 + }, + { + "startDate": "2022-10-19T09:50:01", + "endDate": "2022-10-19T09:55:01", + "unit": "mg\/min·dL", + "value": -0.3547462209861638 + }, + { + "startDate": "2022-10-19T09:55:01", + "endDate": "2022-10-19T10:00:01", + "unit": "mg\/min·dL", + "value": -0.1528813572718399 + }, + { + "startDate": "2022-10-19T10:00:01", + "endDate": "2022-10-19T10:05:01", + "unit": "mg\/min·dL", + "value": 0.0487369632008895 + }, + { + "startDate": "2022-10-19T10:05:01", + "endDate": "2022-10-19T10:10:01", + "unit": "mg\/min·dL", + "value": -0.15044460943490148 + }, + { + "startDate": "2022-10-19T10:10:01", + "endDate": "2022-10-19T10:15:01", + "unit": "mg\/min·dL", + "value": -0.34944869885912044 + }, + { + "startDate": "2022-10-19T10:15:01", + "endDate": "2022-10-19T10:20:01", + "unit": "mg\/min·dL", + "value": 0.6507861066920333 + }, + { + "startDate": "2022-10-19T10:20:01", + "endDate": "2022-10-19T10:25:01", + "unit": "mg\/min·dL", + "value": 0.24969999327567519 + }, + { + "startDate": "2022-10-19T10:25:01", + "endDate": "2022-10-19T10:30:01", + "unit": "mg\/min·dL", + "value": 0.048794343384953386 + }, + { + "startDate": "2022-10-19T10:30:01", + "endDate": "2022-10-19T10:35:01", + "unit": "mg\/min·dL", + "value": -1.7526382442657054 + }, + { + "startDate": "2022-10-19T10:35:01", + "endDate": "2022-10-19T10:40:01", + "unit": "mg\/min·dL", + "value": -0.3534383576597841 + }, + { + "startDate": "2022-10-19T10:40:01", + "endDate": "2022-10-19T10:45:01", + "unit": "mg\/min·dL", + "value": 0.4455757100573625 + }, + { + "startDate": "2022-10-19T10:45:01", + "endDate": "2022-10-19T10:50:01", + "unit": "mg\/min·dL", + "value": -0.7586169858263023 + }, + { + "startDate": "2022-10-19T10:50:01", + "endDate": "2022-10-19T10:55:01", + "unit": "mg\/min·dL", + "value": -0.3648598606818268 + }, + { + "startDate": "2022-10-19T10:55:01", + "endDate": "2022-10-19T11:00:01", + "unit": "mg\/min·dL", + "value": 0.42939657423429517 + }, + { + "startDate": "2022-10-19T11:00:01", + "endDate": "2022-10-19T11:05:00", + "unit": "mg\/min·dL", + "value": 0.42225019302974154 + }, + { + "startDate": "2022-10-19T11:05:00", + "endDate": "2022-10-19T11:10:01", + "unit": "mg\/min·dL", + "value": 0.21186447917983653 + }, + { + "startDate": "2022-10-19T11:10:01", + "endDate": "2022-10-19T11:15:01", + "unit": "mg\/min·dL", + "value": -0.19565062644910516 + }, + { + "startDate": "2022-10-19T11:15:01", + "endDate": "2022-10-19T11:20:01", + "unit": "mg\/min·dL", + "value": 0.19783593524528145 + }, + { + "startDate": "2022-10-19T11:20:01", + "endDate": "2022-10-19T11:25:01", + "unit": "mg\/min·dL", + "value": -0.008068324764244858 + }, + { + "startDate": "2022-10-19T11:25:01", + "endDate": "2022-10-19T11:30:01", + "unit": "mg\/min·dL", + "value": -0.21297518811277394 + }, + { + "startDate": "2022-10-19T11:30:01", + "endDate": "2022-10-19T11:35:01", + "unit": "mg\/min·dL", + "value": -1.4154392414184709 + }, + { + "startDate": "2022-10-19T11:35:01", + "endDate": "2022-10-19T11:40:01", + "unit": "mg\/min·dL", + "value": 0.18063367404695194 + }, + { + "startDate": "2022-10-19T11:40:01", + "endDate": "2022-10-19T11:45:01", + "unit": "mg\/min·dL", + "value": -0.024732221844547427 + }, + { + "startDate": "2022-10-19T11:45:01", + "endDate": "2022-10-19T11:50:01", + "unit": "mg\/min·dL", + "value": 0.36890313428117716 + }, + { + "startDate": "2022-10-19T11:50:01", + "endDate": "2022-10-19T11:55:01", + "unit": "mg\/min·dL", + "value": -0.037138331257601985 + }, + { + "startDate": "2022-10-19T11:55:01", + "endDate": "2022-10-19T12:00:01", + "unit": "mg\/min·dL", + "value": 0.15833000430373778 + }, + { + "startDate": "2022-10-19T12:00:01", + "endDate": "2022-10-19T12:05:01", + "unit": "mg\/min·dL", + "value": 0.5559567576354827 + }, + { + "startDate": "2022-10-19T12:05:01", + "endDate": "2022-10-19T12:10:01", + "unit": "mg\/min·dL", + "value": 0.5530172956564566 + }, + { + "startDate": "2022-10-19T12:10:01", + "endDate": "2022-10-19T12:15:01", + "unit": "mg\/min·dL", + "value": -0.44852664362951983 + }, + { + "startDate": "2022-10-19T12:15:01", + "endDate": "2022-10-19T12:20:01", + "unit": "mg\/min·dL", + "value": -2.6477415901684105 + }, + { + "startDate": "2022-10-19T12:20:01", + "endDate": "2022-10-19T12:25:01", + "unit": "mg\/min·dL", + "value": 0.1552977678286194 + }, + { + "startDate": "2022-10-19T12:25:01", + "endDate": "2022-10-19T12:30:01", + "unit": "mg\/min·dL", + "value": 0.35583861287686863 + }, + { + "startDate": "2022-10-19T12:30:01", + "endDate": "2022-10-19T12:35:01", + "unit": "mg\/min·dL", + "value": 0.15178321128801062 + }, + { + "startDate": "2022-10-19T12:35:01", + "endDate": "2022-10-19T12:40:01", + "unit": "mg\/min·dL", + "value": -0.054382641516428916 + }, + { + "startDate": "2022-10-19T12:40:01", + "endDate": "2022-10-19T12:45:01", + "unit": "mg\/min·dL", + "value": 0.34013478086255156 + }, + { + "startDate": "2022-10-19T12:45:01", + "endDate": "2022-10-19T12:50:01", + "unit": "mg\/min·dL", + "value": 0.3382845937498871 + }, + { + "startDate": "2022-10-19T12:50:01", + "endDate": "2022-10-19T12:55:01", + "unit": "mg\/min·dL", + "value": 0.3348726530312953 + }, + { + "startDate": "2022-10-19T12:55:01", + "endDate": "2022-10-19T13:00:01", + "unit": "mg\/min·dL", + "value": 0.13375947107729694 + }, + { + "startDate": "2022-10-19T13:00:01", + "endDate": "2022-10-19T13:05:01", + "unit": "mg\/min·dL", + "value": 0.33363760645792245 + }, + { + "startDate": "2022-10-19T13:05:01", + "endDate": "2022-10-19T13:10:01", + "unit": "mg\/min·dL", + "value": 0.13416151687489303 + }, + { + "startDate": "2022-10-19T13:10:01", + "endDate": "2022-10-19T13:15:01", + "unit": "mg\/min·dL", + "value": 0.13637905971035752 + }, + { + "startDate": "2022-10-19T13:15:01", + "endDate": "2022-10-19T13:20:01", + "unit": "mg\/min·dL", + "value": -0.05841515325947949 + }, + { + "startDate": "2022-10-19T13:20:01", + "endDate": "2022-10-19T13:25:01", + "unit": "mg\/min·dL", + "value": 0.34745831602294186 + }, + { + "startDate": "2022-10-19T13:25:01", + "endDate": "2022-10-19T13:30:01", + "unit": "mg\/min·dL", + "value": 0.15356560530275998 + }, + { + "startDate": "2022-10-19T13:30:01", + "endDate": "2022-10-19T13:35:01", + "unit": "mg\/min·dL", + "value": 0.15814886769556477 + }, + { + "startDate": "2022-10-19T13:35:01", + "endDate": "2022-10-19T13:40:01", + "unit": "mg\/min·dL", + "value": 0.1643211476796707 + }, + { + "startDate": "2022-10-19T13:40:01", + "endDate": "2022-10-19T13:45:01", + "unit": "mg\/min·dL", + "value": -0.42819858626465096 + }, + { + "startDate": "2022-10-19T13:45:01", + "endDate": "2022-10-19T13:50:01", + "unit": "mg\/min·dL", + "value": -0.4246398182972079 + }, + { + "startDate": "2022-10-19T13:50:01", + "endDate": "2022-10-19T13:55:01", + "unit": "mg\/min·dL", + "value": -1.6170221190514171 + }, + { + "startDate": "2022-10-19T13:55:01", + "endDate": "2022-10-19T14:00:01", + "unit": "mg\/min·dL", + "value": -0.01458102994261643 + }, + { + "startDate": "2022-10-19T14:00:01", + "endDate": "2022-10-19T14:05:01", + "unit": "mg\/min·dL", + "value": -0.013310698763393285 + }, + { + "startDate": "2022-10-19T14:05:01", + "endDate": "2022-10-19T14:10:01", + "unit": "mg\/min·dL", + "value": -3.0229583310838355 + }, + { + "startDate": "2022-10-19T14:10:01", + "endDate": "2022-10-19T14:15:01", + "unit": "mg\/min·dL", + "value": -0.6231747533325243 + }, + { + "startDate": "2022-10-19T14:15:01", + "endDate": "2022-10-19T14:20:01", + "unit": "mg\/min·dL", + "value": -0.031222581685987863 + }, + { + "startDate": "2022-10-19T14:20:01", + "endDate": "2022-10-19T14:25:01", + "unit": "mg\/min·dL", + "value": -0.24248441048452055 + }, + { + "startDate": "2022-10-19T14:25:01", + "endDate": "2022-10-19T14:30:01", + "unit": "mg\/min·dL", + "value": -0.2576185319073888 + }, + { + "startDate": "2022-10-19T14:30:01", + "endDate": "2022-10-19T14:35:01", + "unit": "mg\/min·dL", + "value": -0.07416048984164367 + }, + { + "startDate": "2022-10-19T14:35:01", + "endDate": "2022-10-19T14:40:02", + "unit": "mg\/min·dL", + "value": 0.10654218048278917 + }, + { + "startDate": "2022-10-19T14:40:02", + "endDate": "2022-10-19T14:45:01", + "unit": "mg\/min·dL", + "value": 0.2868467771919189 + }, + { + "startDate": "2022-10-19T14:45:01", + "endDate": "2022-10-19T14:50:01", + "unit": "mg\/min·dL", + "value": 3.859662943460218 + }, + { + "startDate": "2022-10-19T14:50:01", + "endDate": "2022-10-19T14:55:01", + "unit": "mg\/min·dL", + "value": 2.0426250789551563 + }, + { + "startDate": "2022-10-19T14:55:01", + "endDate": "2022-10-19T15:00:02", + "unit": "mg\/min·dL", + "value": 0.8274726282751605 + }, + { + "startDate": "2022-10-19T15:00:02", + "endDate": "2022-10-19T15:05:01", + "unit": "mg\/min·dL", + "value": -0.1647373172917607 + }, + { + "startDate": "2022-10-19T15:05:01", + "endDate": "2022-10-19T15:10:01", + "unit": "mg\/min·dL", + "value": -0.3383393318533003 + }, + { + "startDate": "2022-10-19T15:10:01", + "endDate": "2022-10-19T15:15:01", + "unit": "mg\/min·dL", + "value": -1.3010751993501548 + }, + { + "startDate": "2022-10-19T15:15:01", + "endDate": "2022-10-19T15:20:01", + "unit": "mg\/min·dL", + "value": -1.0578507825421526 + }, + { + "startDate": "2022-10-19T15:20:01", + "endDate": "2022-10-19T15:25:01", + "unit": "mg\/min·dL", + "value": -1.2236208075979995 + }, + { + "startDate": "2022-10-19T15:25:01", + "endDate": "2022-10-19T15:30:01", + "unit": "mg\/min·dL", + "value": -0.1898106156676509 + }, + { + "startDate": "2022-10-19T15:30:01", + "endDate": "2022-10-19T15:35:01", + "unit": "mg\/min·dL", + "value": -0.16517201004765142 + }, + { + "startDate": "2022-10-19T15:35:01", + "endDate": "2022-10-19T15:40:01", + "unit": "mg\/min·dL", + "value": -0.9484454909505492 + }, + { + "startDate": "2022-10-19T15:40:01", + "endDate": "2022-10-19T15:45:01", + "unit": "mg\/min·dL", + "value": 0.056840842438597564 + }, + { + "startDate": "2022-10-19T15:45:01", + "endDate": "2022-10-19T15:50:01", + "unit": "mg\/min·dL", + "value": 1.6894159072949382 + }, + { + "startDate": "2022-10-19T15:50:01", + "endDate": "2022-10-19T15:55:01", + "unit": "mg\/min·dL", + "value": 3.4370337711808054 + }, + { + "startDate": "2022-10-19T15:55:01", + "endDate": "2022-10-19T16:00:01", + "unit": "mg\/min·dL", + "value": 4.154314244332858 + }, + { + "startDate": "2022-10-19T16:00:01", + "endDate": "2022-10-19T16:05:01", + "unit": "mg\/min·dL", + "value": 4.291317248669294 + }, + { + "startDate": "2022-10-19T16:05:01", + "endDate": "2022-10-19T16:10:01", + "unit": "mg\/min·dL", + "value": 3.5995338405154502 + }, + { + "startDate": "2022-10-19T16:10:01", + "endDate": "2022-10-19T16:15:01", + "unit": "mg\/min·dL", + "value": 2.503912693304304 + }, + { + "startDate": "2022-10-19T16:15:01", + "endDate": "2022-10-19T16:20:01", + "unit": "mg\/min·dL", + "value": 1.6066437038993249 + }, + { + "startDate": "2022-10-19T16:20:01", + "endDate": "2022-10-19T16:25:02", + "unit": "mg\/min·dL", + "value": 0.6934545720681625 + }, + { + "startDate": "2022-10-19T16:25:02", + "endDate": "2022-10-19T16:30:01", + "unit": "mg\/min·dL", + "value": -0.03221607567498431 + }, + { + "startDate": "2022-10-19T16:30:01", + "endDate": "2022-10-19T16:35:01", + "unit": "mg\/min·dL", + "value": -0.37750668609914667 + }, + { + "startDate": "2022-10-19T16:35:01", + "endDate": "2022-10-19T16:40:02", + "unit": "mg\/min·dL", + "value": -0.5366513932315495 + }, + { + "startDate": "2022-10-19T16:40:02", + "endDate": "2022-10-19T16:45:01", + "unit": "mg\/min·dL", + "value": -0.7149824772770897 + }, + { + "startDate": "2022-10-19T16:45:01", + "endDate": "2022-10-19T16:50:01", + "unit": "mg\/min·dL", + "value": -0.5044701021773266 + }, + { + "startDate": "2022-10-19T16:50:01", + "endDate": "2022-10-19T16:55:01", + "unit": "mg\/min·dL", + "value": -2.109505339654445 + }, + { + "startDate": "2022-10-19T16:55:01", + "endDate": "2022-10-19T17:00:01", + "unit": "mg\/min·dL", + "value": 0.4716914633278851 + }, + { + "startDate": "2022-10-19T17:00:01", + "endDate": "2022-10-19T17:05:01", + "unit": "mg\/min·dL", + "value": -2.5569783763841247 + }, + { + "startDate": "2022-10-19T17:05:01", + "endDate": "2022-10-19T17:10:01", + "unit": "mg\/min·dL", + "value": -0.5931422423549346 + }, + { + "startDate": "2022-10-19T17:10:01", + "endDate": "2022-10-19T17:15:01", + "unit": "mg\/min·dL", + "value": 0.9651429840342454 + }, + { + "startDate": "2022-10-19T17:15:01", + "endDate": "2022-10-19T17:20:01", + "unit": "mg\/min·dL", + "value": 1.7185111311450811 + }, + { + "startDate": "2022-10-19T17:20:01", + "endDate": "2022-10-19T17:25:01", + "unit": "mg\/min·dL", + "value": 3.261097657377394 + }, + { + "startDate": "2022-10-19T17:25:01", + "endDate": "2022-10-19T17:30:01", + "unit": "mg\/min·dL", + "value": 2.218111960607878 + }, + { + "startDate": "2022-10-19T17:30:01", + "endDate": "2022-10-19T17:35:01", + "unit": "mg\/min·dL", + "value": 1.9614989874606985 + }, + { + "startDate": "2022-10-19T17:35:01", + "endDate": "2022-10-19T17:40:01", + "unit": "mg\/min·dL", + "value": 2.310030719935431 + }, + { + "startDate": "2022-10-19T17:40:01", + "endDate": "2022-10-19T17:45:01", + "unit": "mg\/min·dL", + "value": 2.2796929226444966 + }, + { + "startDate": "2022-10-19T17:45:01", + "endDate": "2022-10-19T17:50:01", + "unit": "mg\/min·dL", + "value": 1.3070330004516437 + }, + { + "startDate": "2022-10-19T17:50:01", + "endDate": "2022-10-19T17:55:01", + "unit": "mg\/min·dL", + "value": 1.3369831256626694 + }, + { + "startDate": "2022-10-19T17:55:01", + "endDate": "2022-10-19T18:00:01", + "unit": "mg\/min·dL", + "value": 0.7637465872357964 + }, + { + "startDate": "2022-10-19T18:00:01", + "endDate": "2022-10-19T18:05:01", + "unit": "mg\/min·dL", + "value": 2.982136459448906 + }, + { + "startDate": "2022-10-19T18:05:01", + "endDate": "2022-10-19T18:10:02", + "unit": "mg\/min·dL", + "value": 1.7816453872682723 + }, + { + "startDate": "2022-10-19T18:10:02", + "endDate": "2022-10-19T18:15:01", + "unit": "mg\/min·dL", + "value": 1.3895013557358005 + }, + { + "startDate": "2022-10-19T18:15:01", + "endDate": "2022-10-19T18:20:02", + "unit": "mg\/min·dL", + "value": 1.7750958006672506 + }, + { + "startDate": "2022-10-19T18:20:02", + "endDate": "2022-10-19T18:25:01", + "unit": "mg\/min·dL", + "value": -0.0325518583724865 + }, + { + "startDate": "2022-10-19T18:25:01", + "endDate": "2022-10-19T18:30:01", + "unit": "mg\/min·dL", + "value": 2.5531676738470956 + }, + { + "startDate": "2022-10-19T18:30:01", + "endDate": "2022-10-19T18:35:01", + "unit": "mg\/min·dL", + "value": 1.541259298963333 + }, + { + "startDate": "2022-10-19T18:35:01", + "endDate": "2022-10-19T18:40:01", + "unit": "mg\/min·dL", + "value": 2.7233457312657436 + }, + { + "startDate": "2022-10-19T18:40:01", + "endDate": "2022-10-19T18:45:02", + "unit": "mg\/min·dL", + "value": 1.3138886583696006 + }, + { + "startDate": "2022-10-19T18:45:02", + "endDate": "2022-10-19T18:50:02", + "unit": "mg\/min·dL", + "value": -0.8787840983987345 + }, + { + "startDate": "2022-10-19T18:50:02", + "endDate": "2022-10-19T18:55:01", + "unit": "mg\/min·dL", + "value": 4.349193045733724 + }, + { + "startDate": "2022-10-19T18:55:01", + "endDate": "2022-10-19T19:00:01", + "unit": "mg\/min·dL", + "value": 0.957056782908769 + }, + { + "startDate": "2022-10-19T19:00:01", + "endDate": "2022-10-19T19:05:01", + "unit": "mg\/min·dL", + "value": 1.1801653071785538 + }, + { + "startDate": "2022-10-19T19:05:01", + "endDate": "2022-10-19T19:10:01", + "unit": "mg\/min·dL", + "value": -0.38564618768177417 + }, + { + "startDate": "2022-10-19T19:10:01", + "endDate": "2022-10-19T19:15:02", + "unit": "mg\/min·dL", + "value": 0.4529340515641141 + }, + { + "startDate": "2022-10-19T19:15:02", + "endDate": "2022-10-19T19:20:01", + "unit": "mg\/min·dL", + "value": 0.3289978439895736 + }, + { + "startDate": "2022-10-19T19:20:01", + "endDate": "2022-10-19T19:25:01", + "unit": "mg\/min·dL", + "value": 1.1342381510013801 + }, + { + "startDate": "2022-10-19T19:25:01", + "endDate": "2022-10-19T19:30:01", + "unit": "mg\/min·dL", + "value": 2.1251594326202325 + }, + { + "startDate": "2022-10-19T19:30:01", + "endDate": "2022-10-19T19:35:01", + "unit": "mg\/min·dL", + "value": 2.6903281741958516 + }, + { + "startDate": "2022-10-19T19:35:01", + "endDate": "2022-10-19T19:40:01", + "unit": "mg\/min·dL", + "value": 2.4470085889667157 + }, + { + "startDate": "2022-10-19T19:40:01", + "endDate": "2022-10-19T19:45:01", + "unit": "mg\/min·dL", + "value": 4.7634008664345195 + }, + { + "startDate": "2022-10-19T19:45:01", + "endDate": "2022-10-19T19:50:02", + "unit": "mg\/min·dL", + "value": 1.8652478945159814 + }, + { + "startDate": "2022-10-19T19:50:02", + "endDate": "2022-10-19T19:55:01", + "unit": "mg\/min·dL", + "value": 0.34772059751605466 + }, + { + "startDate": "2022-10-19T19:55:01", + "endDate": "2022-10-19T20:00:02", + "unit": "mg\/min·dL", + "value": 1.806746991288012 + }, + { + "startDate": "2022-10-19T20:00:02", + "endDate": "2022-10-19T20:05:01", + "unit": "mg\/min·dL", + "value": 0.2464776812075047 + }, + { + "startDate": "2022-10-19T20:05:01", + "endDate": "2022-10-19T20:10:01", + "unit": "mg\/min·dL", + "value": -0.9376351386189138 + }, + { + "startDate": "2022-10-19T20:10:01", + "endDate": "2022-10-19T20:15:01", + "unit": "mg\/min·dL", + "value": 0.05860685806376006 + }, + { + "startDate": "2022-10-19T20:15:01", + "endDate": "2022-10-19T20:20:02", + "unit": "mg\/min·dL", + "value": 2.0363173703859756 + }, + { + "startDate": "2022-10-19T20:20:02", + "endDate": "2022-10-19T20:25:01", + "unit": "mg\/min·dL", + "value": 3.4046523515645477 + }, + { + "startDate": "2022-10-19T20:25:01", + "endDate": "2022-10-19T20:30:01", + "unit": "mg\/min·dL", + "value": 3.345839857833783 + }, + { + "startDate": "2022-10-19T20:30:01", + "endDate": "2022-10-19T20:35:01", + "unit": "mg\/min·dL", + "value": 3.2841929027812276 + }, + { + "startDate": "2022-10-19T20:35:01", + "endDate": "2022-10-19T20:40:02", + "unit": "mg\/min·dL", + "value": 3.2232532666739027 + }, + { + "startDate": "2022-10-19T20:40:02", + "endDate": "2022-10-19T20:45:01", + "unit": "mg\/min·dL", + "value": 1.9676584040493557 + }, + { + "startDate": "2022-10-19T20:45:01", + "endDate": "2022-10-19T20:50:01", + "unit": "mg\/min·dL", + "value": 2.299368111515345 + }, + { + "startDate": "2022-10-19T20:50:01", + "endDate": "2022-10-19T20:55:02", + "unit": "mg\/min·dL", + "value": 4.034026725887583 + }, + { + "startDate": "2022-10-19T20:55:02", + "endDate": "2022-10-19T21:00:01", + "unit": "mg\/min·dL", + "value": 2.3799054220542706 + }, + { + "startDate": "2022-10-19T21:00:01", + "endDate": "2022-10-19T21:05:01", + "unit": "mg\/min·dL", + "value": 2.3167018765165173 + }, + { + "startDate": "2022-10-19T21:05:01", + "endDate": "2022-10-19T21:10:01", + "unit": "mg\/min·dL", + "value": 1.8556929650115728 + }, + { + "startDate": "2022-10-19T21:10:01", + "endDate": "2022-10-19T21:15:01", + "unit": "mg\/min·dL", + "value": 3.616009538834508 + }, + { + "startDate": "2022-10-19T21:15:01", + "endDate": "2022-10-19T21:20:01", + "unit": "mg\/min·dL", + "value": 2.1720784828835176 + }, + { + "startDate": "2022-10-19T21:20:01", + "endDate": "2022-10-19T21:25:01", + "unit": "mg\/min·dL", + "value": 1.3496070250759875 + }, + { + "startDate": "2022-10-19T21:25:01", + "endDate": "2022-10-19T21:30:01", + "unit": "mg\/min·dL", + "value": 0.9670380126484306 + }, + { + "startDate": "2022-10-19T21:30:01", + "endDate": "2022-10-19T21:35:01", + "unit": "mg\/min·dL", + "value": -0.16076892735619985 + }, + { + "startDate": "2022-10-19T21:35:01", + "endDate": "2022-10-19T21:40:01", + "unit": "mg\/min·dL", + "value": -0.9050254515029905 + } +] diff --git a/LoopTests/Fixtures/meal_detection/uam_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/uam_counteraction_effect.json new file mode 100644 index 0000000000..64b5f498a4 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/uam_counteraction_effect.json @@ -0,0 +1,122 @@ +[ + { + "startDate": "2022-10-17T21:08:45", + "endDate": "2022-10-17T21:13:45", + "unit": "mg\/min·dL", + "value": 0.8019261605973419 + }, + { + "startDate": "2022-10-17T21:13:45", + "endDate": "2022-10-17T21:18:45", + "unit": "mg\/min·dL", + "value": -0.5784692917036025 + }, + { + "startDate": "2022-10-17T21:18:45", + "endDate": "2022-10-17T21:23:45", + "unit": "mg\/min·dL", + "value": 0.1655312713905142 + }, + { + "startDate": "2022-10-17T21:23:45", + "endDate": "2022-10-17T21:28:45", + "unit": "mg\/min·dL", + "value": 1.7504524257718737 + }, + { + "startDate": "2022-10-17T21:28:45", + "endDate": "2022-10-17T21:33:45", + "unit": "mg\/min·dL", + "value": -0.0922608525680516 + }, + { + "startDate": "2022-10-17T21:33:45", + "endDate": "2022-10-17T21:38:45", + "unit": "mg\/min·dL", + "value": -0.3598634421205699 + }, + { + "startDate": "2022-10-17T21:38:45", + "endDate": "2022-10-17T21:54:19", + "unit": "mg\/min·dL", + "value": 0.8027570693463704 + }, + { + "startDate": "2022-10-17T21:54:19", + "endDate": "2022-10-17T22:12:04", + "unit": "mg\/min·dL", + "value": 1.4477572210086778 + }, + { + "startDate": "2022-10-17T22:12:04", + "endDate": "2022-10-17T22:18:45", + "unit": "mg\/min·dL", + "value": 1.4975396644708778 + }, + { + "startDate": "2022-10-17T22:18:45", + "endDate": "2022-10-17T22:28:45", + "unit": "mg\/min·dL", + "value": 1.5245986218389043 + }, + { + "startDate": "2022-10-17T22:28:45", + "endDate": "2022-10-17T22:33:45", + "unit": "mg\/min·dL", + "value": 2.3929007506455973 + }, + { + "startDate": "2022-10-17T22:33:45", + "endDate": "2022-10-17T22:38:45", + "unit": "mg\/min·dL", + "value": 2.2706182546903664 + }, + { + "startDate": "2022-10-17T22:38:45", + "endDate": "2022-10-17T22:43:45", + "unit": "mg\/min·dL", + "value": 1.7258552314883575 + }, + { + "startDate": "2022-10-17T22:43:45", + "endDate": "2022-10-17T22:48:45", + "unit": "mg\/min·dL", + "value": 2.760986190856003 + }, + { + "startDate": "2022-10-17T22:48:45", + "endDate": "2022-10-17T22:53:45", + "unit": "mg\/min·dL", + "value": 2.578233617155381 + }, + { + "startDate": "2022-10-17T22:53:45", + "endDate": "2022-10-17T22:58:45", + "unit": "mg\/min·dL", + "value": 0.7795720392241906 + }, + { + "startDate": "2022-10-17T22:58:45", + "endDate": "2022-10-17T23:03:45", + "unit": "mg\/min·dL", + "value": 2.766911269858242 + }, + { + "startDate": "2022-10-17T23:03:45", + "endDate": "2022-10-17T23:18:42", + "unit": "mg\/min·dL", + "value": 1.9079807396410984 + }, + { + "startDate": "2022-10-17T23:18:42", + "endDate": "2022-10-17T23:23:45", + "unit": "mg\/min·dL", + "value": 2.5862132855399116 + }, + { + "startDate": "2022-10-17T23:23:45", + "endDate": "2022-10-17T23:28:45", + "unit": "mg\/min·dL", + "value": 1.346722448222869 + } +] From b719b123c8a9ad0939e874cf26a208f6c7bd150b Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 29 Nov 2022 19:45:00 -0800 Subject: [PATCH 37/51] Remove old TODO --- Loop/Managers/LoopDataManager.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index fe22ee0c9d..2ef53d6f2d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -274,8 +274,7 @@ final class LoopDataManager { invalidateCachedEffects = true analyticsServicesManager.didChangeInsulinModel() } - - // ANNA TODO: add test for this + if newValue.maximumBolus != oldValue.maximumBolus { mealDetectionManager.maximumBolus = newValue.maximumBolus } From a5dc1f2e88dd0716e671f46b3669a8bbf8e9d023 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Sat, 7 Jan 2023 17:10:27 -0800 Subject: [PATCH 38/51] Revert change to Loop signing team --- Loop.xcodeproj/project.pbxproj | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 365f6a9ed2..a1a014f4ba 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -4601,7 +4601,7 @@ CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; CURRENT_PROJECT_VERSION = 101; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GENERATE_INFOPLIST_FILE = NO; @@ -4647,7 +4647,7 @@ CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; CURRENT_PROJECT_VERSION = 101; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = StatusWidget/Info.plist; @@ -4902,7 +4902,7 @@ CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4927,7 +4927,7 @@ CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -4949,7 +4949,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -4976,7 +4976,7 @@ CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5003,7 +5003,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -5027,7 +5027,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -5247,7 +5247,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5269,7 +5269,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5331,7 +5331,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; @@ -5354,7 +5354,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; - DEVELOPMENT_TEAM = T93DJEUU9W; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; From e91ae0dd0d9446d60631bc515003c1b9e68df016 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Sat, 7 Jan 2023 18:53:00 -0800 Subject: [PATCH 39/51] Fix merge issues --- Loop/Views/AlertManagementView.swift | 2 +- Loop/Views/SettingsView.swift | 4 +- LoopTests/Managers/DoseEnactorTests.swift | 5 - .../Managers/LoopDataManagerDosingTests.swift | 4 +- LoopTests/Managers/LoopDataManagerTests.swift | 437 ------------------ 5 files changed, 5 insertions(+), 447 deletions(-) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index de7cdc6be8..c3abe73b77 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -72,7 +72,7 @@ struct AlertManagementView: View { private var alertPermissionsSection: some View { Section(footer: DescriptiveText(label: String(format: NSLocalizedString("Notifications give you important %1$@ app information without requiring you to open the app.", comment: "Alert Permissions descriptive text (1: app name)"), appName))) { NavigationLink(destination: - NotificationsCriticalAlertPermissionsView(mode: .flow, checker: checker)) + NotificationsCriticalAlertPermissionsView(mode: .flow, viewModel: AlertPermissionsViewModel(checker: checker) )) { HStack { Text(NSLocalizedString("Alert Permissions", comment: "Alert Permissions button text")) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 3347dc8d60..20583a400f 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -137,8 +137,8 @@ extension SettingsView { { HStack { Text(NSLocalizedString("Alert Management", comment: "Alert Permissions button text")) - if viewModel.alertPermissionsViewModel.alertPermissionsChecker.showWarning || - viewModel.alertPermissionsViewModel.alertPermissionsChecker.notificationCenterSettings.scheduledDeliveryEnabled { + if viewModel.alertPermissionsViewModel.checker.showWarning || + viewModel.alertPermissionsViewModel.checker.notificationCenterSettings.scheduledDeliveryEnabled { Spacer() Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.critical) diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 67b571646d..72359793e6 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -59,7 +59,6 @@ class MockPumpManager: PumpManager { var pumpRecordsBasalProfileStartEvents: Bool = false var pumpReservoirCapacity: Double = 50 - var deliveryUnitsPerMinute = 1.5 var lastSync: Date? @@ -122,10 +121,6 @@ class MockPumpManager: PumpManager { .minutes(units / deliveryUnitsPerMinute) } - public func estimatedDuration(toBolus units: Double) -> TimeInterval { - .minutes(units / deliveryUnitsPerMinute) - } - var managerIdentifier: String = "MockPumpManager" var localizedTitle: String = "MockPumpManager" diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index 2c2c1b4414..02f0ca474e 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -27,8 +27,8 @@ class MockDelegate: LoopDataManagerDelegate { self.recommendation = automaticDose.recommendation completion(error) } - func loopDataManager(_ manager: LoopDataManager, roundBasalRate unitsPerHour: Double) -> Double { unitsPerHour } - func loopDataManager(_ manager: LoopDataManager, roundBolusVolume units: Double) -> Double { units } + func roundBasalRate(unitsPerHour: Double) -> Double { unitsPerHour } + func roundBolusVolume(units: Double) -> Double { units } var pumpManagerStatus: PumpManagerStatus? var cgmManagerStatus: CGMManagerStatus? var pumpStatusHighlight: DeviceStatusHighlight? diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 42819145b6..a2b5af1d33 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -131,443 +131,6 @@ class LoopDataManagerTests: XCTestCase { override func tearDownWithError() throws { loopDataManager = nil } - - // MARK: Functions to load fixtures - func loadGlucoseEffect(_ name: String) -> [GlucoseEffect] { - let fixture: [JSONDictionary] = loadFixture(name) - let dateFormatter = ISO8601DateFormatter.localTimeDate() - - return fixture.map { - return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) - } - } - - // MARK: Tests - func testFlatAndStable() { - setUp(for: .flatAndStable) - let predictedGlucoseOutput = loadGlucoseEffect("flat_and_stable_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedDose: AutomaticDoseRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedDose = state.recommendedAutomaticDose?.recommendation - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - let recommendedTempBasal = recommendedDose?.basalAdjustment - - XCTAssertEqual(1.40, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testHighAndStable() { - setUp(for: .highAndStable) - let predictedGlucoseOutput = loadGlucoseEffect("high_and_stable_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(4.63, recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testHighAndFalling() { - setUp(for: .highAndFalling) - let predictedGlucoseOutput = loadGlucoseEffect("high_and_falling_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedTempBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testHighAndRisingWithCOB() { - setUp(for: .highAndRisingWithCOB) - let predictedGlucoseOutput = loadGlucoseEffect("high_and_rising_with_cob_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedBolus: ManualBolusRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedBolus = try? state.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(1.6, recommendedBolus!.amount, accuracy: defaultAccuracy) - } - - func testLowAndFallingWithCOB() { - setUp(for: .lowAndFallingWithCOB) - let predictedGlucoseOutput = loadGlucoseEffect("low_and_falling_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedTempBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - func testLowWithLowTreatment() { - setUp(for: .lowWithLowTreatment) - let predictedGlucoseOutput = loadGlucoseEffect("low_with_low_treatment_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedTempBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucose - recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) - - for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - - XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) - } - - class MockDelegate: LoopDataManagerDelegate { - var recommendation: AutomaticDoseRecommendation? - var error: LoopError? - func loopDataManager(_ manager: LoopDataManager, didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), completion: @escaping (LoopError?) -> Void) { - self.recommendation = automaticDose.recommendation - completion(error) - } - func roundBasalRate(unitsPerHour: Double) -> Double { unitsPerHour } - func roundBolusVolume(units: Double) -> Double { units } - var pumpManagerStatus: PumpManagerStatus? - var cgmManagerStatus: CGMManagerStatus? - var pumpStatusHighlight: DeviceStatusHighlight? - } - - func waitOnDataQueue(timeout: TimeInterval = 1.0) { - let e = expectation(description: "dataQueue") - loopDataManager.getLoopState { _, _ in - e.fulfill() - } - wait(for: [e], timeout: timeout) - } - - func testValidateMaxTempBasalDoesntCancelTempBasalIfHigher() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), endDate: nil, value: 3.0, unit: .unitsPerHour, deliveredUnits: nil, description: nil, syncIdentifier: nil, scheduledBasalRate: nil) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - // This wait is working around the issue presented by LoopDataManager.init(). It cancels the temp basal if - // `isClosedLoop` is false (which it is from `setUp` above). When that happens, it races with - // `maxTempBasalSavePreflight` below. This ensures only one happens at a time. - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - var error: Error? - let exp = expectation(description: #function) - XCTAssertNil(delegate.recommendation) - loopDataManager.maxTempBasalSavePreflight(unitsPerHour: 5.0) { - error = $0 - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertNil(error) - XCTAssertNil(delegate.recommendation) - XCTAssertTrue(dosingDecisionStore.dosingDecisions.isEmpty) - } - - func testValidateMaxTempBasalCancelsTempBasalIfLower() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), endDate: nil, value: 5.0, unit: .unitsPerHour, deliveredUnits: nil, description: nil, syncIdentifier: nil, scheduledBasalRate: nil) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - // This wait is working around the issue presented by LoopDataManager.init(). It cancels the temp basal if - // `isClosedLoop` is false (which it is from `setUp` above). When that happens, it races with - // `maxTempBasalSavePreflight` below. This ensures only one happens at a time. - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - var error: Error? - let exp = expectation(description: #function) - XCTAssertNil(delegate.recommendation) - loopDataManager.maxTempBasalSavePreflight(unitsPerHour: 3.0) { - error = $0 - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertNil(error) - XCTAssertEqual(delegate.recommendation, AutomaticDoseRecommendation(basalAdjustment: .cancel)) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "maximumBasalRateChanged") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, AutomaticDoseRecommendation(basalAdjustment: .cancel)) - } - - func testChangingMaxBasalUpdatesLoopData() { - setUp(for: .highAndStable) - waitOnDataQueue() - var loopDataUpdated = false - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in - loopDataUpdated = true - exp.fulfill() - } - XCTAssertFalse(loopDataUpdated) - loopDataManager.mutateSettings { $0.maximumBasalRatePerHour = 2.0 } - wait(for: [exp], timeout: 1.0) - XCTAssertTrue(loopDataUpdated) - NotificationCenter.default.removeObserver(observer) - } - - func testOpenLoopCancelsTempBasal() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 1.0, unit: .unitsPerHour) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in - exp.fulfill() - } - automaticDosingStatus.isClosedLoop = false - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "closedLoopDisabled") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - NotificationCenter.default.removeObserver(observer) - } - - func testReceivedUnreliableCGMReadingCancelsTempBasal() { - let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 5.0, unit: .unitsPerHour) - setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in - exp.fulfill() - } - loopDataManager.receivedUnreliableCGMReading() - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "unreliableCGMData") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - NotificationCenter.default.removeObserver(observer) - } - - func testLoopEnactsTempBasalWithoutManualBolusRecommendation() { - setUp(for: .highAndStable) - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopCompleted, object: nil, queue: nil) { _ in - exp.fulfill() - } - loopDataManager.loop() - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 4.577747629410191, duration: .minutes(30))) - XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - if dosingDecisionStore.dosingDecisions.count == 1 { - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) - } - NotificationCenter.default.removeObserver(observer) - } - - func testLoopRecommendsTempBasalWithoutEnactingIfOpenLoop() { - setUp(for: .highAndStable) - automaticDosingStatus.isClosedLoop = false - waitOnDataQueue() - let delegate = MockDelegate() - loopDataManager.delegate = delegate - let exp = expectation(description: #function) - let observer = NotificationCenter.default.addObserver(forName: .LoopCompleted, object: nil, queue: nil) { _ in - exp.fulfill() - } - loopDataManager.loop() - wait(for: [exp], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 4.577747629410191, duration: .minutes(30))) - XCTAssertNil(delegate.recommendation) - XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") - XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) - XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) - NotificationCenter.default.removeObserver(observer) - } - - func testLoopGetStateRecommendsManualBolus() { - setUp(for: .highAndStable) - let exp = expectation(description: #function) - var recommendedBolus: ManualBolusRecommendation? - loopDataManager.getLoopState { (_, loopState) in - recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) - exp.fulfill() - } - wait(for: [exp], timeout: 100000.0) - XCTAssertEqual(recommendedBolus!.amount, 1.82, accuracy: 0.01) - } - - func testLoopGetStateRecommendsManualBolusWithMomentum() { - setUp(for: .highAndRisingWithCOB) - let exp = expectation(description: #function) - var recommendedBolus: ManualBolusRecommendation? - loopDataManager.getLoopState { (_, loopState) in - recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertEqual(recommendedBolus!.amount, 1.62, accuracy: 0.01) - } - - func testLoopGetStateRecommendsManualBolusWithoutMomentum() { - setUp(for: .highAndRisingWithCOB) - let exp = expectation(description: #function) - var recommendedBolus: ManualBolusRecommendation? - loopDataManager.getLoopState { (_, loopState) in - recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - XCTAssertEqual(recommendedBolus!.amount, 1.52, accuracy: 0.01) - } - - func testIsClosedLoopAvoidsTriggeringTempBasalCancelOnCreation() { - let settings = LoopSettings( - dosingEnabled: false, - glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, - maximumBasalRatePerHour: maxBasalRate, - maximumBolus: maxBolus, - suspendThreshold: suspendThreshold - ) - - let doseStore = MockDoseStore() - let glucoseStore = MockGlucoseStore() - let carbStore = MockCarbStore() - - let currentDate = Date() - - dosingDecisionStore = MockDosingDecisionStore() - automaticDosingStatus = AutomaticDosingStatus(isClosedLoop: false, isClosedLoopAllowed: true) - let existingTempBasal = DoseEntry( - type: .tempBasal, - startDate: currentDate.addingTimeInterval(-.minutes(2)), - endDate: currentDate.addingTimeInterval(.minutes(28)), - value: 1.0, - unit: .unitsPerHour, - deliveredUnits: nil, - description: "Mock Temp Basal", - syncIdentifier: "asdf", - scheduledBasalRate: nil, - insulinType: .novolog, - automatic: true, - manuallyEntered: false, - isMutable: true) - loopDataManager = LoopDataManager( - lastLoopCompleted: currentDate.addingTimeInterval(-.minutes(5)), - basalDeliveryState: .tempBasal(existingTempBasal), - settings: settings, - overrideHistory: TemporaryScheduleOverrideHistory(), - analyticsServicesManager: AnalyticsServicesManager(), - localCacheDuration: .days(1), - doseStore: doseStore, - glucoseStore: glucoseStore, - carbStore: carbStore, - dosingDecisionStore: dosingDecisionStore, - latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), - now: { currentDate }, - pumpInsulinType: .novolog, - automaticDosingStatus: automaticDosingStatus, - trustedTimeOffset: { 0 } - ) - let mockDelegate = MockDelegate() - loopDataManager.delegate = mockDelegate - - // Dose enacting happens asynchronously, as does receiving isClosedLoop signals - waitOnMain(timeout: 5) - XCTAssertNil(mockDelegate.recommendation) - } - } extension LoopDataManagerTests { From 352a1481aded395765933aa3fabc57cc5ac420b8 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Tue, 10 Jan 2023 08:50:19 -0800 Subject: [PATCH 40/51] Make tests runnable --- LoopTests/Managers/MealDetectionManagerTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 191001c843..32acb42538 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -161,7 +161,7 @@ extension UAMTestType { } } -class MealDetectionManagerTests { +class MealDetectionManagerTests: XCTestCase { let dateFormatter = ISO8601DateFormatter.localTimeDate() let pumpManager = MockPumpManager() @@ -232,7 +232,7 @@ class MealDetectionManagerTests { } } - func tearDown() { + override func tearDown() { mealDetectionManager.lastUAMNotification = nil mealDetectionManager = nil UserDefaults.standard.unannouncedMealNotificationsEnabled = false @@ -392,7 +392,7 @@ class MealDetectionManagerTests { mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 100) + XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 75) } func testUnannouncedMealNoPendingBolus() { From fa2bbe24fb0f6f692168072093b1e3fc9ceb9399 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 11 Jan 2023 10:07:01 -0800 Subject: [PATCH 41/51] Lower meal carb threshold to 15 g to reduce false-negatives --- Loop/Managers/Missed Meal Detection/UAMSettings.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/Missed Meal Detection/UAMSettings.swift b/Loop/Managers/Missed Meal Detection/UAMSettings.swift index 6998146571..0aff5bf6c2 100644 --- a/Loop/Managers/Missed Meal Detection/UAMSettings.swift +++ b/Loop/Managers/Missed Meal Detection/UAMSettings.swift @@ -10,7 +10,7 @@ import Foundation public struct UAMSettings { /// Minimum grams of unannounced carbs that must be detected for a notification to be delivered - public static let minCarbThreshold: Double = 20 // grams + public static let minCarbThreshold: Double = 15 // grams /// Maximum grams of unannounced carbs that the algorithm will search for public static let maxCarbThreshold: Double = 80 // grams /// Minimum threshold for glucose rise over the detection window From e3a5822662848f99acbf57932fa3002f40041325 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Wed, 18 Jan 2023 16:27:46 -0800 Subject: [PATCH 42/51] Fix carb entry controller merge issues --- .../CarbEntryViewController.swift | 68 ++++++------------- 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/Loop/View Controllers/CarbEntryViewController.swift b/Loop/View Controllers/CarbEntryViewController.swift index 08355a2c83..0c323f09d8 100644 --- a/Loop/View Controllers/CarbEntryViewController.swift +++ b/Loop/View Controllers/CarbEntryViewController.swift @@ -108,13 +108,13 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable private var shouldBeginEditingFoodType = false - private var shouldDisplayAccurateCarbEntryWarning = false { + private var shouldDisplayUnannouncedMealWarning = false { didSet { - if shouldDisplayAccurateCarbEntryWarning != oldValue { + if shouldDisplayUnannouncedMealWarning != oldValue { if shouldDisplayOverrideEnabledWarning { - self.displayWarningRow(rowType: WarningRow.carbEntry, isAddingRow: shouldDisplayAccurateCarbEntryWarning) + self.displayWarningRow(rowType: WarningRow.unannouncedMeal, isAddingRow: shouldDisplayUnannouncedMealWarning) } else { - self.shouldDisplayWarning = shouldDisplayAccurateCarbEntryWarning || shouldDisplayOverrideEnabledWarning + self.shouldDisplayWarning = shouldDisplayUnannouncedMealWarning || shouldDisplayOverrideEnabledWarning } } } @@ -123,10 +123,10 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable private var shouldDisplayOverrideEnabledWarning = false { didSet { if shouldDisplayOverrideEnabledWarning != oldValue { - if shouldDisplayAccurateCarbEntryWarning { + if shouldDisplayUnannouncedMealWarning { self.displayWarningRow(rowType: WarningRow.override, isAddingRow: shouldDisplayOverrideEnabledWarning) } else { - self.shouldDisplayWarning = shouldDisplayOverrideEnabledWarning || shouldDisplayAccurateCarbEntryWarning + self.shouldDisplayWarning = shouldDisplayOverrideEnabledWarning || shouldDisplayUnannouncedMealWarning } } } @@ -212,14 +212,12 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable cell.textField.becomeFirstResponder() } - // check if either warning should be displayed - updateDisplayAccurateCarbEntryWarning() + // check if warning should be displayed updateDisplayOverrideEnabledWarning() // monitor loop updates notificationObservers += [ NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] _ in - self?.updateDisplayAccurateCarbEntryWarning() self?.updateDisplayOverrideEnabledWarning() } ] @@ -241,34 +239,6 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable private var foodKeyboard: EmojiInputController! - private func updateDisplayAccurateCarbEntryWarning() { - let now = Date() - let startDate = now.addingTimeInterval(-LoopConstants.missedMealWarningGlucoseRecencyWindow) - - deviceManager.glucoseStore.getGlucoseSamples(start: startDate, end: nil) { [weak self] (result) -> Void in - DispatchQueue.main.async { - switch result { - case .failure: - self?.shouldDisplayAccurateCarbEntryWarning = false - case .success(let samples): - let filteredSamples = samples.filterDateRange(startDate, now) - guard let startSample = filteredSamples.first, let endSample = filteredSamples.last else { - self?.shouldDisplayAccurateCarbEntryWarning = false - return - } - let duration = endSample.startDate.timeIntervalSince(startSample.startDate) - guard duration >= LoopConstants.missedMealWarningVelocitySampleMinDuration else { - self?.shouldDisplayAccurateCarbEntryWarning = false - return - } - let delta = endSample.quantity.doubleValue(for: .milligramsPerDeciliter) - startSample.quantity.doubleValue(for: .milligramsPerDeciliter) - let velocity = delta / duration.minutes // Unit = mg/dL/m - self?.shouldDisplayAccurateCarbEntryWarning = velocity > LoopConstants.missedMealWarningGlucoseRiseThreshold - } - } - } - } - private func updateDisplayOverrideEnabledWarning() { DispatchQueue.main.async { if let managerSettings = self.deviceManager?.settings { @@ -297,8 +267,8 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable if shouldDisplayWarning { tableView.beginUpdates() - // If the accurate carb entry warning is shown, use the positional index of the given row type. - let rowIndex = shouldDisplayAccurateCarbEntryWarning ? rowType.rawValue : 0 + // If the unannounced meal warning is shown, use the positional index of the given row type. + let rowIndex = shouldDisplayUnannouncedMealWarning ? rowType.rawValue : 0 if isAddingRow { tableView.insertRows(at: [IndexPath(row: rowIndex, section: Sections.warning.rawValue)], with: UITableView.RowAnimation.top) @@ -327,9 +297,9 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable return displayWarningSection ? indexPath.section : indexPath.section + 1 } - static func numberOfRows(for section: Int, displayCarbEntryWarning: Bool, displayOverrideWarning: Bool) -> Int { - if section == Sections.warning.rawValue && (displayCarbEntryWarning || displayOverrideWarning) { - return displayCarbEntryWarning && displayOverrideWarning ? WarningRow.allCases.count : WarningRow.allCases.count - 1 + static func numberOfRows(for section: Int, displayUnannouncedMealWarning: Bool, displayOverrideWarning: Bool) -> Int { + if section == Sections.warning.rawValue && (displayUnannouncedMealWarning || displayOverrideWarning) { + return displayUnannouncedMealWarning && displayOverrideWarning ? WarningRow.allCases.count : WarningRow.allCases.count - 1 } return DetailsRow.allCases.count @@ -364,7 +334,7 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable } fileprivate enum WarningRow: Int, CaseIterable { - case carbEntry + case unannouncedMeal case override } @@ -373,15 +343,15 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Sections.numberOfRows(for: section, displayCarbEntryWarning: shouldDisplayAccurateCarbEntryWarning, displayOverrideWarning: shouldDisplayOverrideEnabledWarning) + return Sections.numberOfRows(for: section, displayUnannouncedMealWarning: shouldDisplayUnannouncedMealWarning, displayOverrideWarning: shouldDisplayOverrideEnabledWarning) } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch Sections(rawValue: Sections.section(for: indexPath, displayWarningSection: shouldDisplayWarning))! { case .warning: let cell: UITableViewCell - // if no accurate carb entry warning should be shown OR if the given indexPath is for the override warning row, return the override warning cell. - if !shouldDisplayAccurateCarbEntryWarning || WarningRow(rawValue: indexPath.row)! == .override { + // if no unannounced meal warning should be shown OR if the given indexPath is for the override warning row, return the override warning cell. + if !shouldDisplayUnannouncedMealWarning || WarningRow(rawValue: indexPath.row)! == .override { if let existingCell = tableView.dequeueReusableCell(withIdentifier: "CarbEntryOverrideEnabledWarningCell") { cell = existingCell } else { @@ -405,7 +375,7 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable cell.imageView?.image = UIImage(systemName: "exclamationmark.triangle.fill") cell.imageView?.tintColor = .destructive cell.textLabel?.numberOfLines = 0 - cell.textLabel?.text = NSLocalizedString("Your glucose is rapidly rising. Check that any carbs you've eaten were logged. If you logged carbs, check that the time you entered lines up with when you started eating.", comment: "Warning to ensure the carb entry is accurate") + cell.textLabel?.text = NSLocalizedString("Loop has detected an unannounced meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten.", comment: "Warning displayed when user is adding a meal from an unannounced meal notification") cell.textLabel?.font = UIFont.preferredFont(forTextStyle: .caption1) cell.textLabel?.textColor = .secondaryLabel cell.isUserInteractionEnabled = false @@ -562,6 +532,10 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable self.absorptionTime = absorptionTime absorptionTimeWasEdited = true } + + if activity.entryIsUnannouncedMeal { + shouldDisplayUnannouncedMealWarning = true + } } } From 9dfaf22350529edf249e9bc6d1e56079b217d7eb Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Thu, 16 Feb 2023 10:40:35 -0800 Subject: [PATCH 43/51] Unannounced meal / UAM -> missed meal --- Loop.xcodeproj/project.pbxproj | 20 +-- Loop/Managers/LoopAppManager.swift | 12 +- Loop/Managers/LoopDataManager.swift | 2 +- .../MealDetectionManager.swift | 96 +++++----- ...ettings.swift => MissedMealSettings.swift} | 4 +- Loop/Managers/NotificationManager.swift | 12 +- .../CarbEntryViewController.swift | 36 ++-- .../AlertPermissionsViewModel.swift | 14 +- ...icationsCriticalAlertPermissionsView.swift | 8 +- ...ion.swift => MissedMealNotification.swift} | 6 +- LoopCore/NSUserDefaults.swift | 14 +- .../Managers/MealDetectionManagerTests.swift | 166 +++++++++--------- WatchApp Extension/ExtensionDelegate.swift | 10 +- 13 files changed, 200 insertions(+), 200 deletions(-) rename Loop/Managers/Missed Meal Detection/{UAMSettings.swift => MissedMealSettings.swift} (94%) rename LoopCore/{UAMNotification.swift => MissedMealNotification.swift} (71%) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index d49741671f..28e35cc726 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -607,9 +607,9 @@ E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */; }; E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552129358C440076AB04 /* MealDetectionManager.swift */; }; - E9B355292935919E0076AB04 /* UAMSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B35525293590980076AB04 /* UAMSettings.swift */; }; - E9B3552A293591E70076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; - E9B3552B293591E70076AB04 /* UAMNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* UAMNotification.swift */; }; + E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B35525293590980076AB04 /* MissedMealSettings.swift */; }; + E9B3552A293591E70076AB04 /* MissedMealNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* MissedMealNotification.swift */; }; + E9B3552B293591E70076AB04 /* MissedMealNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* MissedMealNotification.swift */; }; E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */; }; E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */; }; E9B35538293706CB0076AB04 /* needs_clamping_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */; }; @@ -1742,9 +1742,9 @@ E9B07FED253BBC7100BAD8F8 /* OverrideIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideIntentHandler.swift; sourceTree = ""; }; E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+LoopIntents.swift"; sourceTree = ""; }; E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentExtensionInfo.swift; sourceTree = ""; }; - E9B3551B292844010076AB04 /* UAMNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UAMNotification.swift; sourceTree = ""; }; + E9B3551B292844010076AB04 /* MissedMealNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedMealNotification.swift; sourceTree = ""; }; E9B3552129358C440076AB04 /* MealDetectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetectionManager.swift; sourceTree = ""; }; - E9B35525293590980076AB04 /* UAMSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UAMSettings.swift; sourceTree = ""; }; + E9B35525293590980076AB04 /* MissedMealSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedMealSettings.swift; sourceTree = ""; }; E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetectionManagerTests.swift; sourceTree = ""; }; E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKHealthStoreMock.swift; sourceTree = ""; }; E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = needs_clamping_counteraction_effect.json; sourceTree = ""; }; @@ -2282,7 +2282,7 @@ 4B60626A287E286000BF8BBB /* Localizable.strings */, E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, C16575742539FD60004AE16E /* LoopCoreConstants.swift */, - E9B3551B292844010076AB04 /* UAMNotification.swift */, + E9B3551B292844010076AB04 /* MissedMealNotification.swift */, C1D0B62F2986D4D90098D215 /* LocalizedString.swift */, ); path = LoopCore; @@ -3016,7 +3016,7 @@ isa = PBXGroup; children = ( E9B3552129358C440076AB04 /* MealDetectionManager.swift */, - E9B35525293590980076AB04 /* UAMSettings.swift */, + E9B35525293590980076AB04 /* MissedMealSettings.swift */, ); path = "Missed Meal Detection"; sourceTree = ""; @@ -3816,7 +3816,7 @@ C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */, A9B996F227238705002DC09C /* DosingDecisionStore.swift in Sources */, 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, - E9B355292935919E0076AB04 /* UAMSettings.swift in Sources */, + E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */, 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, 4372E48B213CB5F00068E043 /* Double.swift in Sources */, @@ -4090,7 +4090,7 @@ A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */, 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, - E9B3552B293591E70076AB04 /* UAMNotification.swift in Sources */, + E9B3552B293591E70076AB04 /* MissedMealNotification.swift in Sources */, 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4143,7 +4143,7 @@ 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */, 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, - E9B3552A293591E70076AB04 /* UAMNotification.swift in Sources */, + E9B3552A293591E70076AB04 /* MissedMealNotification.swift in Sources */, 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 5a93bd148f..77ad150cd3 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -485,7 +485,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { LoopNotificationCategory.remoteBolusFailure.rawValue, LoopNotificationCategory.remoteCarbs.rawValue, LoopNotificationCategory.remoteCarbsFailure.rawValue, - LoopNotificationCategory.unannouncedMeal.rawValue: + LoopNotificationCategory.missedMeal.rawValue: completionHandler([.badge, .sound, .list, .banner]) default: // For all others, banners are not to be displayed while in the foreground @@ -518,7 +518,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { alertManager?.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier)) } case UNNotificationDefaultActionIdentifier: - guard response.notification.request.identifier == LoopNotificationCategory.unannouncedMeal.rawValue else { + guard response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue else { break } @@ -526,15 +526,15 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { let userInfo = response.notification.request.content.userInfo if - let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date, - let carbAmount = userInfo[LoopNotificationUserInfoKey.unannouncedMealCarbAmount.rawValue] as? Double + let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, + let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double { - let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: carbAmount), startDate: mealTime, foodType: nil, absorptionTime: nil) - carbActivity.update(from: unannouncedEntry, isUnannouncedMeal: true) + carbActivity.update(from: missedEntry, isMissedMeal: true) } rootViewController?.restoreUserActivityState(carbActivity) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 1dd0395aca..cc81c46427 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1710,7 +1710,7 @@ extension LoopDataManager { private func enactRecommendedAutomaticDose() -> LoopError? { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - mealDetectionManager.generateUnannouncedMealNotificationIfNeeded( + mealDetectionManager.generateMissedMealNotificationIfNeeded( using: insulinCounteractionEffects, pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, bolusDurationEstimator: { [unowned self] bolusAmount in diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index 0b6092108b..4f596e864f 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -12,9 +12,9 @@ import OSLog import LoopCore import LoopKit -enum UnannouncedMealStatus: Equatable { - case hasUnannouncedMeal(startTime: Date, carbAmount: Double) - case noUnannouncedMeal +enum MissedMealStatus: Equatable { + case hasMissedMeal(startTime: Date, carbAmount: Double) + case noMissedMeal } class MealDetectionManager { @@ -22,22 +22,22 @@ class MealDetectionManager { public var maximumBolus: Double? - /// The last unannounced meal notification that was sent + /// The last missed meal notification that was sent /// Internal for unit testing - var lastUAMNotification: UAMNotification? = UserDefaults.standard.lastUAMNotification { + var lastMissedMealNotification: MissedMealNotification? = UserDefaults.standard.lastMissedMealNotification { didSet { - UserDefaults.standard.lastUAMNotification = lastUAMNotification + UserDefaults.standard.lastMissedMealNotification = lastMissedMealNotification } } private var carbStore: CarbStoreProtocol - /// Debug info for UAM - /// Timeline from the most recent check for unannounced meals - private var lastEvaluatedUamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + /// Debug info for missed meal detection + /// Timeline from the most recent check for missed meals + private var lastEvaluatedMissedMealTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] - /// Timeline from the most recent detection of an unannounced meal - private var lastDetectedUamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + /// Timeline from the most recent detection of an missed meal + private var lastDetectedMissedMealTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] /// Allows for controlling uses of the system date in unit testing internal var test_currentDate: Date? @@ -62,11 +62,11 @@ class MealDetectionManager { } // MARK: Meal Detection - func hasUnannouncedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (UnannouncedMealStatus) -> Void) { + func hasMissedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (MissedMealStatus) -> Void) { let delta = TimeInterval(minutes: 5) - let intervalStart = currentDate(timeIntervalSinceNow: -UAMSettings.maxRecency) - let intervalEnd = currentDate(timeIntervalSinceNow: -UAMSettings.minRecency) + let intervalStart = currentDate(timeIntervalSinceNow: -MissedMealSettings.maxRecency) + let intervalEnd = currentDate(timeIntervalSinceNow: -MissedMealSettings.minRecency) let now = self.currentDate carbStore.getGlucoseEffects(start: intervalStart, end: now, effectVelocities: insulinCounteractionEffects) {[weak self] result in @@ -78,7 +78,7 @@ class MealDetectionManager { self?.log.error("Failed to fetch glucose effects to check for missed meal: %{public}@", String(describing: error)) } - completion(.noUnannouncedMeal) + completion(.noMissedMeal) return } @@ -129,41 +129,41 @@ class MealDetectionManager { delta: delta) .reversed() - /// Dates the algorithm is allowed to check for the presence of a UAM + /// Dates the algorithm is allowed to check for the presence of a missed meal let dateSearchRange = Set(LoopMath.simulationDateRange(from: intervalStart, to: intervalEnd, delta: delta)) /// Timeline used for debug purposes - var uamTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + var missedMealTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] for pastTime in summationRange { guard let unexpectedEffect = effectValueCache[pastTime], !carbEntries.contains(where: { $0.startDate >= pastTime }) else { - uamTimeline.append((pastTime, nil, nil, nil)) + missedMealTimeline.append((pastTime, nil, nil, nil)) continue } unexpectedDeviation += unexpectedEffect guard dateSearchRange.contains(pastTime) else { - /// This time is too recent to check for a UAM - uamTimeline.append((pastTime, unexpectedDeviation, nil, nil)) + /// This time is too recent to check for a missed meal + missedMealTimeline.append((pastTime, unexpectedDeviation, nil, nil)) continue } - /// Find the threshold based on a minimum of `unannouncedMealGlucoseRiseThreshold` of change per minute + /// Find the threshold based on a minimum of `missedMealGlucoseRiseThreshold` of change per minute let minutesAgo = now.timeIntervalSince(pastTime).minutes - let deviationChangeThreshold = UAMSettings.glucoseRiseThreshold * minutesAgo + let deviationChangeThreshold = MissedMealSettings.glucoseRiseThreshold * minutesAgo /// Find the total effect we'd expect to see for a meal with `carbThreshold`-worth of carbs that started at `pastTime` - guard let modeledMealEffectThreshold = self.effectThreshold(mealStart: pastTime, carbsInGrams: UAMSettings.minCarbThreshold) else { + guard let modeledMealEffectThreshold = self.effectThreshold(mealStart: pastTime, carbsInGrams: MissedMealSettings.minCarbThreshold) else { continue } - uamTimeline.append((pastTime, unexpectedDeviation, modeledMealEffectThreshold, deviationChangeThreshold)) + missedMealTimeline.append((pastTime, unexpectedDeviation, modeledMealEffectThreshold, deviationChangeThreshold)) /// Use the higher of the 2 thresholds to ensure noisy CGM data doesn't cause false-positives for more recent times let effectThreshold = max(deviationChangeThreshold, modeledMealEffectThreshold) @@ -173,18 +173,18 @@ class MealDetectionManager { } } - self.lastEvaluatedUamTimeline = uamTimeline.reversed() + self.lastEvaluatedMissedMealTimeline = missedMealTimeline.reversed() - let mealTimeTooRecent = now.timeIntervalSince(mealTime) < UAMSettings.minRecency + let mealTimeTooRecent = now.timeIntervalSince(mealTime) < MissedMealSettings.minRecency guard !mealTimeTooRecent else { - completion(.noUnannouncedMeal) + completion(.noMissedMeal) return } - self.lastDetectedUamTimeline = uamTimeline.reversed() + self.lastDetectedMissedMealTimeline = missedMealTimeline.reversed() let carbAmount = self.determineCarbs(mealtime: mealTime, unexpectedDeviation: unexpectedDeviation) - completion(.hasUnannouncedMeal(startTime: mealTime, carbAmount: carbAmount ?? UAMSettings.minCarbThreshold)) + completion(.hasMissedMeal(startTime: mealTime, carbAmount: carbAmount ?? MissedMealSettings.minCarbThreshold)) } } @@ -193,7 +193,7 @@ class MealDetectionManager { /// Search `carbAmount`s from `minCarbThreshold` to `maxCarbThreshold` in 5-gram increments, /// seeing if the deviation is at least `carbAmount` of carbs - for carbAmount in stride(from: UAMSettings.minCarbThreshold, through: UAMSettings.maxCarbThreshold, by: 5) { + for carbAmount in stride(from: MissedMealSettings.minCarbThreshold, through: MissedMealSettings.maxCarbThreshold, by: 5) { if let modeledCarbEffect = effectThreshold(mealStart: mealtime, carbsInGrams: carbAmount), unexpectedDeviation >= modeledCarbEffect @@ -228,30 +228,30 @@ class MealDetectionManager { } // MARK: Notification Generation - func generateUnannouncedMealNotificationIfNeeded( + func generateMissedMealNotificationIfNeeded( using insulinCounteractionEffects: [GlucoseEffectVelocity], pendingAutobolusUnits: Double? = nil, bolusDurationEstimator: @escaping (Double) -> TimeInterval? ) { - hasUnannouncedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in + hasMissedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits, bolusDurationEstimator: bolusDurationEstimator) } } // Internal for unit testing - func manageMealNotifications(for status: UnannouncedMealStatus, pendingAutobolusUnits: Double? = nil, bolusDurationEstimator getBolusDuration: (Double) -> TimeInterval?) { + func manageMealNotifications(for status: MissedMealStatus, pendingAutobolusUnits: Double? = nil, bolusDurationEstimator getBolusDuration: (Double) -> TimeInterval?) { // We should remove expired notifications regardless of whether or not there was a meal NotificationManager.removeExpiredMealNotifications() // Figure out if we should deliver a notification let now = self.currentDate - let notificationTimeTooRecent = now.timeIntervalSince(lastUAMNotification?.deliveryTime ?? .distantPast) < (UAMSettings.maxRecency - UAMSettings.minRecency) + let notificationTimeTooRecent = now.timeIntervalSince(lastMissedMealNotification?.deliveryTime ?? .distantPast) < (MissedMealSettings.maxRecency - MissedMealSettings.minRecency) guard - case .hasUnannouncedMeal(let startTime, let carbAmount) = status, + case .hasMissedMeal(let startTime, let carbAmount) = status, !notificationTimeTooRecent, - UserDefaults.standard.unannouncedMealNotificationsEnabled + UserDefaults.standard.missedMealNotificationsEnabled else { // No notification needed! return @@ -268,20 +268,20 @@ class MealDetectionManager { log.debug("Delivering a missed meal notification") - /// Coordinate the unannounced meal notification time with any pending autoboluses that `update` may have started + /// Coordinate the missed meal notification time with any pending autoboluses that `update` may have started /// so that the user doesn't have to cancel the current autobolus to bolus in response to the missed meal notification if let pendingAutobolusUnits, pendingAutobolusUnits > 0, let estimatedBolusDuration = getBolusDuration(pendingAutobolusUnits), - estimatedBolusDuration < UAMSettings.maxNotificationDelay + estimatedBolusDuration < MissedMealSettings.maxNotificationDelay { - NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) - lastUAMNotification = UAMNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), + NotificationManager.sendMissedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) + lastMissedMealNotification = MissedMealNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), carbAmount: clampedCarbAmount) } else { - NotificationManager.sendUnannouncedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount) - lastUAMNotification = UAMNotification(deliveryTime: now, carbAmount: clampedCarbAmount) + NotificationManager.sendMissedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount) + lastMissedMealNotification = MissedMealNotification(deliveryTime: now, carbAmount: clampedCarbAmount) } } @@ -294,14 +294,14 @@ class MealDetectionManager { let report = [ "## MealDetectionManager", "", - "* lastUnannouncedMealNotificationTime: \(String(describing: lastUAMNotification?.deliveryTime))", - "* lastUnannouncedMealCarbEstimate: \(String(describing: lastUAMNotification?.carbAmount))", - "* lastEvaluatedUnannouncedMealTimeline:", - lastEvaluatedUamTimeline.reduce(into: "", { (entries, entry) in + "* lastMissedMealNotificationTime: \(String(describing: lastMissedMealNotification?.deliveryTime))", + "* lastMissedMealCarbEstimate: \(String(describing: lastMissedMealNotification?.carbAmount))", + "* lastEvaluatedMissedMealTimeline:", + lastEvaluatedMissedMealTimeline.reduce(into: "", { (entries, entry) in entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") }), - "* lastDetectedUnannouncedMealTimeline:", - lastDetectedUamTimeline.reduce(into: "", { (entries, entry) in + "* lastDetectedMissedMealTimeline:", + lastDetectedMissedMealTimeline.reduce(into: "", { (entries, entry) in entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") }) ] diff --git a/Loop/Managers/Missed Meal Detection/UAMSettings.swift b/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift similarity index 94% rename from Loop/Managers/Missed Meal Detection/UAMSettings.swift rename to Loop/Managers/Missed Meal Detection/MissedMealSettings.swift index 0aff5bf6c2..00ad6d5249 100644 --- a/Loop/Managers/Missed Meal Detection/UAMSettings.swift +++ b/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift @@ -1,5 +1,5 @@ // -// UAMSettings.swift +// MissedMealSettings.swift // Loop // // Created by Anna Quinlan on 11/28/22. @@ -8,7 +8,7 @@ import Foundation -public struct UAMSettings { +public struct MissedMealSettings { /// Minimum grams of unannounced carbs that must be detected for a notification to be delivered public static let minCarbThreshold: Double = 15 // grams /// Maximum grams of unannounced carbs that the algorithm will search for diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 1c3ba6114b..0b23364f85 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -219,18 +219,18 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - static func sendUnannouncedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { + static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { let notification = UNMutableNotificationContent() /// Notifications should expire after the missed meal is no longer relevant let expirationDate = mealStart.addingTimeInterval(LoopCoreConstants.defaultCarbAbsorptionTimes.slow) - notification.title = String(format: NSLocalizedString("Possible Unannounced Meal", comment: "The notification title for a meal that was possibly not logged in Loop.")) + notification.title = String(format: NSLocalizedString("Possible Missed Meal", comment: "The notification title for a meal that was possibly not logged in Loop.")) notification.body = String(format: NSLocalizedString("It looks like you may not have logged a meal you ate. Tap to log it now.", comment: "The notification description for a meal that was possibly not logged in Loop.")) notification.sound = .default notification.userInfo = [ - LoopNotificationUserInfoKey.unannouncedMealTime.rawValue: mealStart, - LoopNotificationUserInfoKey.unannouncedMealCarbAmount.rawValue: amountInGrams, + LoopNotificationUserInfoKey.missedMealTime.rawValue: mealStart, + LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue: amountInGrams, LoopNotificationUserInfoKey.expirationDate.rawValue: expirationDate ] @@ -242,7 +242,7 @@ extension NotificationManager { let request = UNNotificationRequest( /// We use the same `identifier` for all requests so a newer missed meal notification will replace a current one (if it exists) - identifier: LoopNotificationCategory.unannouncedMeal.rawValue, + identifier: LoopNotificationCategory.missedMeal.rawValue, content: notification, trigger: notificationTrigger ) @@ -259,7 +259,7 @@ extension NotificationManager { let request = notification.request guard - request.identifier == LoopNotificationCategory.unannouncedMeal.rawValue, + request.identifier == LoopNotificationCategory.missedMeal.rawValue, let expirationDate = request.content.userInfo[LoopNotificationUserInfoKey.expirationDate.rawValue] as? Date, expirationDate < now else { diff --git a/Loop/View Controllers/CarbEntryViewController.swift b/Loop/View Controllers/CarbEntryViewController.swift index 0c323f09d8..31f65e0067 100644 --- a/Loop/View Controllers/CarbEntryViewController.swift +++ b/Loop/View Controllers/CarbEntryViewController.swift @@ -108,13 +108,13 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable private var shouldBeginEditingFoodType = false - private var shouldDisplayUnannouncedMealWarning = false { + private var shouldDisplayMissedMealWarning = false { didSet { - if shouldDisplayUnannouncedMealWarning != oldValue { + if shouldDisplayMissedMealWarning != oldValue { if shouldDisplayOverrideEnabledWarning { - self.displayWarningRow(rowType: WarningRow.unannouncedMeal, isAddingRow: shouldDisplayUnannouncedMealWarning) + self.displayWarningRow(rowType: WarningRow.missedMeal, isAddingRow: shouldDisplayMissedMealWarning) } else { - self.shouldDisplayWarning = shouldDisplayUnannouncedMealWarning || shouldDisplayOverrideEnabledWarning + self.shouldDisplayWarning = shouldDisplayMissedMealWarning || shouldDisplayOverrideEnabledWarning } } } @@ -123,10 +123,10 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable private var shouldDisplayOverrideEnabledWarning = false { didSet { if shouldDisplayOverrideEnabledWarning != oldValue { - if shouldDisplayUnannouncedMealWarning { + if shouldDisplayMissedMealWarning { self.displayWarningRow(rowType: WarningRow.override, isAddingRow: shouldDisplayOverrideEnabledWarning) } else { - self.shouldDisplayWarning = shouldDisplayOverrideEnabledWarning || shouldDisplayUnannouncedMealWarning + self.shouldDisplayWarning = shouldDisplayOverrideEnabledWarning || shouldDisplayMissedMealWarning } } } @@ -267,8 +267,8 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable if shouldDisplayWarning { tableView.beginUpdates() - // If the unannounced meal warning is shown, use the positional index of the given row type. - let rowIndex = shouldDisplayUnannouncedMealWarning ? rowType.rawValue : 0 + // If the missed meal warning is shown, use the positional index of the given row type. + let rowIndex = shouldDisplayMissedMealWarning ? rowType.rawValue : 0 if isAddingRow { tableView.insertRows(at: [IndexPath(row: rowIndex, section: Sections.warning.rawValue)], with: UITableView.RowAnimation.top) @@ -297,9 +297,9 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable return displayWarningSection ? indexPath.section : indexPath.section + 1 } - static func numberOfRows(for section: Int, displayUnannouncedMealWarning: Bool, displayOverrideWarning: Bool) -> Int { - if section == Sections.warning.rawValue && (displayUnannouncedMealWarning || displayOverrideWarning) { - return displayUnannouncedMealWarning && displayOverrideWarning ? WarningRow.allCases.count : WarningRow.allCases.count - 1 + static func numberOfRows(for section: Int, displayMissedMealWarning: Bool, displayOverrideWarning: Bool) -> Int { + if section == Sections.warning.rawValue && (displayMissedMealWarning || displayOverrideWarning) { + return displayMissedMealWarning && displayOverrideWarning ? WarningRow.allCases.count : WarningRow.allCases.count - 1 } return DetailsRow.allCases.count @@ -334,7 +334,7 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable } fileprivate enum WarningRow: Int, CaseIterable { - case unannouncedMeal + case missedMeal case override } @@ -343,15 +343,15 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Sections.numberOfRows(for: section, displayUnannouncedMealWarning: shouldDisplayUnannouncedMealWarning, displayOverrideWarning: shouldDisplayOverrideEnabledWarning) + return Sections.numberOfRows(for: section, displayMissedMealWarning: shouldDisplayMissedMealWarning, displayOverrideWarning: shouldDisplayOverrideEnabledWarning) } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch Sections(rawValue: Sections.section(for: indexPath, displayWarningSection: shouldDisplayWarning))! { case .warning: let cell: UITableViewCell - // if no unannounced meal warning should be shown OR if the given indexPath is for the override warning row, return the override warning cell. - if !shouldDisplayUnannouncedMealWarning || WarningRow(rawValue: indexPath.row)! == .override { + // if no missed meal warning should be shown OR if the given indexPath is for the override warning row, return the override warning cell. + if !shouldDisplayMissedMealWarning || WarningRow(rawValue: indexPath.row)! == .override { if let existingCell = tableView.dequeueReusableCell(withIdentifier: "CarbEntryOverrideEnabledWarningCell") { cell = existingCell } else { @@ -375,7 +375,7 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable cell.imageView?.image = UIImage(systemName: "exclamationmark.triangle.fill") cell.imageView?.tintColor = .destructive cell.textLabel?.numberOfLines = 0 - cell.textLabel?.text = NSLocalizedString("Loop has detected an unannounced meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten.", comment: "Warning displayed when user is adding a meal from an unannounced meal notification") + cell.textLabel?.text = NSLocalizedString("Loop has detected an missed meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten.", comment: "Warning displayed when user is adding a meal from an missed meal notification") cell.textLabel?.font = UIFont.preferredFont(forTextStyle: .caption1) cell.textLabel?.textColor = .secondaryLabel cell.isUserInteractionEnabled = false @@ -533,8 +533,8 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable absorptionTimeWasEdited = true } - if activity.entryIsUnannouncedMeal { - shouldDisplayUnannouncedMealWarning = true + if activity.entryisMissedMeal { + shouldDisplayMissedMealWarning = true } } } diff --git a/Loop/View Models/AlertPermissionsViewModel.swift b/Loop/View Models/AlertPermissionsViewModel.swift index 3356d4c394..f9f63a55c3 100644 --- a/Loop/View Models/AlertPermissionsViewModel.swift +++ b/Loop/View Models/AlertPermissionsViewModel.swift @@ -9,16 +9,16 @@ import SwiftUI public class AlertPermissionsViewModel: ObservableObject { - @Published var unannouncedMealNotificationsEnabled: Bool { + @Published var missedMealNotificationsEnabled: Bool { didSet { - UserDefaults.standard.unannouncedMealNotificationsEnabled = unannouncedMealNotificationsEnabled + UserDefaults.standard.missedMealNotificationsEnabled = missedMealNotificationsEnabled } } @Published var checker: AlertPermissionsChecker init(checker: AlertPermissionsChecker) { - self.unannouncedMealNotificationsEnabled = UserDefaults.standard.unannouncedMealNotificationsEnabled + self.missedMealNotificationsEnabled = UserDefaults.standard.missedMealNotificationsEnabled self.checker = checker } } @@ -27,15 +27,15 @@ public class AlertPermissionsViewModel: ObservableObject { extension UserDefaults { private enum Key: String { - case unannouncedMealNotificationsEnabled = "com.loopkit.Loop.UnannouncedMealNotificationsEnabled" + case missedMealNotificationsEnabled = "com.loopkit.Loop.MissedMealNotificationsEnabled" } - var unannouncedMealNotificationsEnabled: Bool { + var missedMealNotificationsEnabled: Bool { get { - return object(forKey: Key.unannouncedMealNotificationsEnabled.rawValue) as? Bool ?? false + return object(forKey: Key.missedMealNotificationsEnabled.rawValue) as? Bool ?? false } set { - set(newValue, forKey: Key.unannouncedMealNotificationsEnabled.rawValue) + set(newValue, forKey: Key.missedMealNotificationsEnabled.rawValue) } } } diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index bd85d1d904..6090efe244 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -66,7 +66,7 @@ public struct NotificationsCriticalAlertPermissionsView: View { } } } - unannouncedMealAlertSection + missedMealAlertSection notificationAndCriticalAlertPermissionSupportSection } .insetGroupedListStyle() @@ -138,9 +138,9 @@ extension NotificationsCriticalAlertPermissionsView { } } - private var unannouncedMealAlertSection: some View { - Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects a meal that wasn't logged.", comment: "Description of unannounced meal notifications."))) { - Toggle("Missed Meal Notifications", isOn: $viewModel.unannouncedMealNotificationsEnabled) + private var missedMealAlertSection: some View { + Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects a meal that wasn't logged.", comment: "Description of missed meal notifications."))) { + Toggle("Missed Meal Notifications", isOn: $viewModel.missedMealNotificationsEnabled) } } diff --git a/LoopCore/UAMNotification.swift b/LoopCore/MissedMealNotification.swift similarity index 71% rename from LoopCore/UAMNotification.swift rename to LoopCore/MissedMealNotification.swift index 607721440e..2cbf61b0e5 100644 --- a/LoopCore/UAMNotification.swift +++ b/LoopCore/MissedMealNotification.swift @@ -1,5 +1,5 @@ // -// UAMNotification.swift +// MissedMealNotification.swift // Loop // // Created by Anna Quinlan on 11/18/22. @@ -8,8 +8,8 @@ import Foundation -/// Information about an unannounced meal notification -public struct UAMNotification: Equatable, Codable { +/// Information about a missed meal notification +public struct MissedMealNotification: Equatable, Codable { public let deliveryTime: Date public let carbAmount: Double diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index 80e28d046f..da129160bd 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -20,7 +20,7 @@ extension UserDefaults { case lastProfileExpirationAlertDate = "com.loopkit.Loop.lastProfileExpirationAlertDate" case allowDebugFeatures = "com.loopkit.Loop.allowDebugFeatures" case allowSimulators = "com.loopkit.Loop.allowSimulators" - case LastUAMNotification = "com.loopkit.Loop.lastUAMNotification" + case LastMissedMealNotification = "com.loopkit.Loop.lastMissedMealNotification" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -115,25 +115,25 @@ extension UserDefaults { } } - public var lastUAMNotification: UAMNotification? { + public var lastMissedMealNotification: MissedMealNotification? { get { let decoder = JSONDecoder() - guard let data = object(forKey: Key.LastUAMNotification.rawValue) as? Data else { + guard let data = object(forKey: Key.LastMissedMealNotification.rawValue) as? Data else { return nil } - return try? decoder.decode(UAMNotification.self, from: data) + return try? decoder.decode(MissedMealNotification.self, from: data) } set { do { if let newValue = newValue { let encoder = JSONEncoder() let data = try encoder.encode(newValue) - set(data, forKey: Key.LastUAMNotification.rawValue) + set(data, forKey: Key.LastMissedMealNotification.rawValue) } else { - set(nil, forKey: Key.LastUAMNotification.rawValue) + set(nil, forKey: Key.LastMissedMealNotification.rawValue) } } catch { - assertionFailure("Unable to encode UAMNotification") + assertionFailure("Unable to encode MissedMealNotification") } } } diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 32acb42538..e69d64e76e 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -12,19 +12,19 @@ import LoopCore import LoopKit @testable import Loop -enum UAMTestType { +enum MissedMealTestType { private static var dateFormatter = ISO8601DateFormatter.localTimeDate() /// No meal is present case noMeal - /// No meal is present, but if the counteraction effects aren't clamped properly it will look like there's a UAM + /// No meal is present, but if the counteraction effects aren't clamped properly it will look like there's a missed meal case noMealCounteractionEffectsNeedClamping // No meal is present and there is COB case noMealWithCOB - /// UAM with no carbs on board - case unannouncedMealNoCOB - /// UAM with carbs logged prior to it - case unannouncedMealWithCOB + /// Missed meal with no carbs on board + case missedMealNoCOB + /// Missed meal with carbs logged prior to it + case missedMealWithCOB /// There is a meal, but it's announced and not unannounced case announcedMeal /// CGM data is noisy, but no meal is present @@ -37,10 +37,10 @@ enum UAMTestType { case notificationTest } -extension UAMTestType { +extension MissedMealTestType { var counteractionEffectFixture: String { switch self { - case .unannouncedMealNoCOB, .noMealWithCOB, .notificationTest: + case .missedMealNoCOB, .noMealWithCOB, .notificationTest: return "uam_counteraction_effect" case .noMeal, .announcedMeal: return "long_interval_counteraction_effect" @@ -48,7 +48,7 @@ extension UAMTestType { return "needs_clamping_counteraction_effect" case .noisyCGM: return "noisy_cgm_counteraction_effect" - case .manyMeals, .unannouncedMealWithCOB: + case .manyMeals, .missedMealWithCOB: return "realistic_report_counteraction_effect" case .dynamicCarbAutofill: return "dynamic_autofill_counteraction_effect" @@ -57,13 +57,13 @@ extension UAMTestType { var currentDate: Date { switch self { - case .unannouncedMealNoCOB, .noMealWithCOB, .notificationTest: + case .missedMealNoCOB, .noMealWithCOB, .notificationTest: return Self.dateFormatter.date(from: "2022-10-17T23:28:45")! case .noMeal, .noMealCounteractionEffectsNeedClamping, .announcedMeal: return Self.dateFormatter.date(from: "2022-10-17T02:49:16")! case .noisyCGM: return Self.dateFormatter.date(from: "2022-10-19T20:46:23")! - case .unannouncedMealWithCOB: + case .missedMealWithCOB: return Self.dateFormatter.date(from: "2022-10-19T19:50:15")! case .manyMeals: return Self.dateFormatter.date(from: "2022-10-19T21:50:15")! @@ -72,11 +72,11 @@ extension UAMTestType { } } - var uamDate: Date? { + var missedMealDate: Date? { switch self { - case .unannouncedMealNoCOB: + case .missedMealNoCOB: return Self.dateFormatter.date(from: "2022-10-17T22:10:00") - case .unannouncedMealWithCOB: + case .missedMealWithCOB: return Self.dateFormatter.date(from: "2022-10-19T19:15:00") case .dynamicCarbAutofill: return Self.dateFormatter.date(from: "2022-10-17T07:20:00")! @@ -87,7 +87,7 @@ extension UAMTestType { var carbEntries: [NewCarbEntry] { switch self { - case .unannouncedMealWithCOB: + case .missedMealWithCOB: return [ NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, @@ -174,7 +174,7 @@ class MealDetectionManagerTests: XCTestCase { var bolusUnits: Double? var bolusDurationEstimator: ((Double) -> TimeInterval?)! - @discardableResult func setUp(for testType: UAMTestType) -> [GlucoseEffectVelocity] { + @discardableResult func setUp(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { let healthStore = HKHealthStoreMock() let carbStore = CarbStore( @@ -220,7 +220,7 @@ class MealDetectionManagerTests: XCTestCase { return counteractionEffects(for: testType) } - private func counteractionEffects(for testType: UAMTestType) -> [GlucoseEffectVelocity] { + private func counteractionEffects(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { let fixture: [JSONDictionary] = loadFixture(testType.counteractionEffectFixture) let dateFormatter = ISO8601DateFormatter.localTimeDate() @@ -233,82 +233,82 @@ class MealDetectionManagerTests: XCTestCase { } override func tearDown() { - mealDetectionManager.lastUAMNotification = nil + mealDetectionManager.lastMissedMealNotification = nil mealDetectionManager = nil - UserDefaults.standard.unannouncedMealNotificationsEnabled = false + UserDefaults.standard.missedMealNotificationsEnabled = false } // MARK: - Algorithm Tests - func testNoUnannouncedMeal() { + func testNoMissedMeal() { let counteractionEffects = setUp(for: .noMeal) let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in - XCTAssertEqual(status, .noUnannouncedMeal) + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } updateGroup.wait() } - func testNoUnannouncedMeal_WithCOB() { + func testNoMissedMeal_WithCOB() { let counteractionEffects = setUp(for: .noMealWithCOB) let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in - XCTAssertEqual(status, .noUnannouncedMeal) + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } updateGroup.wait() } - func testUnannouncedMeal_NoCarbEntry() { - let testType = UAMTestType.unannouncedMealNoCOB + func testMissedMeal_NoCarbEntry() { + let testType = MissedMealTestType.missedMealNoCOB let counteractionEffects = setUp(for: testType) let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in - XCTAssertEqual(status, .hasUnannouncedMeal(startTime: testType.uamDate!, carbAmount: 55)) + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 55)) updateGroup.leave() } updateGroup.wait() } func testDynamicCarbAutofill() { - let testType = UAMTestType.dynamicCarbAutofill + let testType = MissedMealTestType.dynamicCarbAutofill let counteractionEffects = setUp(for: testType) let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in - XCTAssertEqual(status, .hasUnannouncedMeal(startTime: testType.uamDate!, carbAmount: 25)) + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) updateGroup.leave() } updateGroup.wait() } - func testUnannouncedMeal_UAMAndCOB() { - let testType = UAMTestType.unannouncedMealWithCOB + func testMissedMeal_MissedMealAndCOB() { + let testType = MissedMealTestType.missedMealWithCOB let counteractionEffects = setUp(for: testType) let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in - XCTAssertEqual(status, .hasUnannouncedMeal(startTime: testType.uamDate!, carbAmount: 50)) + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 50)) updateGroup.leave() } updateGroup.wait() } - func testNoUnannouncedMeal_AnnouncedMealPresent() { + func testNoMissedMeal_AnnouncedMealPresent() { let counteractionEffects = setUp(for: .announcedMeal) let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in - XCTAssertEqual(status, .noUnannouncedMeal) + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } updateGroup.wait() @@ -319,8 +319,8 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in - XCTAssertEqual(status, .noUnannouncedMeal) + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } updateGroup.wait() @@ -331,113 +331,113 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasUnannouncedMeal(insulinCounteractionEffects: counteractionEffects) { status in - XCTAssertEqual(status, .noUnannouncedMeal) + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } updateGroup.wait() } // MARK: - Notification Tests - func testNoUnannouncedMealLastNotificationTime() { + func testNoMissedMealLastNotificationTime() { setUp(for: .notificationTest) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true + UserDefaults.standard.missedMealNotificationsEnabled = true - let status = UnannouncedMealStatus.noUnannouncedMeal + let status = MissedMealStatus.noMissedMeal mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - XCTAssertNil(mealDetectionManager.lastUAMNotification) + XCTAssertNil(mealDetectionManager.lastMissedMealNotification) } - func testUnannouncedMealUpdatesLastNotificationTime() { + func testMissedMealUpdatesLastNotificationTime() { setUp(for: .notificationTest) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true + UserDefaults.standard.missedMealNotificationsEnabled = true - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 40) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) } - func testUnannouncedMealWithoutNotificationsEnabled() { + func testMissedMealWithoutNotificationsEnabled() { setUp(for: .notificationTest) - UserDefaults.standard.unannouncedMealNotificationsEnabled = false + UserDefaults.standard.missedMealNotificationsEnabled = false - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - XCTAssertNil(mealDetectionManager.lastUAMNotification) + XCTAssertNil(mealDetectionManager.lastMissedMealNotification) } - func testUnannouncedMealWithTooRecentNotificationTime() { + func testMissedMealWithTooRecentNotificationTime() { setUp(for: .notificationTest) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true + UserDefaults.standard.missedMealNotificationsEnabled = true let oldTime = now.addingTimeInterval(.hours(1)) - let oldNotification = UAMNotification(deliveryTime: oldTime, carbAmount: 40) - mealDetectionManager.lastUAMNotification = oldNotification + let oldNotification = MissedMealNotification(deliveryTime: oldTime, carbAmount: 40) + mealDetectionManager.lastMissedMealNotification = oldNotification - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: UAMSettings.minCarbThreshold) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: MissedMealSettings.minCarbThreshold) mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - XCTAssertEqual(mealDetectionManager.lastUAMNotification, oldNotification) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification, oldNotification) } - func testUnannouncedMealCarbClamping() { + func testMissedMealCarbClamping() { setUp(for: .notificationTest) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true + UserDefaults.standard.missedMealNotificationsEnabled = true - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 120) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 120) mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 75) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 75) } - func testUnannouncedMealNoPendingBolus() { + func testMissedMealNoPendingBolus() { setUp(for: .notificationTest) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true + UserDefaults.standard.missedMealNotificationsEnabled = true - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 0, bolusDurationEstimator: bolusDurationEstimator) /// The bolus units time delegate should never be called if there are 0 pending units XCTAssertNil(bolusUnits) - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 40) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) } - func testUnannouncedMealLongPendingBolus() { + func testMissedMealLongPendingBolus() { setUp(for: .notificationTest) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true + UserDefaults.standard.missedMealNotificationsEnabled = true - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 40) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10, bolusDurationEstimator: bolusDurationEstimator) XCTAssertEqual(bolusUnits, 10) /// There shouldn't be a delay in delivering notification, since the autobolus will take the length of the notification window to deliver - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, now) - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.carbAmount, 40) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) } - func testNoUnannouncedMealShortPendingBolus_DelaysNotificationTime() { + func testNoMissedMealShortPendingBolus_DelaysNotificationTime() { setUp(for: .notificationTest) - UserDefaults.standard.unannouncedMealNotificationsEnabled = true + UserDefaults.standard.missedMealNotificationsEnabled = true - let status = UnannouncedMealStatus.hasUnannouncedMeal(startTime: now, carbAmount: 30) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 30) mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 2, bolusDurationEstimator: bolusDurationEstimator) let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(80)) XCTAssertEqual(bolusUnits, 2) - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime) - mealDetectionManager.lastUAMNotification = nil + mealDetectionManager.lastMissedMealNotification = nil mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5, bolusDurationEstimator: bolusDurationEstimator) let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) XCTAssertEqual(bolusUnits, 4.5) - XCTAssertEqual(mealDetectionManager.lastUAMNotification?.deliveryTime, expectedDeliveryTime2) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime2) } } diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 442da8cb89..1ef1d13d75 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -250,7 +250,7 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: guard - response.notification.request.identifier == LoopNotificationCategory.unannouncedMeal.rawValue, + response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue, let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController else { break @@ -259,15 +259,15 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { let userInfo = response.notification.request.content.userInfo // If we have info about a meal, the carb entry UI should reflect it if - let mealTime = userInfo[LoopNotificationUserInfoKey.unannouncedMealTime.rawValue] as? Date, - let carbAmount = userInfo[LoopNotificationUserInfoKey.unannouncedMealCarbAmount.rawValue] as? Double + let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, + let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double { - let unannouncedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: carbAmount), startDate: mealTime, foodType: nil, absorptionTime: nil) - statusController.addCarbs(initialEntry: unannouncedEntry) + statusController.addCarbs(initialEntry: missedEntry) // Otherwise, just provide the ability to add carbs } else { statusController.addCarbs() From dbc0b963e3e74b3434df9408a7fb08b4a4adf8dc Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Thu, 16 Feb 2023 17:58:27 -0800 Subject: [PATCH 44/51] UAM test fixture -> missed meal test fixture --- Loop.xcodeproj/project.pbxproj | 8 ++++---- ..._effect.json => missed_meal_counteraction_effect.json} | 0 LoopTests/Managers/MealDetectionManagerTests.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename LoopTests/Fixtures/meal_detection/{uam_counteraction_effect.json => missed_meal_counteraction_effect.json} (100%) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 28e35cc726..00a484d993 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -614,7 +614,7 @@ E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */; }; E9B35538293706CB0076AB04 /* needs_clamping_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */; }; E9B35539293706CB0076AB04 /* dynamic_autofill_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */; }; - E9B3553A293706CB0076AB04 /* uam_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35534293706CB0076AB04 /* uam_counteraction_effect.json */; }; + E9B3553A293706CB0076AB04 /* missed_meal_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35534293706CB0076AB04 /* missed_meal_counteraction_effect.json */; }; E9B3553B293706CB0076AB04 /* noisy_cgm_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35535293706CB0076AB04 /* noisy_cgm_counteraction_effect.json */; }; E9B3553C293706CB0076AB04 /* realistic_report_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35536293706CB0076AB04 /* realistic_report_counteraction_effect.json */; }; E9B3553D293706CB0076AB04 /* long_interval_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35537293706CB0076AB04 /* long_interval_counteraction_effect.json */; }; @@ -1749,7 +1749,7 @@ E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKHealthStoreMock.swift; sourceTree = ""; }; E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = needs_clamping_counteraction_effect.json; sourceTree = ""; }; E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = dynamic_autofill_counteraction_effect.json; sourceTree = ""; }; - E9B35534293706CB0076AB04 /* uam_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = uam_counteraction_effect.json; sourceTree = ""; }; + E9B35534293706CB0076AB04 /* missed_meal_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = missed_meal_counteraction_effect.json; sourceTree = ""; }; E9B35535293706CB0076AB04 /* noisy_cgm_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = noisy_cgm_counteraction_effect.json; sourceTree = ""; }; E9B35536293706CB0076AB04 /* realistic_report_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = realistic_report_counteraction_effect.json; sourceTree = ""; }; E9B35537293706CB0076AB04 /* long_interval_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = long_interval_counteraction_effect.json; sourceTree = ""; }; @@ -3026,7 +3026,7 @@ children = ( E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */, E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */, - E9B35534293706CB0076AB04 /* uam_counteraction_effect.json */, + E9B35534293706CB0076AB04 /* missed_meal_counteraction_effect.json */, E9B35535293706CB0076AB04 /* noisy_cgm_counteraction_effect.json */, E9B35537293706CB0076AB04 /* long_interval_counteraction_effect.json */, E9B35536293706CB0076AB04 /* realistic_report_counteraction_effect.json */, @@ -3617,7 +3617,7 @@ E9C58A7F24DB529A00487A17 /* counteraction_effect_falling_glucose.json in Resources */, E93E865624DB731900FF40C8 /* predicted_glucose_without_retrospective.json in Resources */, E9B3553D293706CB0076AB04 /* long_interval_counteraction_effect.json in Resources */, - E9B3553A293706CB0076AB04 /* uam_counteraction_effect.json in Resources */, + E9B3553A293706CB0076AB04 /* missed_meal_counteraction_effect.json in Resources */, E9B35538293706CB0076AB04 /* needs_clamping_counteraction_effect.json in Resources */, E90909D424E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json in Resources */, E93E86CC24E2E02200FF40C8 /* high_and_stable_predicted_glucose.json in Resources */, diff --git a/LoopTests/Fixtures/meal_detection/uam_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/missed_meal_counteraction_effect.json similarity index 100% rename from LoopTests/Fixtures/meal_detection/uam_counteraction_effect.json rename to LoopTests/Fixtures/meal_detection/missed_meal_counteraction_effect.json diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index e69d64e76e..3df22fd2d5 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -41,7 +41,7 @@ extension MissedMealTestType { var counteractionEffectFixture: String { switch self { case .missedMealNoCOB, .noMealWithCOB, .notificationTest: - return "uam_counteraction_effect" + return "missed_meal_counteraction_effect" case .noMeal, .announcedMeal: return "long_interval_counteraction_effect" case .noMealCounteractionEffectsNeedClamping: From 7b23e08bc8d76498745d302fa133c61554333859 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Fri, 17 Feb 2023 16:51:16 -0800 Subject: [PATCH 45/51] Variable naming improvements --- .../Missed Meal Detection/MealDetectionManager.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index 4f596e864f..ad937fd63f 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -156,17 +156,17 @@ class MealDetectionManager { /// Find the threshold based on a minimum of `missedMealGlucoseRiseThreshold` of change per minute let minutesAgo = now.timeIntervalSince(pastTime).minutes - let deviationChangeThreshold = MissedMealSettings.glucoseRiseThreshold * minutesAgo + let rateThreshold = MissedMealSettings.glucoseRiseThreshold * minutesAgo /// Find the total effect we'd expect to see for a meal with `carbThreshold`-worth of carbs that started at `pastTime` - guard let modeledMealEffectThreshold = self.effectThreshold(mealStart: pastTime, carbsInGrams: MissedMealSettings.minCarbThreshold) else { + guard let mealThreshold = self.effectThreshold(mealStart: pastTime, carbsInGrams: MissedMealSettings.minCarbThreshold) else { continue } - missedMealTimeline.append((pastTime, unexpectedDeviation, modeledMealEffectThreshold, deviationChangeThreshold)) + missedMealTimeline.append((pastTime, unexpectedDeviation, mealThreshold, rateThreshold)) /// Use the higher of the 2 thresholds to ensure noisy CGM data doesn't cause false-positives for more recent times - let effectThreshold = max(deviationChangeThreshold, modeledMealEffectThreshold) + let effectThreshold = max(rateThreshold, mealThreshold) if unexpectedDeviation >= effectThreshold { mealTime = pastTime From d35c21071e6cdf1e33fbf87eeec9df43b7f35289 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Fri, 17 Feb 2023 17:08:17 -0800 Subject: [PATCH 46/51] Remove `AlertPermissionsViewModel` --- Loop.xcodeproj/project.pbxproj | 4 -- .../StatusTableViewController.swift | 2 +- .../AlertPermissionsViewModel.swift | 41 ------------ Loop/View Models/SettingsViewModel.swift | 10 +-- Loop/Views/AlertManagementView.swift | 2 +- ...icationsCriticalAlertPermissionsView.swift | 63 +++++++++++++------ Loop/Views/SettingsView.swift | 6 +- 7 files changed, 53 insertions(+), 75 deletions(-) delete mode 100644 Loop/View Models/AlertPermissionsViewModel.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 00a484d993..9b957f1c94 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -628,7 +628,6 @@ E9C58A7E24DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7924DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json */; }; E9C58A7F24DB529A00487A17 /* counteraction_effect_falling_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7A24DB529A00487A17 /* counteraction_effect_falling_glucose.json */; }; E9C58A8024DB529A00487A17 /* insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7B24DB529A00487A17 /* insulin_effect.json */; }; - E9CECC8A2919CFCE004B8BE8 /* AlertPermissionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CECC892919CFCE004B8BE8 /* AlertPermissionsViewModel.swift */; }; E9E5E56024D3519700B5DFFE /* far_future_high_bg_forecast_after_6_hours.json in Resources */ = {isa = PBXBuildFile; fileRef = E9E5E55F24D3519700B5DFFE /* far_future_high_bg_forecast_after_6_hours.json */; }; /* End PBXBuildFile section */ @@ -1762,7 +1761,6 @@ E9C58A7924DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = dynamic_glucose_effect_partially_observed.json; sourceTree = ""; }; E9C58A7A24DB529A00487A17 /* counteraction_effect_falling_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = counteraction_effect_falling_glucose.json; sourceTree = ""; }; E9C58A7B24DB529A00487A17 /* insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = insulin_effect.json; sourceTree = ""; }; - E9CECC892919CFCE004B8BE8 /* AlertPermissionsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPermissionsViewModel.swift; sourceTree = ""; }; E9E5E55F24D3519700B5DFFE /* far_future_high_bg_forecast_after_6_hours.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = far_future_high_bg_forecast_after_6_hours.json; sourceTree = ""; }; F5D9C01727DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Intents.strings; sourceTree = ""; }; F5D9C01927DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Main.strings; sourceTree = ""; }; @@ -2726,7 +2724,6 @@ 897A5A9724C22DCE00C4E71D /* View Models */ = { isa = PBXGroup; children = ( - E9CECC892919CFCE004B8BE8 /* AlertPermissionsViewModel.swift */, 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */, A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */, C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */, @@ -3976,7 +3973,6 @@ C18913B52524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift in Sources */, B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */, 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */, - E9CECC8A2919CFCE004B8BE8 /* AlertPermissionsViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 373e94381c..904f64cabd 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1451,7 +1451,7 @@ final class StatusTableViewController: LoopChartsTableViewController { activeServices: { [weak self] in self?.deviceManager.servicesManager.activeServices ?? [] }, delegate: self) let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) - let viewModel = SettingsViewModel(alertPermissionsViewModel: AlertPermissionsViewModel(checker: alertPermissionsChecker), + let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, alertMuter: alertMuter, versionUpdateViewModel: versionUpdateViewModel, pumpManagerSettingsViewModel: pumpViewModel, diff --git a/Loop/View Models/AlertPermissionsViewModel.swift b/Loop/View Models/AlertPermissionsViewModel.swift deleted file mode 100644 index f9f63a55c3..0000000000 --- a/Loop/View Models/AlertPermissionsViewModel.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// AlertPermissionsViewModel.swift -// Loop -// -// Created by Anna Quinlan on 11/7/22. -// Copyright © 2022 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -public class AlertPermissionsViewModel: ObservableObject { - @Published var missedMealNotificationsEnabled: Bool { - didSet { - UserDefaults.standard.missedMealNotificationsEnabled = missedMealNotificationsEnabled - } - } - - @Published var checker: AlertPermissionsChecker - - init(checker: AlertPermissionsChecker) { - self.missedMealNotificationsEnabled = UserDefaults.standard.missedMealNotificationsEnabled - self.checker = checker - } -} - - -extension UserDefaults { - - private enum Key: String { - case missedMealNotificationsEnabled = "com.loopkit.Loop.MissedMealNotificationsEnabled" - } - - var missedMealNotificationsEnabled: Bool { - get { - return object(forKey: Key.missedMealNotificationsEnabled.rawValue) as? Bool ?? false - } - set { - set(newValue, forKey: Key.missedMealNotificationsEnabled.rawValue) - } - } -} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index c25a0c8da5..16e58743b9 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -58,7 +58,7 @@ public protocol SettingsViewModelDelegate: AnyObject { public class SettingsViewModel: ObservableObject { - let alertPermissionsViewModel: AlertPermissionsViewModel + let alertPermissionsChecker: AlertPermissionsChecker let alertMuter: AlertMuter @@ -102,7 +102,7 @@ public class SettingsViewModel: ObservableObject { lazy private var cancellables = Set() - public init(alertPermissionsViewModel: AlertPermissionsViewModel, + public init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, versionUpdateViewModel: VersionUpdateViewModel, pumpManagerSettingsViewModel: PumpManagerViewModel, @@ -120,7 +120,7 @@ public class SettingsViewModel: ObservableObject { therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, delegate: SettingsViewModelDelegate? ) { - self.alertPermissionsViewModel = alertPermissionsViewModel + self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter self.versionUpdateViewModel = versionUpdateViewModel self.pumpManagerSettingsViewModel = pumpManagerSettingsViewModel @@ -139,7 +139,7 @@ public class SettingsViewModel: ObservableObject { self.delegate = delegate // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) - alertPermissionsViewModel.checker.objectWillChange.sink { [weak self] in + alertPermissionsChecker.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } .store(in: &cancellables) @@ -181,7 +181,7 @@ extension SettingsViewModel { } static var preview: SettingsViewModel { - return SettingsViewModel(alertPermissionsViewModel: AlertPermissionsViewModel(checker: AlertPermissionsChecker()) , + return SettingsViewModel(alertPermissionsChecker: AlertPermissionsChecker(), alertMuter: AlertMuter(), versionUpdateViewModel: VersionUpdateViewModel(supportManager: nil, guidanceColors: GuidanceColors()), pumpManagerSettingsViewModel: DeviceViewModel(), diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index c3abe73b77..de7cdc6be8 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -72,7 +72,7 @@ struct AlertManagementView: View { private var alertPermissionsSection: some View { Section(footer: DescriptiveText(label: String(format: NSLocalizedString("Notifications give you important %1$@ app information without requiring you to open the app.", comment: "Alert Permissions descriptive text (1: app name)"), appName))) { NavigationLink(destination: - NotificationsCriticalAlertPermissionsView(mode: .flow, viewModel: AlertPermissionsViewModel(checker: checker) )) + NotificationsCriticalAlertPermissionsView(mode: .flow, checker: checker)) { HStack { Text(NSLocalizedString("Alert Permissions", comment: "Alert Permissions button text")) diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index 6090efe244..f995fc9ae9 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -14,7 +14,7 @@ public struct NotificationsCriticalAlertPermissionsView: View { @Environment(\.appName) private var appName private let backButtonText: String - @ObservedObject private var viewModel: AlertPermissionsViewModel + @ObservedObject private var checker: AlertPermissionsChecker // TODO: This screen is used in both the 'old Settings UI' and the 'new Settings UI'. This is temporary. // In the old UI, it is a "top level" navigation view. In the new UI, it is just part of the "flow". This @@ -24,20 +24,28 @@ public struct NotificationsCriticalAlertPermissionsView: View { } private let mode: PresentationMode - public init(backButtonText: String = "", mode: PresentationMode = .topLevel, viewModel: AlertPermissionsViewModel) { + public init(backButtonText: String = "", mode: PresentationMode = .topLevel, checker: AlertPermissionsChecker) { self.backButtonText = backButtonText - self.viewModel = viewModel + self.checker = checker self.mode = mode } - @ViewBuilder public var body: some View { switch mode { - case .flow: content() - case .topLevel: navigationContent() + case .flow: return AnyView(content()) + case .topLevel: return AnyView(navigationContent()) } } + private var missedMealNotificationsEnabled: Binding { + Binding( + get: { UserDefaults.standard.missedMealNotificationsEnabled }, + set: { enabled in + UserDefaults.standard.missedMealNotificationsEnabled = enabled + } + ) + } + private func navigationContent() -> some View { return NavigationView { content() @@ -55,13 +63,13 @@ public struct NotificationsCriticalAlertPermissionsView: View { manageNotifications notificationsEnabledStatus if #available(iOS 15.0, *) { - if !viewModel.checker.notificationCenterSettings.notificationsDisabled { + if !checker.notificationCenterSettings.notificationsDisabled { notificationDelivery } } criticalAlertsStatus if #available(iOS 15.0, *) { - if !viewModel.checker.notificationCenterSettings.notificationsDisabled { + if !checker.notificationCenterSettings.notificationsDisabled { timeSensitiveStatus } } @@ -103,7 +111,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Notifications", comment: "Notifications Status text") Spacer() - onOff(!viewModel.checker.notificationCenterSettings.notificationsDisabled) + onOff(!checker.notificationCenterSettings.notificationsDisabled) } } @@ -111,7 +119,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Critical Alerts", comment: "Critical Alerts Status text") Spacer() - onOff(!viewModel.checker.notificationCenterSettings.criticalAlertsDisabled) + onOff(!checker.notificationCenterSettings.criticalAlertsDisabled) } } @@ -120,7 +128,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Time Sensitive Notifications", comment: "Time Sensitive Status text") Spacer() - onOff(!viewModel.checker.notificationCenterSettings.timeSensitiveNotificationsDisabled) + onOff(!checker.notificationCenterSettings.timeSensitiveNotificationsDisabled) } } @@ -129,7 +137,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Notification Delivery", comment: "Notification Delivery Status text") Spacer() - if viewModel.checker.notificationCenterSettings.scheduledDeliveryEnabled { + if checker.notificationCenterSettings.scheduledDeliveryEnabled { Image(systemName: "exclamationmark.triangle.fill").foregroundColor(.critical) Text("Scheduled", comment: "Scheduled Delivery status text") } else { @@ -137,12 +145,6 @@ extension NotificationsCriticalAlertPermissionsView { } } } - - private var missedMealAlertSection: some View { - Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects a meal that wasn't logged.", comment: "Description of missed meal notifications."))) { - Toggle("Missed Meal Notifications", isOn: $viewModel.missedMealNotificationsEnabled) - } - } private var notificationAndCriticalAlertPermissionSupportSection: some View { Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "Section title for Support"))) { @@ -151,17 +153,38 @@ extension NotificationsCriticalAlertPermissionsView { } } } + + private var missedMealAlertSection: some View { + Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects a meal that wasn't logged.", comment: "Description of missed meal notifications."))) { + Toggle("Missed Meal Notifications", isOn: missedMealNotificationsEnabled) + } + } +} +extension UserDefaults { + private enum Key: String { + case missedMealNotificationsEnabled = "com.loopkit.Loop.MissedMealNotificationsEnabled" + } + + var missedMealNotificationsEnabled: Bool { + get { + return object(forKey: Key.missedMealNotificationsEnabled.rawValue) as? Bool ?? false + } + set { + set(newValue, forKey: Key.missedMealNotificationsEnabled.rawValue) + } + } } + struct NotificationsCriticalAlertPermissionsView_Previews: PreviewProvider { static var previews: some View { return Group { - NotificationsCriticalAlertPermissionsView(viewModel: AlertPermissionsViewModel(checker: AlertPermissionsChecker())) + NotificationsCriticalAlertPermissionsView(checker: AlertPermissionsChecker()) .colorScheme(.light) .previewDevice(PreviewDevice(rawValue: "iPhone SE")) .previewDisplayName("SE light") - NotificationsCriticalAlertPermissionsView(viewModel: AlertPermissionsViewModel(checker: AlertPermissionsChecker())) + NotificationsCriticalAlertPermissionsView(checker: AlertPermissionsChecker()) .colorScheme(.dark) .previewDevice(PreviewDevice(rawValue: "iPhone XS Max")) .previewDisplayName("XS Max dark") diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 538eb2d339..90859273e3 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -136,12 +136,12 @@ extension SettingsView { private var alertManagementSection: some View { Section { - NavigationLink(destination: AlertManagementView(checker: viewModel.alertPermissionsViewModel.checker, alertMuter: viewModel.alertMuter)) + NavigationLink(destination: AlertManagementView(checker: viewModel.alertPermissionsChecker, alertMuter: viewModel.alertMuter)) { HStack { Text(NSLocalizedString("Alert Management", comment: "Alert Permissions button text")) - if viewModel.alertPermissionsViewModel.checker.showWarning || - viewModel.alertPermissionsViewModel.checker.notificationCenterSettings.scheduledDeliveryEnabled { + if viewModel.alertPermissionsChecker.showWarning || + viewModel.alertPermissionsChecker.notificationCenterSettings.scheduledDeliveryEnabled { Spacer() Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.critical) From 44a45579a1a6112e426ef5d986ab6b67d05e7fab Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Fri, 17 Feb 2023 17:08:45 -0800 Subject: [PATCH 47/51] Move call to check for missed meals to `loop()`-level --- Loop/Managers/LoopDataManager.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index cc81c46427..69d15668bf 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -862,6 +862,14 @@ extension LoopDataManager { logger.default("Loop ended") notify(forChange: .loopFinished) + mealDetectionManager.generateMissedMealNotificationIfNeeded( + using: insulinCounteractionEffects, + 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 DispatchQueue.main.asyncAfter(deadline: .now() + 5) { self.widgetLog.default("Refreshing widget. Reason: Loop completed") @@ -1709,14 +1717,6 @@ extension LoopDataManager { /// *This method should only be called from the `dataAccessQueue`* private func enactRecommendedAutomaticDose() -> LoopError? { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - mealDetectionManager.generateMissedMealNotificationIfNeeded( - using: insulinCounteractionEffects, - pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, - bolusDurationEstimator: { [unowned self] bolusAmount in - return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) - } - ) guard let recommendedDose = self.recommendedAutomaticDose else { return nil From 879a42cad0101fde1be751cc099ba62ff0baf099 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Sat, 18 Feb 2023 13:28:28 -0800 Subject: [PATCH 48/51] Remove `CarbStore` dependency from `MealDetectionManager` These changes also remove the requirement that there be no carbs entered after the missed meal was detected - since we're now observing direct absorption, I didn't think this requirement made sense anymore --- Loop/Managers/LoopDataManager.swift | 40 ++- .../MealDetectionManager.swift | 240 +++++++++--------- .../Managers/MealDetectionManagerTests.swift | 74 +++--- 3 files changed, 179 insertions(+), 175 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 69d15668bf..c5ce19cf89 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -109,7 +109,11 @@ final class LoopDataManager { self.now = now self.latestStoredSettingsProvider = latestStoredSettingsProvider - self.mealDetectionManager = MealDetectionManager(carbStore: carbStore, maximumBolus: settings.maximumBolus) + self.mealDetectionManager = MealDetectionManager( + carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, + insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, + maximumBolus: settings.maximumBolus + ) self.lockedPumpInsulinType = Locked(pumpInsulinType) @@ -260,11 +264,16 @@ final class LoopDataManager { // Invalidate cached effects affected by the override invalidateCachedEffects = true + + // Update the affected schedules + mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory + mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory } if newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule { carbStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule doseStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule + mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory invalidateCachedEffects = true analyticsServicesManager.didChangeInsulinSensitivitySchedule() } @@ -279,6 +288,7 @@ final class LoopDataManager { if newValue.carbRatioSchedule != oldValue.carbRatioSchedule { carbStore.carbRatioSchedule = newValue.carbRatioSchedule + mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory invalidateCachedEffects = true analyticsServicesManager.didChangeCarbRatioSchedule() } @@ -862,14 +872,28 @@ extension LoopDataManager { logger.default("Loop ended") notify(forChange: .loopFinished) - mealDetectionManager.generateMissedMealNotificationIfNeeded( - using: insulinCounteractionEffects, - pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, - bolusDurationEstimator: { [unowned self] bolusAmount in - return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) + let carbEffectStart = now().addingTimeInterval(-MissedMealSettings.maxRecency) + carbStore.getGlucoseEffects(start: carbEffectStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in + guard + let self = self, + case .success((_, let carbEffects)) = result + else { + if case .failure(let error) = result { + self?.logger.error("Failed to fetch glucose effects to check for missed meal: %{public}@", String(describing: error)) + } + 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) + } + ) + } + // 5 second delay to allow stores to cache data before it is read by widget DispatchQueue.main.asyncAfter(deadline: .now() + 5) { self.widgetLog.default("Refreshing widget. Reason: Loop completed") diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index ad937fd63f..12130f067e 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -20,6 +20,8 @@ enum MissedMealStatus: Equatable { class MealDetectionManager { private let log = OSLog(category: "MealDetectionManager") + public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? + public var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? public var maximumBolus: Double? /// The last missed meal notification that was sent @@ -30,8 +32,6 @@ class MealDetectionManager { } } - private var carbStore: CarbStoreProtocol - /// Debug info for missed meal detection /// Timeline from the most recent check for missed meals private var lastEvaluatedMissedMealTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] @@ -52,140 +52,128 @@ class MealDetectionManager { } public init( - carbStore: CarbStoreProtocol, + carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule?, + insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule?, maximumBolus: Double?, test_currentDate: Date? = nil ) { - self.carbStore = carbStore + self.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory + self.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory self.maximumBolus = maximumBolus self.test_currentDate = test_currentDate } // MARK: Meal Detection - func hasMissedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], completion: @escaping (MissedMealStatus) -> Void) { + func hasMissedMeal(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 - carbStore.getGlucoseEffects(start: intervalStart, end: now, effectVelocities: insulinCounteractionEffects) {[weak self] result in - guard - let self = self, - case .success((let carbEntries, let carbEffects)) = result - else { - if case .failure(let error) = result { - self?.log.error("Failed to fetch glucose effects to check for missed meal: %{public}@", String(describing: error)) - } - - completion(.noMissedMeal) - return - } + let filteredCarbEffects = carbEffects.filterDateRange(intervalStart, now) - /// Compute how much of the ICE effect we can't explain via our entered carbs - /// Effect caching inspired by `LoopMath.predictGlucose` - var effectValueCache: [Date: Double] = [:] - let unit = HKUnit.milligramsPerDeciliter - - /// Carb effects are cumulative, so we have to subtract the previous effect value - var previousEffectValue: Double = carbEffects.first?.quantity.doubleValue(for: unit) ?? 0 + /// Compute how much of the ICE effect we can't explain via our entered carbs + /// Effect caching inspired by `LoopMath.predictGlucose` + var effectValueCache: [Date: Double] = [:] + let unit = HKUnit.milligramsPerDeciliter - /// Counteraction effects only take insulin into account, so we need to account for the carb effects when computing the unexpected deviations - for effect in carbEffects { - let value = effect.quantity.doubleValue(for: unit) - /// We do `-1 * (value - previousEffectValue)` because this will compute the carb _counteraction_ effect - effectValueCache[effect.startDate] = (effectValueCache[effect.startDate] ?? 0) + -1 * (value - previousEffectValue) - previousEffectValue = value - } + /// Carb effects are cumulative, so we have to subtract the previous effect value + var previousEffectValue: Double = filteredCarbEffects.first?.quantity.doubleValue(for: unit) ?? 0 - let processedICE = insulinCounteractionEffects - .filterDateRange(intervalStart, now) - .compactMap { - /// Clamp starts & ends to `intervalStart...now` since our algorithm assumes all effects occur within that interval - let start = max($0.startDate, intervalStart) - let end = min($0.endDate, now) + /// Counteraction effects only take insulin into account, so we need to account for the carb effects when computing the unexpected deviations + for effect in filteredCarbEffects { + let value = effect.quantity.doubleValue(for: unit) + /// We do `-1 * (value - previousEffectValue)` because this will compute the carb _counteraction_ effect + effectValueCache[effect.startDate] = (effectValueCache[effect.startDate] ?? 0) + -1 * (value - previousEffectValue) + previousEffectValue = value + } - guard let effect = $0.effect(from: start, to: end) else { - let item: GlucoseEffect? = nil // FIXME: we get a compiler error if we try to return `nil` directly - return item - } + let processedICE = insulinCounteractionEffects + .filterDateRange(intervalStart, now) + .compactMap { + /// Clamp starts & ends to `intervalStart...now` since our algorithm assumes all effects occur within that interval + let start = max($0.startDate, intervalStart) + let end = min($0.endDate, now) - return GlucoseEffect(startDate: effect.startDate.dateFlooredToTimeInterval(delta), - quantity: effect.quantity) + guard let effect = $0.effect(from: start, to: end) else { + let item: GlucoseEffect? = nil // FIXME: we get a compiler error if we try to return `nil` directly + return item } - - for effect in processedICE { - let value = effect.quantity.doubleValue(for: unit) - effectValueCache[effect.startDate] = (effectValueCache[effect.startDate] ?? 0) + value + +// return GlucoseEffect(startDate: effect.endDate.dateCeiledToTimeInterval(delta), + return GlucoseEffect(startDate: effect.startDate.dateFlooredToTimeInterval(delta), + quantity: effect.quantity) + } + + for effect in processedICE { + let value = effect.quantity.doubleValue(for: unit) + effectValueCache[effect.startDate] = (effectValueCache[effect.startDate] ?? 0) + value + } + + var unexpectedDeviation: Double = 0 + var mealTime = now + + /// Dates the algorithm uses when computing effects + /// Have the range go from newest -> oldest time + let summationRange = LoopMath.simulationDateRange(from: intervalStart, + to: now, + delta: delta) + .reversed() + + /// Dates the algorithm is allowed to check for the presence of a missed meal + let dateSearchRange = Set(LoopMath.simulationDateRange(from: intervalStart, + to: intervalEnd, + delta: delta)) + + /// Timeline used for debug purposes + var missedMealTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + + for pastTime in summationRange { + guard let unexpectedEffect = effectValueCache[pastTime] else { + missedMealTimeline.append((pastTime, nil, nil, nil)) + continue } - var unexpectedDeviation: Double = 0 - var mealTime = now - - /// Dates the algorithm uses when computing effects - /// Have the range go from newest -> oldest time - let summationRange = LoopMath.simulationDateRange(from: intervalStart, - to: now, - delta: delta) - .reversed() - - /// Dates the algorithm is allowed to check for the presence of a missed meal - let dateSearchRange = Set(LoopMath.simulationDateRange(from: intervalStart, - to: intervalEnd, - delta: delta)) - - /// Timeline used for debug purposes - var missedMealTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] - - for pastTime in summationRange { - guard - let unexpectedEffect = effectValueCache[pastTime], - !carbEntries.contains(where: { $0.startDate >= pastTime }) - else { - missedMealTimeline.append((pastTime, nil, nil, nil)) - continue - } - - unexpectedDeviation += unexpectedEffect - - guard dateSearchRange.contains(pastTime) else { - /// This time is too recent to check for a missed meal - missedMealTimeline.append((pastTime, unexpectedDeviation, nil, nil)) - continue - } - - /// Find the threshold based on a minimum of `missedMealGlucoseRiseThreshold` of change per minute - let minutesAgo = now.timeIntervalSince(pastTime).minutes - let rateThreshold = MissedMealSettings.glucoseRiseThreshold * minutesAgo - - /// Find the total effect we'd expect to see for a meal with `carbThreshold`-worth of carbs that started at `pastTime` - guard let mealThreshold = self.effectThreshold(mealStart: pastTime, carbsInGrams: MissedMealSettings.minCarbThreshold) else { - continue - } - - missedMealTimeline.append((pastTime, unexpectedDeviation, mealThreshold, rateThreshold)) - - /// Use the higher of the 2 thresholds to ensure noisy CGM data doesn't cause false-positives for more recent times - let effectThreshold = max(rateThreshold, mealThreshold) + unexpectedDeviation += unexpectedEffect - if unexpectedDeviation >= effectThreshold { - mealTime = pastTime - } + guard dateSearchRange.contains(pastTime) else { + /// This time is too recent to check for a missed meal + missedMealTimeline.append((pastTime, unexpectedDeviation, nil, nil)) + continue } - self.lastEvaluatedMissedMealTimeline = missedMealTimeline.reversed() + /// Find the threshold based on a minimum of `missedMealGlucoseRiseThreshold` of change per minute + let minutesAgo = now.timeIntervalSince(pastTime).minutes + let rateThreshold = MissedMealSettings.glucoseRiseThreshold * minutesAgo - let mealTimeTooRecent = now.timeIntervalSince(mealTime) < MissedMealSettings.minRecency - guard !mealTimeTooRecent else { - completion(.noMissedMeal) - return + /// Find the total effect we'd expect to see for a meal with `carbThreshold`-worth of carbs that started at `pastTime` + guard let mealThreshold = self.effectThreshold(mealStart: pastTime, carbsInGrams: MissedMealSettings.minCarbThreshold) else { + continue } - - self.lastDetectedMissedMealTimeline = missedMealTimeline.reversed() - let carbAmount = self.determineCarbs(mealtime: mealTime, unexpectedDeviation: unexpectedDeviation) - completion(.hasMissedMeal(startTime: mealTime, carbAmount: carbAmount ?? MissedMealSettings.minCarbThreshold)) + missedMealTimeline.append((pastTime, unexpectedDeviation, mealThreshold, rateThreshold)) + + /// Use the higher of the 2 thresholds to ensure noisy CGM data doesn't cause false-positives for more recent times + let effectThreshold = max(rateThreshold, mealThreshold) + + if unexpectedDeviation >= effectThreshold { + mealTime = pastTime + } } + + self.lastEvaluatedMissedMealTimeline = missedMealTimeline.reversed() + + let mealTimeTooRecent = now.timeIntervalSince(mealTime) < MissedMealSettings.minRecency + guard !mealTimeTooRecent else { + completion(.noMissedMeal) + return + } + + self.lastDetectedMissedMealTimeline = missedMealTimeline.reversed() + + let carbAmount = self.determineCarbs(mealtime: mealTime, unexpectedDeviation: unexpectedDeviation) + completion(.hasMissedMeal(startTime: mealTime, carbAmount: carbAmount ?? MissedMealSettings.minCarbThreshold)) } private func determineCarbs(mealtime: Date, unexpectedDeviation: Double) -> Double? { @@ -206,34 +194,32 @@ class MealDetectionManager { } private func effectThreshold(mealStart: Date, carbsInGrams: Double) -> Double? { - do { - return try carbStore.glucoseEffects( - of: [NewCarbEntry(quantity: HKQuantity(unit: .gram(), - doubleValue: carbsInGrams), - startDate: mealStart, - foodType: nil, - absorptionTime: nil) - ], - startingAt: mealStart, - endingAt: nil, - effectVelocities: nil - ) - .last? - .quantity.doubleValue(for: HKUnit.milligramsPerDeciliter) - } catch let error { - self.log.error("Error fetching carb glucose effects: %{public}@", String(describing: error)) + guard + let carbRatio = carbRatioScheduleApplyingOverrideHistory?.value(at: mealStart), + let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory?.value(at: mealStart) + else { + return nil } - return nil + return carbsInGrams / carbRatio * insulinSensitivity } // MARK: Notification Generation + /// Searches for any potential missed meals and sends a notification. + /// A missed meal notification can be delivered a maximum of every `MissedMealSettings.maxRecency - MissedMealSettings.minRecency` minutes. + /// + /// - Parameters: + /// - insulinCounteractionEffects: the current insulin counteraction effects that have been observed + /// - carbEffects: the effects of any active carb entries. Must include effects from `currentDate() - MissedMealSettings.maxRecency` until `currentDate()`. + /// - 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( - using insulinCounteractionEffects: [GlucoseEffectVelocity], + insulinCounteractionEffects: [GlucoseEffectVelocity], + carbEffects: [GlucoseEffect], pendingAutobolusUnits: Double? = nil, bolusDurationEstimator: @escaping (Double) -> TimeInterval? ) { - hasMissedMeal(insulinCounteractionEffects: insulinCounteractionEffects) {[weak self] status in + hasMissedMeal(insulinCounteractionEffects: insulinCounteractionEffects, carbEffects: carbEffects) {[weak self] status in self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits, bolusDurationEstimator: bolusDurationEstimator) } } @@ -260,7 +246,7 @@ class MealDetectionManager { var clampedCarbAmount = carbAmount if let maxBolus = maximumBolus, - let currentCarbRatio = carbStore.carbRatioSchedule?.quantity(at: now).doubleValue(for: .gram()) + let currentCarbRatio = carbRatioScheduleApplyingOverrideHistory?.quantity(at: now).doubleValue(for: .gram()) { let maxAllowedCarbAutofill = maxBolus * currentCarbRatio clampedCarbAmount = min(clampedCarbAmount, maxAllowedCarbAutofill) diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 3df22fd2d5..c26654cec6 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -25,8 +25,6 @@ enum MissedMealTestType { case missedMealNoCOB /// Missed meal with carbs logged prior to it case missedMealWithCOB - /// There is a meal, but it's announced and not unannounced - case announcedMeal /// CGM data is noisy, but no meal is present case noisyCGM /// Realistic counteraction effects with multiple meals @@ -42,7 +40,7 @@ extension MissedMealTestType { switch self { case .missedMealNoCOB, .noMealWithCOB, .notificationTest: return "missed_meal_counteraction_effect" - case .noMeal, .announcedMeal: + case .noMeal: return "long_interval_counteraction_effect" case .noMealCounteractionEffectsNeedClamping: return "needs_clamping_counteraction_effect" @@ -59,7 +57,7 @@ extension MissedMealTestType { switch self { case .missedMealNoCOB, .noMealWithCOB, .notificationTest: return Self.dateFormatter.date(from: "2022-10-17T23:28:45")! - case .noMeal, .noMealCounteractionEffectsNeedClamping, .announcedMeal: + case .noMeal, .noMealCounteractionEffectsNeedClamping: return Self.dateFormatter.date(from: "2022-10-17T02:49:16")! case .noisyCGM: return Self.dateFormatter.date(from: "2022-10-19T20:46:23")! @@ -98,21 +96,6 @@ extension MissedMealTestType { foodType: nil, absorptionTime: nil) ] - case .announcedMeal: - return [ - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 40), - startDate: Self.dateFormatter.date(from: "2022-10-17T01:06:52")!, - foodType: nil, - absorptionTime: nil), - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1), - startDate: Self.dateFormatter.date(from: "2022-10-17T02:15:00")!, - foodType: nil, - absorptionTime: nil), - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), - startDate: Self.dateFormatter.date(from: "2022-10-17T02:35:00")!, - foodType: nil, - absorptionTime: nil), - ] case .noMealWithCOB: return [ NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), @@ -166,6 +149,7 @@ class MealDetectionManagerTests: XCTestCase { let pumpManager = MockPumpManager() var mealDetectionManager: MealDetectionManager! + var carbStore: CarbStore! var now: Date { mealDetectionManager.test_currentDate! @@ -177,7 +161,7 @@ class MealDetectionManagerTests: XCTestCase { @discardableResult func setUp(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { let healthStore = HKHealthStoreMock() - let carbStore = CarbStore( + carbStore = CarbStore( healthStore: healthStore, cacheStore: PersistenceController(directoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString, isDirectory: true)), cacheLength: .hours(24), @@ -206,7 +190,8 @@ class MealDetectionManagerTests: XCTestCase { _ = updateGroup.wait(timeout: .now() + .seconds(5)) mealDetectionManager = MealDetectionManager( - carbStore: carbStore, + carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, + insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, maximumBolus: 5, test_currentDate: testType.currentDate ) @@ -232,6 +217,27 @@ class MealDetectionManagerTests: XCTestCase { } } + private func mealDetectionCarbEffects(using insulinCounteractionEffects: [GlucoseEffectVelocity]) -> [GlucoseEffect] { + let carbEffectStart = now.addingTimeInterval(-MissedMealSettings.maxRecency) + + var carbEffects: [GlucoseEffect] = [] + + let updateGroup = DispatchGroup() + updateGroup.enter() + carbStore.getGlucoseEffects(start: carbEffectStart, end: now, effectVelocities: insulinCounteractionEffects) { result in + defer { updateGroup.leave() } + + guard case .success((_, let effects)) = result else { + XCTFail("Failed to fetch glucose effects to check for missed meal") + return + } + carbEffects = effects + } + _ = updateGroup.wait(timeout: .now() + .seconds(5)) + + return carbEffects + } + override func tearDown() { mealDetectionManager.lastMissedMealNotification = nil mealDetectionManager = nil @@ -244,7 +250,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } @@ -256,7 +262,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } @@ -269,7 +275,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 55)) updateGroup.leave() } @@ -282,7 +288,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) updateGroup.leave() } @@ -295,31 +301,19 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 50)) updateGroup.leave() } updateGroup.wait() } - func testNoMissedMeal_AnnouncedMealPresent() { - let counteractionEffects = setUp(for: .announcedMeal) - - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() - } - func testNoisyCGM() { let counteractionEffects = setUp(for: .noisyCGM) let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } @@ -331,7 +325,7 @@ class MealDetectionManagerTests: XCTestCase { let updateGroup = DispatchGroup() updateGroup.enter() - mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects) { status in + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in XCTAssertEqual(status, .noMissedMeal) updateGroup.leave() } From 1692f554e4ecc92e2e438e140780c9d00bbb93ef Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Sat, 18 Feb 2023 13:31:03 -0800 Subject: [PATCH 49/51] Reduce `minRecency` required to detect a meal --- Loop/Managers/Missed Meal Detection/MissedMealSettings.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift b/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift index 00ad6d5249..24ff03a9a8 100644 --- a/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift +++ b/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift @@ -16,7 +16,7 @@ public struct MissedMealSettings { /// Minimum threshold for glucose rise over the detection window static let glucoseRiseThreshold = 2.0 // mg/dL/m /// Minimum time from now that must have passed for the meal to be detected - public static let minRecency = TimeInterval(minutes: 25) + public static let minRecency = TimeInterval(minutes: 15) /// Maximum time from now that a meal can be detected public static let maxRecency = TimeInterval(hours: 2) /// Maximum delay allowed in missed meal notification time to avoid From e9e2384339984141c5199d350ce3ac3a70ab1875 Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Sun, 19 Feb 2023 20:11:31 +0100 Subject: [PATCH 50/51] Update counteraction effect math in `MealDetectionManager` to skew towards an earlier meal time instead of a later one --- .../Missed Meal Detection/MealDetectionManager.swift | 3 +-- LoopTests/Managers/MealDetectionManagerTests.swift | 11 +++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index 12130f067e..5de7972225 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -101,8 +101,7 @@ class MealDetectionManager { return item } -// return GlucoseEffect(startDate: effect.endDate.dateCeiledToTimeInterval(delta), - return GlucoseEffect(startDate: effect.startDate.dateFlooredToTimeInterval(delta), + return GlucoseEffect(startDate: effect.endDate.dateCeiledToTimeInterval(delta), quantity: effect.quantity) } diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index c26654cec6..d987e3cc3f 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -73,9 +73,11 @@ extension MissedMealTestType { var missedMealDate: Date? { switch self { case .missedMealNoCOB: - return Self.dateFormatter.date(from: "2022-10-17T22:10:00") + return Self.dateFormatter.date(from: "2022-10-17T21:55:00") case .missedMealWithCOB: - return Self.dateFormatter.date(from: "2022-10-19T19:15:00") + return Self.dateFormatter.date(from: "2022-10-19T19:00:00") + case .manyMeals: + return Self.dateFormatter.date(from: "2022-10-19T20:40:00 ") case .dynamicCarbAutofill: return Self.dateFormatter.date(from: "2022-10-17T07:20:00")! default: @@ -321,12 +323,13 @@ class MealDetectionManagerTests: XCTestCase { } func testManyMeals() { - let counteractionEffects = setUp(for: .manyMeals) + let testType = MissedMealTestType.manyMeals + let counteractionEffects = setUp(for: testType) let updateGroup = DispatchGroup() updateGroup.enter() mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) updateGroup.leave() } updateGroup.wait() From cd3093f5e4aeaabb9430332522bdbbe795520d2c Mon Sep 17 00:00:00 2001 From: Anna Quinlan Date: Mon, 20 Feb 2023 07:56:20 +0100 Subject: [PATCH 51/51] Fix conflict + move missed meal toggle 1 level higher --- Loop/Views/AlertManagementView.swift | 31 +++++++++++++++++++ ...icationsCriticalAlertPermissionsView.swift | 31 ------------------- .../Managers/LoopDataManagerDosingTests.swift | 4 +-- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index de7cdc6be8..7fb8b940cd 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -49,6 +49,15 @@ struct AlertManagementView: View { private var formatterDurations: [String] { AlertMuter.allowedDurations.compactMap { formatter.string(from: $0) } } + + private var missedMealNotificationsEnabled: Binding { + Binding( + get: { UserDefaults.standard.missedMealNotificationsEnabled }, + set: { enabled in + UserDefaults.standard.missedMealNotificationsEnabled = enabled + } + ) + } public init(checker: AlertPermissionsChecker, alertMuter: AlertMuter = AlertMuter()) { self.checker = checker @@ -65,6 +74,7 @@ struct AlertManagementView: View { mutePeriodSection } } + missedMealAlertSection } .navigationTitle(NSLocalizedString("Alert Management", comment: "Title of alert management screen")) } @@ -108,6 +118,27 @@ struct AlertManagementView: View { private var muteAlertsFooterString: String { NSLocalizedString("No alerts will sound while muted. Once this period ends, your alerts and alarms will resume as normal.", comment: "Description of temporary mute alerts") } + + private var missedMealAlertSection: some View { + Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects a meal that wasn't logged.", comment: "Description of missed meal notifications."))) { + Toggle("Missed Meal Notifications", isOn: missedMealNotificationsEnabled) + } + } +} + +extension UserDefaults { + private enum Key: String { + case missedMealNotificationsEnabled = "com.loopkit.Loop.MissedMealNotificationsEnabled" + } + + var missedMealNotificationsEnabled: Bool { + get { + return object(forKey: Key.missedMealNotificationsEnabled.rawValue) as? Bool ?? false + } + set { + set(newValue, forKey: Key.missedMealNotificationsEnabled.rawValue) + } + } } struct AlertManagementView_Previews: PreviewProvider { diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index f995fc9ae9..e69084a46a 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -37,15 +37,6 @@ public struct NotificationsCriticalAlertPermissionsView: View { } } - private var missedMealNotificationsEnabled: Binding { - Binding( - get: { UserDefaults.standard.missedMealNotificationsEnabled }, - set: { enabled in - UserDefaults.standard.missedMealNotificationsEnabled = enabled - } - ) - } - private func navigationContent() -> some View { return NavigationView { content() @@ -74,7 +65,6 @@ public struct NotificationsCriticalAlertPermissionsView: View { } } } - missedMealAlertSection notificationAndCriticalAlertPermissionSupportSection } .insetGroupedListStyle() @@ -153,27 +143,6 @@ extension NotificationsCriticalAlertPermissionsView { } } } - - private var missedMealAlertSection: some View { - Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects a meal that wasn't logged.", comment: "Description of missed meal notifications."))) { - Toggle("Missed Meal Notifications", isOn: missedMealNotificationsEnabled) - } - } -} - -extension UserDefaults { - private enum Key: String { - case missedMealNotificationsEnabled = "com.loopkit.Loop.MissedMealNotificationsEnabled" - } - - var missedMealNotificationsEnabled: Bool { - get { - return object(forKey: Key.missedMealNotificationsEnabled.rawValue) as? Bool ?? false - } - set { - set(newValue, forKey: Key.missedMealNotificationsEnabled.rawValue) - } - } } diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index 02f0ca474e..8816e53f96 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -405,8 +405,8 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { let settings = LoopSettings( dosingEnabled: false, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, - maximumBasalRatePerHour: maxBasalRate, - maximumBolus: maxBolus, + maximumBasalRatePerHour: 5, + maximumBolus: 10, suspendThreshold: suspendThreshold )