diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 6ddb07b1c9..9b957f1c94 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -588,6 +588,7 @@ 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 */; }; 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 */; }; @@ -605,6 +606,18 @@ 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 */; }; + E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552129358C440076AB04 /* MealDetectionManager.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 */; }; + E9B35539293706CB0076AB04 /* dynamic_autofill_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35533293706CA0076AB04 /* dynamic_autofill_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 */; }; 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 */; }; @@ -1710,6 +1723,7 @@ 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 = ""; }; 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 = ""; }; @@ -1727,6 +1741,17 @@ 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 /* MissedMealNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedMealNotification.swift; sourceTree = ""; }; + E9B3552129358C440076AB04 /* MealDetectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetectionManager.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 = ""; }; + E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = dynamic_autofill_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 = ""; }; 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 = ""; }; @@ -1935,10 +1960,13 @@ isa = PBXGroup; children = ( 1DA7A84024476E98008257F0 /* Alerts */, + E950CA9429002E4B00B5B692 /* LoopDataManager */, C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, + E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */, + E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */, 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */, A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */, ); @@ -2252,6 +2280,7 @@ 4B60626A287E286000BF8BBB /* Localizable.strings */, E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, C16575742539FD60004AE16E /* LoopCoreConstants.swift */, + E9B3551B292844010076AB04 /* MissedMealNotification.swift */, C1D0B62F2986D4D90098D215 /* LocalizedString.swift */, ); path = LoopCore; @@ -2419,6 +2448,7 @@ 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */, 1DA6499D2441266400F61E75 /* Alerts */, E95D37FF24EADE68005E2F50 /* Store Protocols */, + E9B355232935906B0076AB04 /* Missed Meal Detection */, C1F2075B26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift */, A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, ); @@ -2918,6 +2948,7 @@ E93E86B124DDE21D00FF40C8 /* MockCarbStore.swift */, E98A55F024EDD85E0008715D /* MockDosingDecisionStore.swift */, E98A55F224EDD9530008715D /* MockSettingsStore.swift */, + E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */, ); path = "Mock Stores"; sourceTree = ""; @@ -2946,6 +2977,13 @@ path = high_and_stable; sourceTree = ""; }; + E950CA9429002E4B00B5B692 /* LoopDataManager */ = { + isa = PBXGroup; + children = ( + ); + path = LoopDataManager; + sourceTree = ""; + }; E95D37FF24EADE68005E2F50 /* Store Protocols */ = { isa = PBXGroup; children = ( @@ -2971,9 +3009,32 @@ path = "Loop Intent Extension"; sourceTree = ""; }; + E9B355232935906B0076AB04 /* Missed Meal Detection */ = { + isa = PBXGroup; + children = ( + E9B3552129358C440076AB04 /* MealDetectionManager.swift */, + E9B35525293590980076AB04 /* MissedMealSettings.swift */, + ); + path = "Missed Meal Detection"; + sourceTree = ""; + }; + E9B355312937068A0076AB04 /* meal_detection */ = { + isa = PBXGroup; + children = ( + E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */, + E9B35532293706CA0076AB04 /* needs_clamping_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 */, + ); + 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 */, @@ -3429,9 +3490,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 */, @@ -3552,6 +3613,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 /* 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 */, E90909DC24E34F1600F963D2 /* low_and_falling_predicted_glucose.json in Resources */, @@ -3566,10 +3630,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 */, @@ -3746,6 +3813,7 @@ C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */, A9B996F227238705002DC09C /* DosingDecisionStore.swift in Sources */, 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, + E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */, 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, 4372E48B213CB5F00068E043 /* Double.swift in Sources */, @@ -3860,6 +3928,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 */, @@ -4017,6 +4086,7 @@ A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */, 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, + E9B3552B293591E70076AB04 /* MissedMealNotification.swift in Sources */, 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4069,6 +4139,7 @@ 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */, 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, + E9B3552A293591E70076AB04 /* MissedMealNotification.swift in Sources */, 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4110,6 +4181,8 @@ 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */, A9BD28E7272226B40071DF15 /* TestLocalizedError.swift in Sources */, A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */, + E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */, + E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */, B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */, A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */, @@ -4123,6 +4196,7 @@ E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */, A9E8A80528A7CAC000C0A8A4 /* RemoteCommandTests.swift in Sources */, 1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.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/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 10217dc51a..61e6428245 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1307,6 +1307,10 @@ extension DeviceDataManager: LoopDataManagerDelegate { return rounded } + + func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? { + pumpManager?.estimatedDuration(toBolus: units) + } func loopDataManager( _ manager: LoopDataManager, diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 4a350635e7..6a160790ab 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -484,7 +484,8 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { LoopNotificationCategory.remoteBolus.rawValue, LoopNotificationCategory.remoteBolusFailure.rawValue, LoopNotificationCategory.remoteCarbs.rawValue, - LoopNotificationCategory.remoteCarbsFailure.rawValue: + LoopNotificationCategory.remoteCarbsFailure.rawValue, + LoopNotificationCategory.missedMeal.rawValue: completionHandler([.badge, .sound, .list, .banner]) default: // For all others, banners are not to be displayed while in the foreground @@ -516,6 +517,28 @@ 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.missedMeal.rawValue else { + break + } + + let carbActivity = NSUserActivity.forNewCarbEntry() + let userInfo = response.notification.request.content.userInfo + + if + let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, + let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double + { + let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + doubleValue: carbAmount), + startDate: mealTime, + foodType: nil, + absorptionTime: nil) + carbActivity.update(from: missedEntry, isMissedMeal: true) + } + + rootViewController?.restoreUserActivityState(carbActivity) + default: break } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 4c82d61892..18a0816621 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 @@ -109,6 +111,11 @@ final class LoopDataManager { self.now = now self.latestStoredSettingsProvider = latestStoredSettingsProvider + self.mealDetectionManager = MealDetectionManager( + carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, + insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, + maximumBolus: settings.maximumBolus + ) self.lockedPumpInsulinType = Locked(pumpInsulinType) @@ -259,11 +266,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() } @@ -278,6 +290,7 @@ final class LoopDataManager { if newValue.carbRatioSchedule != oldValue.carbRatioSchedule { carbStore.carbRatioSchedule = newValue.carbRatioSchedule + mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory invalidateCachedEffects = true analyticsServicesManager.didChangeCarbRatioSchedule() } @@ -292,6 +305,10 @@ final class LoopDataManager { analyticsServicesManager.didChangeInsulinModel() } + if newValue.maximumBolus != oldValue.maximumBolus { + mealDetectionManager.maximumBolus = newValue.maximumBolus + } + if invalidateCachedEffects { dataAccessQueue.async { // Invalidate cached effects based on this schedule @@ -857,6 +874,28 @@ extension LoopDataManager { logger.default("Loop ended") notify(forChange: .loopFinished) + let carbEffectStart = now().addingTimeInterval(-MissedMealSettings.maxRecency) + carbStore.getGlucoseEffects(start: carbEffectStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in + 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") @@ -1764,7 +1803,6 @@ extension LoopDataManager { } } } - } /// Describes a view into the loop state @@ -2104,16 +2142,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")) + } } } } @@ -2147,7 +2190,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 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/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift new file mode 100644 index 0000000000..5de7972225 --- /dev/null +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -0,0 +1,296 @@ +// +// 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 MissedMealStatus: Equatable { + case hasMissedMeal(startTime: Date, carbAmount: Double) + case noMissedMeal +} + +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 + /// Internal for unit testing + var lastMissedMealNotification: MissedMealNotification? = UserDefaults.standard.lastMissedMealNotification { + didSet { + UserDefaults.standard.lastMissedMealNotification = lastMissedMealNotification + } + } + + /// 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 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? + + /// 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( + carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule?, + insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule?, + maximumBolus: Double?, + test_currentDate: Date? = nil + ) { + self.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory + self.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory + self.maximumBolus = maximumBolus + self.test_currentDate = test_currentDate + } + + // MARK: Meal Detection + 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 + + 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 = filteredCarbEffects.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 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 + } + + 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.endDate.dateCeiledToTimeInterval(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 + } + + 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) + + 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? { + 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: MissedMealSettings.minCarbThreshold, through: MissedMealSettings.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? { + guard + let carbRatio = carbRatioScheduleApplyingOverrideHistory?.value(at: mealStart), + let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory?.value(at: mealStart) + else { + 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( + insulinCounteractionEffects: [GlucoseEffectVelocity], + carbEffects: [GlucoseEffect], + pendingAutobolusUnits: Double? = nil, + bolusDurationEstimator: @escaping (Double) -> TimeInterval? + ) { + hasMissedMeal(insulinCounteractionEffects: insulinCounteractionEffects, carbEffects: carbEffects) {[weak self] status in + self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits, bolusDurationEstimator: bolusDurationEstimator) + } + } + + + // Internal for unit testing + 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(lastMissedMealNotification?.deliveryTime ?? .distantPast) < (MissedMealSettings.maxRecency - MissedMealSettings.minRecency) + + guard + case .hasMissedMeal(let startTime, let carbAmount) = status, + !notificationTimeTooRecent, + UserDefaults.standard.missedMealNotificationsEnabled + else { + // No notification needed! + return + } + + var clampedCarbAmount = carbAmount + if + let maxBolus = maximumBolus, + let currentCarbRatio = carbRatioScheduleApplyingOverrideHistory?.quantity(at: now).doubleValue(for: .gram()) + { + let maxAllowedCarbAutofill = maxBolus * currentCarbRatio + clampedCarbAmount = min(clampedCarbAmount, maxAllowedCarbAutofill) + } + + log.debug("Delivering a missed meal notification") + + /// 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 < MissedMealSettings.maxNotificationDelay + { + NotificationManager.sendMissedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) + lastMissedMealNotification = MissedMealNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), + carbAmount: clampedCarbAmount) + } else { + NotificationManager.sendMissedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount) + lastMissedMealNotification = MissedMealNotification(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", + "", + "* 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") + }), + "* 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") + }) + ] + + completionHandler(report.joined(separator: "\n")) + } +} diff --git a/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift b/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift new file mode 100644 index 0000000000..24ff03a9a8 --- /dev/null +++ b/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift @@ -0,0 +1,25 @@ +// +// MissedMealSettings.swift +// Loop +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation + +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 + 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: 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 + /// notifying the user during an autobolus + public static let maxNotificationDelay = TimeInterval(minutes: 4) +} diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 1c92ffb7f2..0b23364f85 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 { @@ -218,6 +219,68 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } + 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 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.missedMealTime.rawValue: mealStart, + LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue: amountInGrams, + 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.missedMeal.rawValue, + content: notification, + trigger: notificationTrigger + ) + + UNUserNotificationCenter.current().add(request) + } + + static func removeExpiredMealNotifications(now: Date = Date()) { + let notificationCenter = UNUserNotificationCenter.current() + var identifiersToRemove: [String] = [] + + notificationCenter.getDeliveredNotifications { notifications in + for notification in notifications { + let request = notification.request + + guard + request.identifier == LoopNotificationCategory.missedMeal.rawValue, + let expirationDate = request.content.userInfo[LoopNotificationUserInfoKey.expirationDate.rawValue] as? Date, + expirationDate < now + else { + continue + } + + /// 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.removeDeliveredNotifications(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)) } diff --git a/Loop/View Controllers/CarbEntryViewController.swift b/Loop/View Controllers/CarbEntryViewController.swift index 08355a2c83..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 shouldDisplayAccurateCarbEntryWarning = false { + private var shouldDisplayMissedMealWarning = false { didSet { - if shouldDisplayAccurateCarbEntryWarning != oldValue { + if shouldDisplayMissedMealWarning != oldValue { if shouldDisplayOverrideEnabledWarning { - self.displayWarningRow(rowType: WarningRow.carbEntry, isAddingRow: shouldDisplayAccurateCarbEntryWarning) + self.displayWarningRow(rowType: WarningRow.missedMeal, isAddingRow: shouldDisplayMissedMealWarning) } else { - self.shouldDisplayWarning = shouldDisplayAccurateCarbEntryWarning || shouldDisplayOverrideEnabledWarning + self.shouldDisplayWarning = shouldDisplayMissedMealWarning || shouldDisplayOverrideEnabledWarning } } } @@ -123,10 +123,10 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable private var shouldDisplayOverrideEnabledWarning = false { didSet { if shouldDisplayOverrideEnabledWarning != oldValue { - if shouldDisplayAccurateCarbEntryWarning { + if shouldDisplayMissedMealWarning { self.displayWarningRow(rowType: WarningRow.override, isAddingRow: shouldDisplayOverrideEnabledWarning) } else { - self.shouldDisplayWarning = shouldDisplayOverrideEnabledWarning || shouldDisplayAccurateCarbEntryWarning + self.shouldDisplayWarning = shouldDisplayOverrideEnabledWarning || shouldDisplayMissedMealWarning } } } @@ -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 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) @@ -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, 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 @@ -364,7 +334,7 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable } fileprivate enum WarningRow: Int, CaseIterable { - case carbEntry + case missedMeal 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, 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 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 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 { @@ -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 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 @@ -562,6 +532,10 @@ final class CarbEntryViewController: LoopChartsTableViewController, Identifiable self.absorptionTime = absorptionTime absorptionTimeWasEdited = true } + + if activity.entryisMissedMeal { + shouldDisplayMissedMealWarning = true + } } } 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 79eb9bd4e8..e69084a46a 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -143,9 +143,9 @@ extension NotificationsCriticalAlertPermissionsView { } } } - } + struct NotificationsCriticalAlertPermissionsView_Previews: PreviewProvider { static var previews: some View { return Group { diff --git a/LoopCore/MissedMealNotification.swift b/LoopCore/MissedMealNotification.swift new file mode 100644 index 0000000000..2cbf61b0e5 --- /dev/null +++ b/LoopCore/MissedMealNotification.swift @@ -0,0 +1,20 @@ +// +// MissedMealNotification.swift +// Loop +// +// Created by Anna Quinlan on 11/18/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Information about a missed meal notification +public struct MissedMealNotification: 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/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index 81aa90b52b..da129160bd 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 LastMissedMealNotification = "com.loopkit.Loop.lastMissedMealNotification" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -114,6 +115,29 @@ extension UserDefaults { } } + public var lastMissedMealNotification: MissedMealNotification? { + get { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.LastMissedMealNotification.rawValue) as? Data else { + return nil + } + 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.LastMissedMealNotification.rawValue) + } else { + set(nil, forKey: Key.LastMissedMealNotification.rawValue) + } + } catch { + assertionFailure("Unable to encode MissedMealNotification") + } + } + } + public var allowDebugFeatures: Bool { return bool(forKey: Key.allowDebugFeatures.rawValue) } 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/missed_meal_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/missed_meal_counteraction_effect.json new file mode 100644 index 0000000000..64b5f498a4 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/missed_meal_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 + } +] 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/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 7ab7add2e4..72359793e6 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] @@ -57,7 +59,6 @@ class MockPumpManager: PumpManager { var pumpRecordsBasalProfileStartEvents: Bool = false var pumpReservoirCapacity: Double = 50 - var deliveryUnitsPerMinute = 1.5 var lastSync: Date? @@ -115,8 +116,8 @@ class MockPumpManager: PumpManager { func syncDeliveryLimits(limits deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { } - - public func estimatedDuration(toBolus units: Double) -> TimeInterval { + + func estimatedDuration(toBolus units: Double) -> TimeInterval { .minutes(units / deliveryUnitsPerMinute) } diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift new file mode 100644 index 0000000000..8816e53f96 --- /dev/null +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -0,0 +1,459 @@ +// +// 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 MockDelegate: LoopDataManagerDelegate { + let pumpManager = MockPumpManager() + + var bolusUnits: Double? + func loopDataManager(_ manager: Loop.LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? { + self.bolusUnits = units + return pumpManager.estimatedDuration(toBolus: units) + } + + 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? +} + +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) + } + + 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: 5, + maximumBolus: 10, + 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 a11b210228..054d5704c3 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -59,7 +59,7 @@ extension ISO8601DateFormatter { } } -class LoopDataManagerDosingTests: XCTestCase { +class LoopDataManagerTests: XCTestCase { // MARK: Constants for testing let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) let retrospectiveCorrectionGroupingInterval = 1.01 @@ -83,17 +83,26 @@ class LoopDataManagerDosingTests: XCTestCase { } // MARK: Mock stores + var now: Date! var dosingDecisionStore: MockDosingDecisionStore! var automaticDosingStatus: AutomaticDosingStatus! var loopDataManager: LoopDataManager! func setUp(for test: DataManagerTestType, basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil, maxBolus: Double = 10, maxBasalRate: Double = 5.0) { 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, @@ -107,6 +116,7 @@ class LoopDataManagerDosingTests: XCTestCase { let carbStore = MockCarbStore(for: test) let currentDate = glucoseStore.latestGlucose!.startDate + now = currentDate dosingDecisionStore = MockDosingDecisionStore() automaticDosingStatus = AutomaticDosingStatus(isClosedLoop: true, isClosedLoopAllowed: true) @@ -132,522 +142,9 @@ class LoopDataManagerDosingTests: 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 { Double(Int(unitsPerHour / 0.05)) * 0.05 } - func roundBolusVolume(units: Double) -> Double { Double(Int(units / 0.05)) * 0.05 } - 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.55, 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.55, 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: 5, - maximumBolus: 10, - 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) - } - - func testAutoBolusMaxIOBClamping() { - /// `maximumBolus` is set to clamp the automatic dose - /// Autobolus without clamping: 0.65 U. Clamped recommendation: 0.2 U. - setUp(for: .autoBolusIOBClamping, maxBolus: 5) - - // This sets up dose rounding - let delegate = MockDelegate() - loopDataManager.delegate = delegate - - let updateGroup = DispatchGroup() - updateGroup.enter() - - var insulinOnBoard: InsulinValue? - var recommendedBolus: Double? - self.loopDataManager.getLoopState { _, state in - insulinOnBoard = state.insulinOnBoard - recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits - updateGroup.leave() - } - updateGroup.wait() - - XCTAssertEqual(recommendedBolus!, 0.5, accuracy: 0.01) - XCTAssertEqual(insulinOnBoard?.value, 9.47) - - /// Set the `maximumBolus` to 10U so there's no clamping - updateGroup.enter() - self.loopDataManager.mutateSettings { settings in settings.maximumBolus = 10 } - self.loopDataManager.getLoopState { _, state in - insulinOnBoard = state.insulinOnBoard - recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits - updateGroup.leave() - } - updateGroup.wait() - - XCTAssertEqual(recommendedBolus!, 0.65, accuracy: 0.01) - XCTAssertEqual(insulinOnBoard?.value, 9.47) - } - - func testTempBasalMaxIOBClamping() { - /// `maximumBolus` is set to 5U to clamp max IOB at 10U - /// Without clamping: 4.25 U/hr. Clamped recommendation: 1.25 U/hr. - setUp(for: .tempBasalIOBClamping, maxBolus: 5) - - // This sets up dose rounding - let delegate = MockDelegate() - loopDataManager.delegate = delegate - - let updateGroup = DispatchGroup() - updateGroup.enter() - - var insulinOnBoard: InsulinValue? - var recommendedBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - insulinOnBoard = state.insulinOnBoard - recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - updateGroup.wait() - - XCTAssertEqual(recommendedBasal!.unitsPerHour, 1.25, accuracy: 0.01) - XCTAssertEqual(insulinOnBoard?.value, 9.87) - - /// Set the `maximumBolus` to 10U so there's no clamping - updateGroup.enter() - self.loopDataManager.mutateSettings { settings in settings.maximumBolus = 10 } - self.loopDataManager.getLoopState { _, state in - insulinOnBoard = state.insulinOnBoard - recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - updateGroup.wait() - - XCTAssertEqual(recommendedBasal!.unitsPerHour, 4.25, accuracy: 0.01) - XCTAssertEqual(insulinOnBoard?.value, 9.87) - } - } -extension LoopDataManagerDosingTests { +extension LoopDataManagerTests { public var bundle: Bundle { return Bundle(for: type(of: self)) } diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift new file mode 100644 index 0000000000..d987e3cc3f --- /dev/null +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -0,0 +1,450 @@ +// +// 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 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 missed meal + case noMealCounteractionEffectsNeedClamping + // No meal is present and there is COB + case noMealWithCOB + /// Missed meal with no carbs on board + case missedMealNoCOB + /// Missed meal with carbs logged prior to it + case missedMealWithCOB + /// 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 MissedMealTestType { + var counteractionEffectFixture: String { + switch self { + case .missedMealNoCOB, .noMealWithCOB, .notificationTest: + return "missed_meal_counteraction_effect" + case .noMeal: + return "long_interval_counteraction_effect" + case .noMealCounteractionEffectsNeedClamping: + return "needs_clamping_counteraction_effect" + case .noisyCGM: + return "noisy_cgm_counteraction_effect" + case .manyMeals, .missedMealWithCOB: + return "realistic_report_counteraction_effect" + case .dynamicCarbAutofill: + return "dynamic_autofill_counteraction_effect" + } + } + + var currentDate: Date { + switch self { + case .missedMealNoCOB, .noMealWithCOB, .notificationTest: + return Self.dateFormatter.date(from: "2022-10-17T23:28:45")! + 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")! + case .missedMealWithCOB: + 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 missedMealDate: Date? { + switch self { + case .missedMealNoCOB: + return Self.dateFormatter.date(from: "2022-10-17T21:55:00") + case .missedMealWithCOB: + 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: + return nil + } + } + + var carbEntries: [NewCarbEntry] { + switch self { + case .missedMealWithCOB: + 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 .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: XCTestCase { + let dateFormatter = ISO8601DateFormatter.localTimeDate() + let pumpManager = MockPumpManager() + + var mealDetectionManager: MealDetectionManager! + var carbStore: CarbStore! + + var now: Date { + mealDetectionManager.test_currentDate! + } + + var bolusUnits: Double? + var bolusDurationEstimator: ((Double) -> TimeInterval?)! + + @discardableResult func setUp(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { + let healthStore = HKHealthStoreMock() + + 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( + carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, + insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, + 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: MissedMealTestType) -> [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)) + } + } + + 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 + UserDefaults.standard.missedMealNotificationsEnabled = false + } + + // MARK: - Algorithm Tests + func testNoMissedMeal() { + let counteractionEffects = setUp(for: .noMeal) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .noMissedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testNoMissedMeal_WithCOB() { + let counteractionEffects = setUp(for: .noMealWithCOB) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .noMissedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testMissedMeal_NoCarbEntry() { + let testType = MissedMealTestType.missedMealNoCOB + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 55)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testDynamicCarbAutofill() { + let testType = MissedMealTestType.dynamicCarbAutofill + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testMissedMeal_MissedMealAndCOB() { + let testType = MissedMealTestType.missedMealWithCOB + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 50)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testNoisyCGM() { + let counteractionEffects = setUp(for: .noisyCGM) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .noMissedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testManyMeals() { + 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, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) + updateGroup.leave() + } + updateGroup.wait() + } + + // MARK: - Notification Tests + func testNoMissedMealLastNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let status = MissedMealStatus.noMissedMeal + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertNil(mealDetectionManager.lastMissedMealNotification) + } + + func testMissedMealUpdatesLastNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) + } + + func testMissedMealWithoutNotificationsEnabled() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = false + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertNil(mealDetectionManager.lastMissedMealNotification) + } + + func testMissedMealWithTooRecentNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let oldTime = now.addingTimeInterval(.hours(1)) + let oldNotification = MissedMealNotification(deliveryTime: oldTime, carbAmount: 40) + mealDetectionManager.lastMissedMealNotification = oldNotification + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: MissedMealSettings.minCarbThreshold) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification, oldNotification) + } + + func testMissedMealCarbClamping() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 120) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 75) + } + + func testMissedMealNoPendingBolus() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + 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.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) + } + + func testMissedMealLongPendingBolus() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + 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.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) + } + + func testNoMissedMealShortPendingBolus_DelaysNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + 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.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime) + + 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.lastMissedMealNotification?.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/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 39ec1377a9..b23dc56680 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? @@ -96,7 +97,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 20fd1b45fc..1ef1d13d75 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,39 @@ 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.missedMeal.rawValue, + let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController + else { + break + } + + 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.missedMealTime.rawValue] as? Date, + let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double + { + let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + doubleValue: carbAmount), + startDate: mealTime, + foodType: nil, + absorptionTime: nil) + statusController.addCarbs(initialEntry: missedEntry) + // Otherwise, just provide the ability to add carbs + } else { + statusController.addCarbs() + } + default: + break + } + + completionHandler() + } + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.badge, .sound, .list, .banner]) } 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() } }