diff --git a/Common/Models/LoopSettingsUserInfo.swift b/Common/Models/LoopSettingsUserInfo.swift index a6123825d8..bf95b076b4 100644 --- a/Common/Models/LoopSettingsUserInfo.swift +++ b/Common/Models/LoopSettingsUserInfo.swift @@ -6,10 +6,67 @@ // import LoopCore +import LoopKit +struct LoopSettingsUserInfo: Equatable { + var loopSettings: LoopSettings + var scheduleOverride: TemporaryScheduleOverride? + var preMealOverride: TemporaryScheduleOverride? + + public mutating func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { + preMealOverride = makePreMealOverride(beginningAt: date, for: duration) + } + + private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let preMealTargetRange = loopSettings.preMealTargetRange else { + return nil + } + return TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), + startDate: date, + duration: .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public mutating func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { + if context == .preMeal { + preMealOverride = nil + return + } + + guard let scheduleOverride = scheduleOverride else { return } + + if let context = context { + if scheduleOverride.context == context { + self.scheduleOverride = nil + } + } else { + self.scheduleOverride = nil + } + } + + public func nonPreMealOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public mutating func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let legacyWorkoutTargetRange = loopSettings.legacyWorkoutTargetRange else { + return nil + } + + return TemporaryScheduleOverride( + context: .legacyWorkout, + settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), + startDate: date, + duration: duration.isInfinite ? .indefinite : .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } -struct LoopSettingsUserInfo { - let settings: LoopSettings } @@ -23,19 +80,36 @@ extension LoopSettingsUserInfo: RawRepresentable { guard rawValue["v"] as? Int == LoopSettingsUserInfo.version, rawValue["name"] as? String == LoopSettingsUserInfo.name, let settingsRaw = rawValue["s"] as? LoopSettings.RawValue, - let settings = LoopSettings(rawValue: settingsRaw) + let loopSettings = LoopSettings(rawValue: settingsRaw) else { return nil } - self.settings = settings + self.loopSettings = loopSettings + + if let rawScheduleOverride = rawValue["o"] as? TemporaryScheduleOverride.RawValue { + self.scheduleOverride = TemporaryScheduleOverride(rawValue: rawScheduleOverride) + } else { + self.scheduleOverride = nil + } + + if let rawPreMealOverride = rawValue["p"] as? TemporaryScheduleOverride.RawValue { + self.preMealOverride = TemporaryScheduleOverride(rawValue: rawPreMealOverride) + } else { + self.preMealOverride = nil + } } var rawValue: RawValue { - return [ + var raw: RawValue = [ "v": LoopSettingsUserInfo.version, "name": LoopSettingsUserInfo.name, - "s": settings.rawValue + "s": loopSettings.rawValue ] + + raw["o"] = scheduleOverride?.rawValue + raw["p"] = preMealOverride?.rawValue + + return raw } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 652dc0039e..ab382ca1de 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -59,7 +59,6 @@ 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D80313C24746274002810DF /* AlertStoreTests.swift */; }; 1D82E6A025377C6B009131FB /* TrustedTimeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */; }; 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */; }; - 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */; }; 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */; }; 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */; }; 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7A84124476EAD008257F0 /* AlertManagerTests.swift */; }; @@ -93,8 +92,6 @@ 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628120A7A37E00C4BE6F /* CoreBluetooth.framework */; }; 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4344629120A7C19800C4BE6F /* ButtonGroup.swift */; }; 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; - 4345E3F421F036FC009E00E5 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D848AF1E7DCBE100DADCBC /* Result.swift */; }; - 4345E3F521F036FC009E00E5 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D848AF1E7DCBE100DADCBC /* Result.swift */; }; 4345E3FB21F04911009E00E5 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */; }; @@ -271,7 +268,6 @@ 895788B3242E69A2002CB114 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788AB242E69A2002CB114 /* ActionButton.swift */; }; 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */; }; 8968B1122408B3520074BB48 /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8968B1112408B3520074BB48 /* UIFont.swift */; }; - 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */; }; 897A5A9624C2175B00C4E71D /* BolusEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */; }; 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */; }; 898ECA60218ABD17001E9D35 /* GlucoseChartScaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */; }; @@ -293,7 +289,6 @@ 89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */; }; 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */; }; 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */; }; - 89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */; }; 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */; }; 89D1503E24B506EB00EDE253 /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D1503D24B506EB00EDE253 /* Dictionary.swift */; }; 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */; }; @@ -415,6 +410,7 @@ C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */; }; C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; C1201E2D23ECDF3D002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; + C129BF4A2B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */; }; C13072BA2A76AF31009A7C58 /* live_capture_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */; }; C13255D6223E7BE2008AF50C /* BolusProgressTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */; }; C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13DA2AF24F6C7690098BB29 /* UIViewController.swift */; }; @@ -443,7 +439,13 @@ C17824A51E1AD4D100D9D25C /* ManualBolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */; }; C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; - C18913B52524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */; }; + C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */; }; + C188599E2AF15FAB0010F21F /* AlertMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599D2AF15FAB0010F21F /* AlertMocks.swift */; }; + C18859A02AF1612B0010F21F /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599F2AF1612B0010F21F /* PersistenceController.swift */; }; + C18859A22AF165130010F21F /* MockPumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A12AF165130010F21F /* MockPumpManager.swift */; }; + C18859A42AF165330010F21F /* MockCGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A32AF165330010F21F /* MockCGMManager.swift */; }; + C18859A82AF292D90010F21F /* MockTrustedTimeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A72AF292D90010F21F /* MockTrustedTimeChecker.swift */; }; + C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */; }; C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */; }; C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */; }; C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */; }; @@ -463,6 +465,7 @@ C19F48742560ABFB003632D7 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; C1AD4200256D61E500164DDD /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AD41FF256D61E500164DDD /* Comparable.swift */; }; C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */; }; + C1B80D632AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */; }; C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */; }; C1C73F0D1DE3D0270022FC89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */; }; C1CCF1122858FA900035389C /* LoopCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */; }; @@ -472,6 +475,11 @@ C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */; }; C1D476B42A8ED179002C1C87 /* LoopAlgorithmTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */; }; C1D6EEA02A06C7270047DE5C /* MKRingProgressView in Frameworks */ = {isa = PBXBuildFile; productRef = C1D6EE9F2A06C7270047DE5C /* MKRingProgressView */; }; + C1DA434F2B164C6C00CBD33F /* MockSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA434E2B164C6C00CBD33F /* MockSettingsProvider.swift */; }; + C1DA43532B19310A00CBD33F /* LoopControlMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43522B19310A00CBD33F /* LoopControlMock.swift */; }; + C1DA43552B193BCB00CBD33F /* MockUploadEventListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43542B193BCB00CBD33F /* MockUploadEventListener.swift */; }; + C1DA43572B1A70BE00CBD33F /* SettingsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */; }; + C1DA43592B1A784900CBD33F /* MockDeliveryDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43582B1A784900CBD33F /* MockDeliveryDelegate.swift */; }; C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */; }; C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1E2773D224177C000354103 /* ClockKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */; }; @@ -493,46 +501,15 @@ DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */; }; DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */; }; DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */; }; - E90909D124E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CC24E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json */; }; - E90909D224E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CD24E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json */; }; - E90909D324E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CE24E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json */; }; - E90909D424E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CF24E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json */; }; - E90909D524E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D024E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json */; }; - E90909DC24E34F1600F963D2 /* low_and_falling_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D724E34F1500F963D2 /* low_and_falling_predicted_glucose.json */; }; - E90909DD24E34F1600F963D2 /* low_and_falling_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D824E34F1500F963D2 /* low_and_falling_carb_effect.json */; }; - E90909DE24E34F1600F963D2 /* low_and_falling_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D924E34F1500F963D2 /* low_and_falling_counteraction_effect.json */; }; - E90909DF24E34F1600F963D2 /* low_and_falling_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909DA24E34F1600F963D2 /* low_and_falling_insulin_effect.json */; }; - E90909E024E34F1600F963D2 /* low_and_falling_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909DB24E34F1600F963D2 /* low_and_falling_momentum_effect.json */; }; - E90909E724E3530200F963D2 /* low_with_low_treatment_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E224E3530200F963D2 /* low_with_low_treatment_carb_effect.json */; }; - E90909E824E3530200F963D2 /* low_with_low_treatment_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E324E3530200F963D2 /* low_with_low_treatment_insulin_effect.json */; }; - E90909E924E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E424E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json */; }; - E90909EA24E3530200F963D2 /* low_with_low_treatment_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E524E3530200F963D2 /* low_with_low_treatment_momentum_effect.json */; }; - E90909EB24E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E624E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json */; }; - E90909EE24E35B4000F963D2 /* high_and_falling_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909ED24E35B4000F963D2 /* high_and_falling_predicted_glucose.json */; }; - E90909F224E35B4D00F963D2 /* high_and_falling_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909EF24E35B4C00F963D2 /* high_and_falling_counteraction_effect.json */; }; - E90909F324E35B4D00F963D2 /* high_and_falling_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909F024E35B4C00F963D2 /* high_and_falling_carb_effect.json */; }; - E90909F424E35B4D00F963D2 /* high_and_falling_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909F124E35B4C00F963D2 /* high_and_falling_insulin_effect.json */; }; - E90909F624E35B7C00F963D2 /* high_and_falling_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909F524E35B7C00F963D2 /* high_and_falling_momentum_effect.json */; }; E93E865424DB6CBA00FF40C8 /* retrospective_output.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E865324DB6CBA00FF40C8 /* retrospective_output.json */; }; E93E865624DB731900FF40C8 /* predicted_glucose_without_retrospective.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E865524DB731900FF40C8 /* predicted_glucose_without_retrospective.json */; }; E93E865824DB75BE00FF40C8 /* predicted_glucose_very_negative.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E865724DB75BD00FF40C8 /* predicted_glucose_very_negative.json */; }; E93E86A824DDCC4400FF40C8 /* MockDoseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93E86A724DDCC4400FF40C8 /* MockDoseStore.swift */; }; E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93E86AF24DDE1BD00FF40C8 /* MockGlucoseStore.swift */; }; E93E86B224DDE21D00FF40C8 /* MockCarbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93E86B124DDE21D00FF40C8 /* MockCarbStore.swift */; }; - E93E86BA24E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B424E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json */; }; - E93E86BB24E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B524E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json */; }; - E93E86BC24E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B624E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json */; }; - E93E86BE24E1FDC400FF40C8 /* flat_and_stable_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B824E1FDC400FF40C8 /* flat_and_stable_carb_effect.json */; }; - E93E86C324E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C224E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json */; }; - E93E86CA24E2E02200FF40C8 /* high_and_stable_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C524E2E02200FF40C8 /* high_and_stable_insulin_effect.json */; }; - E93E86CB24E2E02200FF40C8 /* high_and_stable_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C624E2E02200FF40C8 /* high_and_stable_carb_effect.json */; }; - E93E86CC24E2E02200FF40C8 /* high_and_stable_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C724E2E02200FF40C8 /* high_and_stable_predicted_glucose.json */; }; - E93E86CD24E2E02200FF40C8 /* high_and_stable_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C824E2E02200FF40C8 /* high_and_stable_counteraction_effect.json */; }; - E93E86CE24E2E02200FF40C8 /* high_and_stable_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */; }; 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 */; }; @@ -776,7 +753,6 @@ 1D80313C24746274002810DF /* AlertStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStoreTests.swift; sourceTree = ""; }; 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedTimeChecker.swift; sourceTree = ""; }; 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryViewModelTests.swift; sourceTree = ""; }; - 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+BolusEntryViewModelDelegate.swift"; sourceTree = ""; }; 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCriticalAlertPermissionsView.swift; sourceTree = ""; }; 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertScheduler.swift; sourceTree = ""; }; 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppModalAlertScheduler.swift; sourceTree = ""; }; @@ -903,7 +879,6 @@ 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderValuesTableViewCell.swift; sourceTree = ""; }; 43D533BB1CFD1DD7009E3085 /* WatchApp Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = "WatchApp Extension.entitlements"; sourceTree = ""; }; - 43D848AF1E7DCBE100DADCBC /* Result.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; 43D9002A21EB209400AF44BF /* LoopCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 43D9002C21EB225D00AF44BF /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/HealthKit.framework; sourceTree = DEVELOPER_DIR; }; 43D9F81721EC51CC000578CD /* DateEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateEntry.swift; sourceTree = ""; }; @@ -1203,7 +1178,6 @@ 895788AB242E69A2002CB114 /* ActionButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideSelectionViewController.swift; sourceTree = ""; }; 8968B1112408B3520074BB48 /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; - 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettingsTests.swift; sourceTree = ""; }; 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryView.swift; sourceTree = ""; }; 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryViewModel.swift; sourceTree = ""; }; 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseChartScaler.swift; sourceTree = ""; }; @@ -1226,7 +1200,6 @@ 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosManager.swift; sourceTree = ""; }; 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryObserver.swift; sourceTree = ""; }; 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosTableViewController.swift; sourceTree = ""; }; - 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTestingScenariosManager.swift; sourceTree = ""; }; 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictedGlucoseChartView.swift; sourceTree = ""; }; 89D1503D24B506EB00EDE253 /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; }; 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotentialCarbEntryTableViewCell.swift; sourceTree = ""; }; @@ -1415,6 +1388,7 @@ C122DEFE29BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/ckcomplication.strings; sourceTree = ""; }; C122DEFF29BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; C122DF0029BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryPresetsManagerTests.swift; sourceTree = ""; }; C12BCCF929BBFA480066A158 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; C12CB9AC23106A3C00F84978 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Intents.strings; sourceTree = ""; }; C12CB9AE23106A5C00F84978 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Intents.strings; sourceTree = ""; }; @@ -1459,10 +1433,16 @@ C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManualBolusRecommendation.swift; sourceTree = ""; }; C1814B85225E507C008D2D8E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; C186B73F298309A700F83024 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataManagerTests.swift; sourceTree = ""; }; + C188599D2AF15FAB0010F21F /* AlertMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMocks.swift; sourceTree = ""; }; + C188599F2AF1612B0010F21F /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + C18859A12AF165130010F21F /* MockPumpManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPumpManager.swift; sourceTree = ""; }; + C18859A32AF165330010F21F /* MockCGMManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCGMManager.swift; sourceTree = ""; }; + C18859A72AF292D90010F21F /* MockTrustedTimeChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTrustedTimeChecker.swift; sourceTree = ""; }; + C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryPresetsManager.swift; sourceTree = ""; }; C18886E629830A5E004C982D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C18886E729830A5E004C982D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; C18886E829830A5E004C982D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/ckcomplication.strings; sourceTree = ""; }; - C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+SimpleBolusViewModelDelegate.swift"; sourceTree = ""; }; C18A491222FCC22800FDA733 /* build-derived-assets.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "build-derived-assets.sh"; sourceTree = ""; }; C18A491322FCC22900FDA733 /* make_scenario.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = make_scenario.py; sourceTree = ""; }; C18A491522FCC22900FDA733 /* copy-plugins.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "copy-plugins.sh"; sourceTree = ""; }; @@ -1508,6 +1488,7 @@ C1B2679B2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; C1B2679C2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/ckcomplication.strings; sourceTree = ""; }; C1B2679D2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; + C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopDataManager+CarbAbsorption.swift"; sourceTree = ""; }; C1BCB5AF298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B0298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B1298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; @@ -1546,6 +1527,11 @@ C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalDeliveryState.swift; sourceTree = ""; }; C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAlgorithmTests.swift; sourceTree = ""; }; C1D70F7A2A914F71009FE129 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; + C1DA434E2B164C6C00CBD33F /* MockSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSettingsProvider.swift; sourceTree = ""; }; + C1DA43522B19310A00CBD33F /* LoopControlMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopControlMock.swift; sourceTree = ""; }; + C1DA43542B193BCB00CBD33F /* MockUploadEventListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUploadEventListener.swift; sourceTree = ""; }; + C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerTests.swift; sourceTree = ""; }; + C1DA43582B1A784900CBD33F /* MockDeliveryDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeliveryDelegate.swift; sourceTree = ""; }; C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistedProperty.swift; sourceTree = ""; }; C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimpleBolusView.swift; sourceTree = ""; }; C1E2773D224177C000354103 /* ClockKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ClockKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/ClockKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -1610,44 +1596,13 @@ DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstantApplicationFactorStrategy.swift; sourceTree = ""; }; DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+algorithmExperimentsSection.swift"; sourceTree = ""; }; DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorSelectionView.swift; sourceTree = ""; }; - E90909CC24E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_momentum_effect.json; sourceTree = ""; }; - E90909CD24E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_insulin_effect.json; sourceTree = ""; }; - E90909CE24E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_predicted_glucose.json; sourceTree = ""; }; - E90909CF24E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_carb_effect.json; sourceTree = ""; }; - E90909D024E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_counteraction_effect.json; sourceTree = ""; }; - E90909D724E34F1500F963D2 /* low_and_falling_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_predicted_glucose.json; sourceTree = ""; }; - E90909D824E34F1500F963D2 /* low_and_falling_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_carb_effect.json; sourceTree = ""; }; - E90909D924E34F1500F963D2 /* low_and_falling_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_counteraction_effect.json; sourceTree = ""; }; - E90909DA24E34F1600F963D2 /* low_and_falling_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_insulin_effect.json; sourceTree = ""; }; - E90909DB24E34F1600F963D2 /* low_and_falling_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_momentum_effect.json; sourceTree = ""; }; - E90909E224E3530200F963D2 /* low_with_low_treatment_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_carb_effect.json; sourceTree = ""; }; - E90909E324E3530200F963D2 /* low_with_low_treatment_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_insulin_effect.json; sourceTree = ""; }; - E90909E424E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_predicted_glucose.json; sourceTree = ""; }; - E90909E524E3530200F963D2 /* low_with_low_treatment_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_momentum_effect.json; sourceTree = ""; }; - E90909E624E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_counteraction_effect.json; sourceTree = ""; }; - E90909ED24E35B4000F963D2 /* high_and_falling_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_predicted_glucose.json; sourceTree = ""; }; - E90909EF24E35B4C00F963D2 /* high_and_falling_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_counteraction_effect.json; sourceTree = ""; }; - E90909F024E35B4C00F963D2 /* high_and_falling_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_carb_effect.json; sourceTree = ""; }; - E90909F124E35B4C00F963D2 /* high_and_falling_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_insulin_effect.json; sourceTree = ""; }; - E90909F524E35B7C00F963D2 /* high_and_falling_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_momentum_effect.json; sourceTree = ""; }; E93E865324DB6CBA00FF40C8 /* retrospective_output.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = retrospective_output.json; sourceTree = ""; }; E93E865524DB731900FF40C8 /* predicted_glucose_without_retrospective.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = predicted_glucose_without_retrospective.json; sourceTree = ""; }; E93E865724DB75BD00FF40C8 /* predicted_glucose_very_negative.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = predicted_glucose_very_negative.json; sourceTree = ""; }; E93E86A724DDCC4400FF40C8 /* MockDoseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDoseStore.swift; sourceTree = ""; }; E93E86AF24DDE1BD00FF40C8 /* MockGlucoseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGlucoseStore.swift; sourceTree = ""; }; E93E86B124DDE21D00FF40C8 /* MockCarbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCarbStore.swift; sourceTree = ""; }; - E93E86B424E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_insulin_effect.json; sourceTree = ""; }; - E93E86B524E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_momentum_effect.json; sourceTree = ""; }; - E93E86B624E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_predicted_glucose.json; sourceTree = ""; }; - E93E86B824E1FDC400FF40C8 /* flat_and_stable_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_carb_effect.json; sourceTree = ""; }; - E93E86C224E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_counteraction_effect.json; sourceTree = ""; }; - E93E86C524E2E02200FF40C8 /* high_and_stable_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_insulin_effect.json; sourceTree = ""; }; - E93E86C624E2E02200FF40C8 /* high_and_stable_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_carb_effect.json; sourceTree = ""; }; - E93E86C724E2E02200FF40C8 /* high_and_stable_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_predicted_glucose.json; sourceTree = ""; }; - 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 = ""; }; @@ -1861,13 +1816,15 @@ 1DA7A84024476E98008257F0 /* Alerts */, C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, + C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, + C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, - E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */, E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */, + C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */, 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */, + C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */, A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */, - C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */, ); path = Managers; sourceTree = ""; @@ -2164,7 +2121,6 @@ C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, - 43D848AF1E7DCBE100DADCBC /* Result.swift */, 43D9FFD121EAE05D00AF44BF /* LoopCore.h */, 43D9FFD221EAE05D00AF44BF /* Info.plist */, 4B60626A287E286000BF8BBB /* Localizable.strings */, @@ -2188,10 +2144,8 @@ 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */, 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */, 892A5D58222F0A27008961AB /* Debug.swift */, - 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */, B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */, A96DAC232838325900D94E38 /* DiagnosticLog.swift */, - C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */, A9C62D832331700D00535612 /* DiagnosticLog+Subsystem.swift */, 89D1503D24B506EB00EDE253 /* Dictionary.swift */, 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */, @@ -2283,40 +2237,41 @@ children = ( B42D124228D371C400E43D22 /* AlertMuter.swift */, 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */, + 1DA6499D2441266400F61E75 /* Alerts */, 439897361CD2F80600223065 /* AnalyticsServicesManager.swift */, + C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */, 439BED291E76093C00B0AED5 /* CGMManager.swift */, C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */, A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */, + 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */, 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */, C16B983D26B4893300256B05 /* DoseEnactor.swift */, - 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */, + 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */, A9C62D862331703000535612 /* LoggingServicesManager.swift */, A9D5C5B525DC6C6A00534873 /* LoopAppManager.swift */, 43A567681C94880B00334FAC /* LoopDataManager.swift */, + E9B355232935906B0076AB04 /* Missed Meal Detection */, 43C094491CACCC73001F6403 /* NotificationManager.swift */, A97F250725E056D500F0EE19 /* OnboardingManager.swift */, 432E73CA1D24B3D6009AD15D /* RemoteDataServicesManager.swift */, - B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */, + 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, A9C62D852331703000535612 /* Service.swift */, A9C62D872331703000535612 /* ServicesManager.swift */, C1F7822527CC056900C0919A /* SettingsManager.swift */, + A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, E9BB27AA23B85C3500FB4987 /* SleepStore.swift */, B470F5832AB22B5100049695 /* StatefulPluggable.swift */, + B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */, 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */, + E95D37FF24EADE68005E2F50 /* Store Protocols */, 1D63DEA426E950D400F46FA5 /* SupportManager.swift */, - 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */, + C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */, 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */, 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */, 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */, - 1DA6499D2441266400F61E75 /* Alerts */, - E95D37FF24EADE68005E2F50 /* Store Protocols */, - E9B355232935906B0076AB04 /* Missed Meal Detection */, - C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, - A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, - 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, - 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, + C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */, ); path = Managers; sourceTree = ""; @@ -2324,17 +2279,17 @@ 43F78D2C1C8FC58F002152D1 /* LoopTests */ = { isa = PBXGroup; children = ( + A9DF02CA24F72B9E00B7C988 /* CriticalEventLogTests.swift */, + A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */, E9C58A7624DB510500487A17 /* Fixtures */, + 43E2D90F1D20C581004DA55F /* Info.plist */, B4CAD8772549D2330057946B /* LoopCore */, + A9DAE7CF2332D77F006AE942 /* LoopTests.swift */, 1DA7A83F24476E8C008257F0 /* Managers */, + E93E86AC24DDE02C00FF40C8 /* Mock Stores */, + C188599C2AF15F9A0010F21F /* Mocks */, A9E6DFED246A0460005B1A1C /* Models */, B4BC56362518DE8800373647 /* ViewModels */, - 43E2D90F1D20C581004DA55F /* Info.plist */, - A9DF02CA24F72B9E00B7C988 /* CriticalEventLogTests.swift */, - A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */, - A9DAE7CF2332D77F006AE942 /* LoopTests.swift */, - 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */, - E93E86AC24DDE02C00FF40C8 /* Mock Stores */, ); path = LoopTests; sourceTree = ""; @@ -2774,6 +2729,22 @@ path = Plugins; sourceTree = ""; }; + C188599C2AF15F9A0010F21F /* Mocks */ = { + isa = PBXGroup; + children = ( + C188599D2AF15FAB0010F21F /* AlertMocks.swift */, + C18859A32AF165330010F21F /* MockCGMManager.swift */, + C18859A12AF165130010F21F /* MockPumpManager.swift */, + C18859A72AF292D90010F21F /* MockTrustedTimeChecker.swift */, + C188599F2AF1612B0010F21F /* PersistenceController.swift */, + C1DA434E2B164C6C00CBD33F /* MockSettingsProvider.swift */, + C1DA43522B19310A00CBD33F /* LoopControlMock.swift */, + C1DA43542B193BCB00CBD33F /* MockUploadEventListener.swift */, + C1DA43582B1A784900CBD33F /* MockDeliveryDelegate.swift */, + ); + path = Mocks; + sourceTree = ""; + }; C18A491122FCC20B00FDA733 /* Scripts */ = { isa = PBXGroup; children = ( @@ -2787,54 +2758,6 @@ path = Scripts; sourceTree = ""; }; - E90909CB24E34A7A00F963D2 /* high_and_rising_with_cob */ = { - isa = PBXGroup; - children = ( - E90909CF24E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json */, - E90909D024E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json */, - E90909CD24E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json */, - E90909CC24E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json */, - E90909CE24E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json */, - ); - path = high_and_rising_with_cob; - sourceTree = ""; - }; - E90909D624E34EC200F963D2 /* low_and_falling */ = { - isa = PBXGroup; - children = ( - E90909D824E34F1500F963D2 /* low_and_falling_carb_effect.json */, - E90909D924E34F1500F963D2 /* low_and_falling_counteraction_effect.json */, - E90909DA24E34F1600F963D2 /* low_and_falling_insulin_effect.json */, - E90909DB24E34F1600F963D2 /* low_and_falling_momentum_effect.json */, - E90909D724E34F1500F963D2 /* low_and_falling_predicted_glucose.json */, - ); - path = low_and_falling; - sourceTree = ""; - }; - E90909E124E352C300F963D2 /* low_with_low_treatment */ = { - isa = PBXGroup; - children = ( - E90909E224E3530200F963D2 /* low_with_low_treatment_carb_effect.json */, - E90909E624E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json */, - E90909E324E3530200F963D2 /* low_with_low_treatment_insulin_effect.json */, - E90909E524E3530200F963D2 /* low_with_low_treatment_momentum_effect.json */, - E90909E424E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json */, - ); - path = low_with_low_treatment; - sourceTree = ""; - }; - E90909EC24E35B3400F963D2 /* high_and_falling */ = { - isa = PBXGroup; - children = ( - E90909F524E35B7C00F963D2 /* high_and_falling_momentum_effect.json */, - E90909F024E35B4C00F963D2 /* high_and_falling_carb_effect.json */, - E90909EF24E35B4C00F963D2 /* high_and_falling_counteraction_effect.json */, - E90909F124E35B4C00F963D2 /* high_and_falling_insulin_effect.json */, - E90909ED24E35B4000F963D2 /* high_and_falling_predicted_glucose.json */, - ); - path = high_and_falling; - sourceTree = ""; - }; E93E86AC24DDE02C00FF40C8 /* Mock Stores */ = { isa = PBXGroup; children = ( @@ -2848,30 +2771,6 @@ path = "Mock Stores"; sourceTree = ""; }; - E93E86B324E1FD8700FF40C8 /* flat_and_stable */ = { - isa = PBXGroup; - children = ( - E93E86C224E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json */, - E93E86B824E1FDC400FF40C8 /* flat_and_stable_carb_effect.json */, - E93E86B424E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json */, - E93E86B524E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json */, - E93E86B624E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json */, - ); - path = flat_and_stable; - sourceTree = ""; - }; - E93E86C424E2DF6700FF40C8 /* high_and_stable */ = { - isa = PBXGroup; - children = ( - E93E86C624E2E02200FF40C8 /* high_and_stable_carb_effect.json */, - E93E86C824E2E02200FF40C8 /* high_and_stable_counteraction_effect.json */, - E93E86C524E2E02200FF40C8 /* high_and_stable_insulin_effect.json */, - E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */, - E93E86C724E2E02200FF40C8 /* high_and_stable_predicted_glucose.json */, - ); - path = high_and_stable; - sourceTree = ""; - }; E95D37FF24EADE68005E2F50 /* Store Protocols */ = { isa = PBXGroup; children = ( @@ -2924,12 +2823,6 @@ children = ( C13072B82A76AF0A009A7C58 /* live_capture */, E9B355312937068A0076AB04 /* meal_detection */, - E90909EC24E35B3400F963D2 /* high_and_falling */, - E90909E124E352C300F963D2 /* low_with_low_treatment */, - E90909D624E34EC200F963D2 /* low_and_falling */, - E90909CB24E34A7A00F963D2 /* high_and_rising_with_cob */, - E93E86C424E2DF6700FF40C8 /* high_and_stable */, - E93E86B324E1FD8700FF40C8 /* flat_and_stable */, E93E865724DB75BD00FF40C8 /* predicted_glucose_very_negative.json */, E93E865524DB731900FF40C8 /* predicted_glucose_without_retrospective.json */, E9C58A7824DB529A00487A17 /* basal_profile.json */, @@ -3413,7 +3306,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - E93E86CE24E2E02200FF40C8 /* high_and_stable_momentum_effect.json in Resources */, C13072BA2A76AF31009A7C58 /* live_capture_predicted_glucose.json in Resources */, E93E865424DB6CBA00FF40C8 /* retrospective_output.json in Resources */, E9C58A7F24DB529A00487A17 /* counteraction_effect_falling_glucose.json in Resources */, @@ -3421,44 +3313,15 @@ 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 */, - E90909DE24E34F1600F963D2 /* low_and_falling_counteraction_effect.json in Resources */, - E90909D224E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json in Resources */, - E90909F224E35B4D00F963D2 /* high_and_falling_counteraction_effect.json in Resources */, - E90909EE24E35B4000F963D2 /* high_and_falling_predicted_glucose.json in Resources */, - E90909DD24E34F1600F963D2 /* low_and_falling_carb_effect.json in Resources */, - E90909E924E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json in Resources */, - E90909D124E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json in Resources */, E9C58A8024DB529A00487A17 /* insulin_effect.json in Resources */, - E90909DF24E34F1600F963D2 /* low_and_falling_insulin_effect.json in Resources */, C16FC0B02A99392F0025E239 /* live_capture_input.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 */, - E90909EA24E3530200F963D2 /* low_with_low_treatment_momentum_effect.json in Resources */, - E90909D324E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json in Resources */, - E90909D524E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json in Resources */, E9C58A7D24DB529A00487A17 /* basal_profile.json in Resources */, - E93E86CD24E2E02200FF40C8 /* high_and_stable_counteraction_effect.json in Resources */, - E93E86C324E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json in Resources */, E9C58A7E24DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json in Resources */, - E90909F324E35B4D00F963D2 /* high_and_falling_carb_effect.json in Resources */, - E93E86CA24E2E02200FF40C8 /* high_and_stable_insulin_effect.json in Resources */, - E93E86BB24E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json in Resources */, - E93E86CB24E2E02200FF40C8 /* high_and_stable_carb_effect.json in Resources */, E9C58A7C24DB529A00487A17 /* momentum_effect_bouncing.json in Resources */, - E90909E024E34F1600F963D2 /* low_and_falling_momentum_effect.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3683,6 +3546,7 @@ C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, + C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, @@ -3714,6 +3578,7 @@ C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */, + C1B80D632AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift in Sources */, DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */, B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, @@ -3724,7 +3589,6 @@ A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */, A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, - 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */, 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, @@ -3804,7 +3668,6 @@ 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */, 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */, C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */, - 89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */, C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, @@ -3834,7 +3697,6 @@ 89A1B66E24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */, 432E73CB1D24B3D6009AD15D /* RemoteDataServicesManager.swift in Sources */, - C18913B52524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift in Sources */, 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */, B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */, 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */, @@ -3942,7 +3804,6 @@ E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */, C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */, 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */, - 4345E3F421F036FC009E00E5 /* Result.swift in Sources */, C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */, @@ -3963,7 +3824,6 @@ E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */, C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */, 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, - 4345E3F521F036FC009E00E5 /* Result.swift in Sources */, C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */, @@ -3991,33 +3851,43 @@ A9DFAFB524F048A000950D1E /* WatchHistoricalCarbsTests.swift in Sources */, C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */, 1DA7A84424477698008257F0 /* InAppModalAlertSchedulerTests.swift in Sources */, + C1DA43532B19310A00CBD33F /* LoopControlMock.swift in Sources */, 1D70C40126EC0F9D00C62570 /* SupportManagerTests.swift in Sources */, E93E86A824DDCC4400FF40C8 /* MockDoseStore.swift in Sources */, B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */, E98A55F124EDD85E0008715D /* MockDosingDecisionStore.swift in Sources */, C1D476B42A8ED179002C1C87 /* LoopAlgorithmTests.swift in Sources */, - 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 */, + C1DA43552B193BCB00CBD33F /* MockUploadEventListener.swift in Sources */, B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, + C1DA43572B1A70BE00CBD33F /* SettingsManagerTests.swift in Sources */, 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */, + C188599E2AF15FAB0010F21F /* AlertMocks.swift in Sources */, A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */, 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */, A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */, + C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */, + C18859A42AF165330010F21F /* MockCGMManager.swift in Sources */, E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */, + C18859A22AF165130010F21F /* MockPumpManager.swift in Sources */, E98A55F324EDD9530008715D /* MockSettingsStore.swift in Sources */, C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */, A96DAC2A2838EF8A00D94E38 /* DiagnosticLogTests.swift in Sources */, A9DAE7D02332D77F006AE942 /* LoopTests.swift in Sources */, + C129BF4A2B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift in Sources */, E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */, + C18859A82AF292D90010F21F /* MockTrustedTimeChecker.swift in Sources */, 1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.swift in Sources */, E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */, B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */, + C1DA434F2B164C6C00CBD33F /* MockSettingsProvider.swift in Sources */, C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */, A9C1719725366F780053BCBD /* WatchHistoricalGlucoseTest.swift in Sources */, E93E86B224DDE21D00FF40C8 /* MockCarbStore.swift in Sources */, + C1DA43592B1A784900CBD33F /* MockDeliveryDelegate.swift in Sources */, + C18859A02AF1612B0010F21F /* PersistenceController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index 5da6ce9cb6..a707eb1a61 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -22,9 +22,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, WindowProvider { setenv("CFNETWORK_DIAGNOSTICS", "3", 1) - loopAppManager.initialize(windowProvider: self, launchOptions: launchOptions) - loopAppManager.launch() - return loopAppManager.isLaunchComplete + // Avoid doing full initialization when running tests + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { + loopAppManager.initialize(windowProvider: self, launchOptions: launchOptions) + loopAppManager.launch() + return loopAppManager.isLaunchComplete + } else { + return true + } } // MARK: - UIApplicationDelegate - Life Cycle diff --git a/Loop/Extensions/BasalDeliveryState.swift b/Loop/Extensions/BasalDeliveryState.swift index 7aef479ccf..6b53f06e2b 100644 --- a/Loop/Extensions/BasalDeliveryState.swift +++ b/Loop/Extensions/BasalDeliveryState.swift @@ -10,7 +10,7 @@ import LoopKit import LoopCore extension PumpManagerStatus.BasalDeliveryState { - func getNetBasal(basalSchedule: BasalRateSchedule, settings: LoopSettings) -> NetBasal? { + func getNetBasal(basalSchedule: BasalRateSchedule, maximumBasalRatePerHour: Double?) -> NetBasal? { func scheduledBasal(for date: Date) -> AbsoluteScheduleValue? { return basalSchedule.between(start: date, end: date).first } @@ -20,7 +20,7 @@ extension PumpManagerStatus.BasalDeliveryState { if let scheduledBasal = scheduledBasal(for: dose.startDate) { return NetBasal( lastTempBasal: dose, - maxBasal: settings.maximumBasalRatePerHour, + maxBasal: maximumBasalRatePerHour, scheduledBasal: scheduledBasal ) } else { @@ -30,7 +30,7 @@ extension PumpManagerStatus.BasalDeliveryState { if let scheduledBasal = scheduledBasal(for: date) { return NetBasal( suspendedAt: date, - maxBasal: settings.maximumBasalRatePerHour, + maxBasal: maximumBasalRatePerHour, scheduledBasal: scheduledBasal ) } else { diff --git a/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift b/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift deleted file mode 100644 index 25173f92d8..0000000000 --- a/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// DeviceDataManager+BolusEntryViewModelDelegate.swift -// Loop -// -// Created by Rick Pasetto on 9/29/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import HealthKit -import LoopCore -import LoopKit - -extension DeviceDataManager: CarbEntryViewModelDelegate { - var defaultAbsorptionTimes: LoopKit.CarbStore.DefaultAbsorptionTimes { - return carbStore.defaultAbsorptionTimes - } -} - -extension DeviceDataManager: BolusEntryViewModelDelegate, ManualDoseViewModelDelegate { - - func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) { - loopManager.addManuallyEnteredDose(startDate: startDate, units: units, insulinType: insulinType) - } - - func withLoopState(do block: @escaping (LoopState) -> Void) { - loopManager.getLoopState { block($1) } - } - - func saveGlucose(sample: NewGlucoseSample) async -> StoredGlucoseSample? { - return await withCheckedContinuation { continuation in - loopManager.addGlucoseSamples([sample]) { result in - switch result { - case .success(let samples): - continuation.resume(returning: samples.first) - case .failure: - continuation.resume(returning: nil) - } - } - } - } - - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { - loopManager.addCarbEntry(carbEntry, replacing: replacingEntry, completion: completion) - } - - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { - loopManager.storeManualBolusDosingDecision(bolusDosingDecision, withDate: date) - } - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - glucoseStore.getGlucoseSamples(start: start, end: end, completion: completion) - } - - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - doseStore.insulinOnBoard(at: date, completion: completion) - } - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { - carbStore.carbsOnBoard(at: date, effectVelocities: effectVelocities, completion: completion) - } - - func ensureCurrentPumpData(completion: @escaping (Date?) -> Void) { - pumpManager?.ensureCurrentPumpData(completion: completion) - } - - var mostRecentGlucoseDataDate: Date? { - return glucoseStore.latestGlucose?.startDate - } - - var mostRecentPumpDataDate: Date? { - return doseStore.lastAddedPumpData - } - - var isPumpConfigured: Bool { - return pumpManager != nil - } - - var preferredGlucoseUnit: HKUnit { - return displayGlucosePreference.unit - } - - var pumpInsulinType: InsulinType? { - return pumpManager?.status.insulinType - } - - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { - return doseStore.insulinModelProvider.model(for: type).effectDuration - } - - var settings: LoopSettings { - return loopManager.settings - } - - func updateRemoteRecommendation() { - loopManager.updateRemoteRecommendation() - } -} diff --git a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift index bc9b40f4d4..5f08105b2e 100644 --- a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift +++ b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift @@ -41,16 +41,16 @@ extension DeviceDataManager { } else if pumpManager == nil { return DeviceDataManager.addPumpStatusHighlight } else { - return pumpManager?.pumpStatusHighlight + return (pumpManager as? PumpManagerUI)?.pumpStatusHighlight } } var pumpStatusBadge: DeviceStatusBadge? { - return pumpManager?.pumpStatusBadge + return (pumpManager as? PumpManagerUI)?.pumpStatusBadge } var pumpLifecycleProgress: DeviceLifecycleProgress? { - return pumpManager?.pumpLifecycleProgress + return (pumpManager as? PumpManagerUI)?.pumpLifecycleProgress } static var resumeOnboardingStatusHighlight: ResumeOnboardingStatusHighlight { @@ -104,18 +104,12 @@ extension DeviceDataManager { let action = pumpManagerHUDProvider.didTapOnHUDView(view, allowDebugFeatures: FeatureFlags.allowDebugFeatures) { return action - } else if let pumpManager = pumpManager { + } else if let pumpManager = pumpManager as? PumpManagerUI { return .presentViewController(pumpManager.settingsViewController(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, allowedInsulinTypes: allowedInsulinTypes)) } else { return .setupNewPump } - } - - var isGlucoseValueStale: Bool { - guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return true } - - return Date().timeIntervalSince(latestGlucoseDataDate) > LoopAlgorithm.inputDataRecencyInterval - } + } } // MARK: - BluetoothState diff --git a/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift b/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift deleted file mode 100644 index 4192700ef4..0000000000 --- a/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// DeviceDataManager+SimpleBolusViewModelDelegate.swift -// Loop -// -// Created by Pete Schwamb on 9/30/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import HealthKit -import LoopCore -import LoopKit - -extension DeviceDataManager: SimpleBolusViewModelDelegate { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - loopManager.addGlucoseSamples(samples, completion: completion) - } - - func enactBolus(units: Double, activationType: BolusActivationType) { - enactBolus(units: units, activationType: activationType) { (_) in } - } - - func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { - return loopManager.generateSimpleBolusRecommendation(at: date, mealCarbs: mealCarbs, manualGlucose: manualGlucose) - } - - var maximumBolus: Double { - return loopManager.settings.maximumBolus! - } - - var suspendThreshold: HKQuantity { - return loopManager.settings.suspendThreshold!.quantity - } -} diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index 80c990bb38..5fbcd152f6 100644 --- a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -154,9 +154,7 @@ fileprivate extension StoredSettings { glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, preMealTargetRange: DoubleRange(minValue: 80.0, maxValue: 90.0).quantityRange(for: .milligramsPerDeciliter), workoutTargetRange: DoubleRange(minValue: 150.0, maxValue: 160.0).quantityRange(for: .milligramsPerDeciliter), - overridePresets: nil, - scheduleOverride: nil, - preMealOverride: preMealOverride, + overridePresets: [], maximumBasalRatePerHour: 3.5, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 75.0), diff --git a/Loop/Extensions/UIDevice+Loop.swift b/Loop/Extensions/UIDevice+Loop.swift index f8df9f58be..a9655723ed 100644 --- a/Loop/Extensions/UIDevice+Loop.swift +++ b/Loop/Extensions/UIDevice+Loop.swift @@ -37,7 +37,7 @@ extension UIDevice { } extension UIDevice { - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { + func generateDiagnosticReport() -> String { var report: [String] = [ "## Device", "", @@ -53,7 +53,7 @@ extension UIDevice { "* batteryState: \(String(describing: batteryState))", ] } - completion(report.joined(separator: "\n")) + return report.joined(separator: "\n") } } diff --git a/Loop/Extensions/UserNotifications+Loop.swift b/Loop/Extensions/UserNotifications+Loop.swift index cd1959c907..dd5eec862d 100644 --- a/Loop/Extensions/UserNotifications+Loop.swift +++ b/Loop/Extensions/UserNotifications+Loop.swift @@ -9,26 +9,25 @@ import UserNotifications extension UNUserNotificationCenter { - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - getNotificationSettings() { notificationSettings in - let report: [String] = [ - "## NotificationSettings", - "", - "* authorizationStatus: \(String(describing: notificationSettings.authorizationStatus))", - "* soundSetting: \(String(describing: notificationSettings.soundSetting))", - "* badgeSetting: \(String(describing: notificationSettings.badgeSetting))", - "* alertSetting: \(String(describing: notificationSettings.alertSetting))", - "* notificationCenterSetting: \(String(describing: notificationSettings.notificationCenterSetting))", - "* lockScreenSetting: \(String(describing: notificationSettings.lockScreenSetting))", - "* carPlaySetting: \(String(describing: notificationSettings.carPlaySetting))", - "* alertStyle: \(String(describing: notificationSettings.alertStyle))", - "* showPreviewsSetting: \(String(describing: notificationSettings.showPreviewsSetting))", - "* criticalAlertSetting: \(String(describing: notificationSettings.criticalAlertSetting))", - "* providesAppNotificationSettings: \(String(describing: notificationSettings.providesAppNotificationSettings))", - "* announcementSetting: \(String(describing: notificationSettings.announcementSetting))", - ] - completion(report.joined(separator: "\n")) - } + func generateDiagnosticReport() async -> String { + let notificationSettings = await notificationSettings() + let report: [String] = [ + "## NotificationSettings", + "", + "* authorizationStatus: \(String(describing: notificationSettings.authorizationStatus))", + "* soundSetting: \(String(describing: notificationSettings.soundSetting))", + "* badgeSetting: \(String(describing: notificationSettings.badgeSetting))", + "* alertSetting: \(String(describing: notificationSettings.alertSetting))", + "* notificationCenterSetting: \(String(describing: notificationSettings.notificationCenterSetting))", + "* lockScreenSetting: \(String(describing: notificationSettings.lockScreenSetting))", + "* carPlaySetting: \(String(describing: notificationSettings.carPlaySetting))", + "* alertStyle: \(String(describing: notificationSettings.alertStyle))", + "* showPreviewsSetting: \(String(describing: notificationSettings.showPreviewsSetting))", + "* criticalAlertSetting: \(String(describing: notificationSettings.criticalAlertSetting))", + "* providesAppNotificationSettings: \(String(describing: notificationSettings.providesAppNotificationSettings))", + "* announcementSetting: \(String(describing: notificationSettings.announcementSetting))", + ] + return report.joined(separator: "\n") } } diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index bae4512e6a..12c88c5e71 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -34,7 +34,7 @@ public class AlertPermissionsChecker: ObservableObject { init() { // Check on loop complete, but only while in the background. - NotificationCenter.default.publisher(for: .LoopCompleted) + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self = self else { return } diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 50b99666e2..010a00074a 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -24,6 +24,7 @@ public enum AlertUserNotificationUserInfoKey: String { /// - managing the different responders that might acknowledge the alert /// - serializing alerts to storage /// - etc. +@MainActor public final class AlertManager { private static let soundsDirectoryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).last!.appendingPathComponent("Sounds") @@ -88,10 +89,12 @@ public final class AlertManager { bluetoothProvider.addBluetoothObserver(self, queue: .main) - NotificationCenter.default.publisher(for: .LoopCompleted) + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .sink { [weak self] publisher in if let loopDataManager = publisher.object as? LoopDataManager { - self?.loopDidComplete(loopDataManager.lastLoopCompleted) + Task { @MainActor in + self?.loopDidComplete(loopDataManager.lastLoopCompleted) + } } } .store(in: &cancellables) @@ -404,10 +407,12 @@ extension AlertManager: AlertIssuer { extension AlertManager { + nonisolated public static func soundURL(for alert: Alert) -> URL? { return soundURL(managerIdentifier: alert.identifier.managerIdentifier, sound: alert.sound) } + nonisolated private static func soundURL(managerIdentifier: String, sound: Alert.Sound?) -> URL? { guard let soundFileName = sound?.filename else { return nil } @@ -494,31 +499,35 @@ extension AlertManager { // MARK: Alert storage access extension AlertManager { - func getStoredEntries(startDate: Date, completion: @escaping (_ report: String) -> Void) { - alertStore.executeQuery(since: startDate, limit: 100) { result in - switch result { - case .failure(let error): - completion("Error: \(error)") - case .success(_, let objects): - let encoder = JSONEncoder() - let report = "## Alerts\n" + objects.map { object in - return """ - **\(object.title ?? "??")** - - * identifier: \(object.identifier.value) - * issued: \(object.issuedDate) - * acknowledged: \(object.acknowledgedDate?.description ?? "n/a") - * retracted: \(object.retractedDate?.description ?? "n/a") - * trigger: \(object.trigger) - * interruptionLevel: \(object.interruptionLevel) - * foregroundContent: \((try? encoder.encodeToStringIfPresent(object.foregroundContent)) ?? "n/a") - * backgroundContent: \((try? encoder.encodeToStringIfPresent(object.backgroundContent)) ?? "n/a") - * sound: \((try? encoder.encodeToStringIfPresent(object.sound)) ?? "n/a") - * metadata: \((try? encoder.encodeToStringIfPresent(object.metadata)) ?? "n/a") - - """ - }.joined(separator: "\n") - completion(report) + func generateDiagnosticReport() async -> String { + await withCheckedContinuation { continuation in + let startDate = Date() - .days(3.5) // Report the last 3 and half days of alerts + let header = "## Alerts\n" + alertStore.executeQuery(since: startDate, limit: 100) { result in + switch result { + case .failure: + continuation.resume(returning: header) + case .success(_, let objects): + let encoder = JSONEncoder() + let report = header + objects.map { object in + return """ + **\(object.title ?? "??")** + + * identifier: \(object.identifier.value) + * issued: \(object.issuedDate) + * acknowledged: \(object.acknowledgedDate?.description ?? "n/a") + * retracted: \(object.retractedDate?.description ?? "n/a") + * trigger: \(object.trigger) + * interruptionLevel: \(object.interruptionLevel) + * foregroundContent: \((try? encoder.encodeToStringIfPresent(object.foregroundContent)) ?? "n/a") + * backgroundContent: \((try? encoder.encodeToStringIfPresent(object.backgroundContent)) ?? "n/a") + * sound: \((try? encoder.encodeToStringIfPresent(object.sound)) ?? "n/a") + * metadata: \((try? encoder.encodeToStringIfPresent(object.metadata)) ?? "n/a") + + """ + }.joined(separator: "\n") + continuation.resume(returning: report) + } } } } diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 808a34c81a..4e80ba7bd5 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -143,10 +143,6 @@ final class AnalyticsServicesManager { logEvent("Therapy schedule time zone change") } - if newValue.scheduleOverride != oldValue.scheduleOverride { - logEvent("Temporary schedule override change") - } - if newValue.glucoseTargetRangeSchedule != oldValue.glucoseTargetRangeSchedule { logEvent("Glucose target range change") } diff --git a/Loop/Managers/CriticalEventLogExportManager.swift b/Loop/Managers/CriticalEventLogExportManager.swift index 6b8f699e5c..546c7986fe 100644 --- a/Loop/Managers/CriticalEventLogExportManager.swift +++ b/Loop/Managers/CriticalEventLogExportManager.swift @@ -9,6 +9,8 @@ import os.log import UIKit import LoopKit +import BackgroundTasks + public enum CriticalEventLogExportError: Error { case exportInProgress @@ -197,6 +199,16 @@ public class CriticalEventLogExportManager { calendar.timeZone = TimeZone(identifier: "UTC")! return calendar }() + + // MARK: - Background Tasks + + func registerBackgroundTasks() { + if Self.registerCriticalEventLogHistoricalExportBackgroundTask({ self.handleCriticalEventLogHistoricalExportBackgroundTask($0) }) { + log.debug("Critical event log export background task registered") + } else { + log.error("Critical event log export background task not registered") + } + } } // MARK: - CriticalEventLogBaseExporter @@ -551,3 +563,82 @@ fileprivate extension FileManager { return temporaryDirectory.appendingPathComponent(UUID().uuidString) } } + +// MARK: - Critical Event Log Export + +extension CriticalEventLogExportManager { + private static var criticalEventLogHistoricalExportBackgroundTaskIdentifier: String { "com.loopkit.background-task.critical-event-log.historical-export" } + + public static func registerCriticalEventLogHistoricalExportBackgroundTask(_ handler: @escaping (BGProcessingTask) -> Void) -> Bool { + return BGTaskScheduler.shared.register(forTaskWithIdentifier: criticalEventLogHistoricalExportBackgroundTaskIdentifier, using: nil) { handler($0 as! BGProcessingTask) } + } + + public func handleCriticalEventLogHistoricalExportBackgroundTask(_ task: BGProcessingTask) { + dispatchPrecondition(condition: .notOnQueue(.main)) + + scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: true) + + let exporter = createHistoricalExporter() + + task.expirationHandler = { + self.log.default("Invoked critical event log historical export background task expiration handler - cancelling exporter") + exporter.cancel() + } + + DispatchQueue.global(qos: .background).async { + exporter.export() { error in + if let error = error { + self.log.error("Critical event log historical export errored: %{public}@", String(describing: error)) + } + + self.scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: error != nil && !exporter.isCancelled) + task.setTaskCompleted(success: error == nil) + + self.log.default("Completed critical event log historical export background task") + } + } + } + + public func scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: Bool = false) { + do { + let earliestBeginDate = isRetry ? retryExportHistoricalDate() : nextExportHistoricalDate() + let request = BGProcessingTaskRequest(identifier: Self.criticalEventLogHistoricalExportBackgroundTaskIdentifier) + request.earliestBeginDate = earliestBeginDate + request.requiresExternalPower = true + + try BGTaskScheduler.shared.submit(request) + + log.default("Scheduled critical event log historical export background task: %{public}@", ISO8601DateFormatter().string(from: earliestBeginDate)) + } catch let error { + #if IOS_SIMULATOR + log.debug("Failed to schedule critical event log export background task due to running on simulator") + #else + log.error("Failed to schedule critical event log export background task: %{public}@", String(describing: error)) + #endif + } + } + + public func removeExportsDirectory() -> Error? { + let fileManager = FileManager.default + let exportsDirectoryURL = fileManager.exportsDirectoryURL + + guard fileManager.fileExists(atPath: exportsDirectoryURL.path) else { + return nil + } + + do { + try fileManager.removeItem(at: exportsDirectoryURL) + } catch let error { + return error + } + + return nil + } +} + +extension FileManager { + var exportsDirectoryURL: URL { + let applicationSupportDirectory = try! url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + return applicationSupportDirectory.appendingPathComponent(Bundle.main.bundleIdentifier!).appendingPathComponent("Exports") + } +} diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index b6cd35d3a6..234dc4eed4 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -6,7 +6,6 @@ // Copyright © 2015 Nathan Racklyeft. All rights reserved. // -import BackgroundTasks import HealthKit import LoopKit import LoopKitUI @@ -15,10 +14,28 @@ import LoopTestingKit import UserNotifications import Combine +protocol LoopControl { + var lastLoopCompleted: Date? { get } + func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async + func loop() async +} + +protocol ActiveServicesProvider { + var activeServices: [Service] { get } +} + +protocol ActiveStatefulPluginsProvider { + var activeStatefulPlugins: [StatefulPluggable] { get } +} + + +protocol UploadEventListener { + func triggerUpload(for triggeringType: RemoteDataType) +} + +@MainActor final class DeviceDataManager { - private let queue = DispatchQueue(label: "com.loopkit.DeviceManagerQueue", qos: .utility) - private let log = DiagnosticLog(category: "DeviceDataManager") let pluginManager: PluginManager @@ -30,10 +47,9 @@ final class DeviceDataManager { private let launchDate = Date() /// The last error recorded by a device manager - /// Should be accessed only on the main queue private(set) var lastError: (date: Date, error: Error)? - private var deviceLog: PersistentDeviceLog + var deviceLog: PersistentDeviceLog // MARK: - App-level responsibilities @@ -84,17 +100,12 @@ final class DeviceDataManager { private var cgmStalenessMonitor: CGMStalenessMonitor - private var displayGlucoseUnitObservers = WeakSynchronizedSet() - - public private(set) var displayGlucosePreference: DisplayGlucosePreference - var deviceWhitelist = DeviceWhitelist() // MARK: - CGM var cgmManager: CGMManager? { didSet { - dispatchPrecondition(condition: .onQueue(.main)) setupCGM() if cgmManager?.pluginIdentifier != oldValue?.pluginIdentifier { @@ -116,10 +127,8 @@ final class DeviceDataManager { // MARK: - Pump - var pumpManager: PumpManagerUI? { + var pumpManager: PumpManager? { didSet { - dispatchPrecondition(condition: .onQueue(.main)) - // If the current CGMManager is a PumpManager, we clear it out. if cgmManager is PumpManagerUI { cgmManager = nil @@ -149,20 +158,13 @@ final class DeviceDataManager { var doseEnactor = DoseEnactor() // MARK: Stores - let healthStore: HKHealthStore - - let carbStore: CarbStore - - let doseStore: DoseStore - - let glucoseStore: GlucoseStore - - let cgmEventStore: CgmEventStore - + private let healthStore: HKHealthStore + private let carbStore: CarbStore + private let doseStore: DoseStore + private let glucoseStore: GlucoseStore private let cacheStore: PersistenceController + private let cgmEventStore: CgmEventStore - let dosingDecisionStore: DosingDecisionStore - /// All the HealthKit types to be read by stores private var readTypes: Set { var readTypes: Set = [] @@ -207,51 +209,48 @@ final class DeviceDataManager { sleepDataAuthorizationRequired } - private(set) var statefulPluginManager: StatefulPluginManager! - // MARK: Services - private(set) var servicesManager: ServicesManager! + private var analyticsServicesManager: AnalyticsServicesManager + private var uploadEventListener: UploadEventListener + private var activeServicesProvider: ActiveServicesProvider - var analyticsServicesManager: AnalyticsServicesManager + // MARK: Misc Managers - var settingsManager: SettingsManager - - var remoteDataServicesManager: RemoteDataServicesManager { return servicesManager.remoteDataServicesManager } - - var criticalEventLogExportManager: CriticalEventLogExportManager! - - var crashRecoveryManager: CrashRecoveryManager + private let settingsManager: SettingsManager + private let crashRecoveryManager: CrashRecoveryManager + private let activeStatefulPluginsProvider: ActiveStatefulPluginsProvider private(set) var pumpManagerHUDProvider: HUDProvider? - private var trustedTimeChecker: TrustedTimeChecker - - // MARK: - WatchKit - - private var watchManager: WatchDataManager! - - // MARK: - Status Extension - - private var statusExtensionManager: ExtensionDataManager! + public private(set) var displayGlucosePreference: DisplayGlucosePreference - // MARK: - Initialization + private(set) var loopControl: LoopControl - private(set) var loopManager: LoopDataManager! + private weak var displayGlucoseUnitBroadcaster: DisplayGlucoseUnitBroadcaster? init(pluginManager: PluginManager, alertManager: AlertManager, settingsManager: SettingsManager, - loggingServicesManager: LoggingServicesManager, + healthStore: HKHealthStore, + carbStore: CarbStore, + doseStore: DoseStore, + glucoseStore: GlucoseStore, + cgmEventStore: CgmEventStore, + uploadEventListener: UploadEventListener, + crashRecoveryManager: CrashRecoveryManager, + loopControl: LoopControl, analyticsServicesManager: AnalyticsServicesManager, + activeServicesProvider: ActiveServicesProvider, + activeStatefulPluginsProvider: ActiveStatefulPluginsProvider, bluetoothProvider: BluetoothProvider, alertPresenter: AlertPresenter, automaticDosingStatus: AutomaticDosingStatus, cacheStore: PersistenceController, localCacheDuration: TimeInterval, - overrideHistory: TemporaryScheduleOverrideHistory, - trustedTimeChecker: TrustedTimeChecker) - { + displayGlucosePreference: DisplayGlucosePreference, + displayGlucoseUnitBroadcaster: DisplayGlucoseUnitBroadcaster + ) { let fileManager = FileManager.default let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! @@ -267,190 +266,41 @@ final class DeviceDataManager { self.pluginManager = pluginManager self.alertManager = alertManager + self.settingsManager = settingsManager + self.healthStore = healthStore + self.carbStore = carbStore + self.doseStore = doseStore + self.glucoseStore = glucoseStore + self.cgmEventStore = cgmEventStore + self.loopControl = loopControl + self.analyticsServicesManager = analyticsServicesManager self.bluetoothProvider = bluetoothProvider self.alertPresenter = alertPresenter - - self.healthStore = HKHealthStore() + self.automaticDosingStatus = automaticDosingStatus self.cacheStore = cacheStore - self.settingsManager = settingsManager + self.crashRecoveryManager = crashRecoveryManager + self.activeStatefulPluginsProvider = activeStatefulPluginsProvider + self.uploadEventListener = uploadEventListener + self.activeServicesProvider = activeServicesProvider + self.displayGlucosePreference = displayGlucosePreference + self.displayGlucoseUnitBroadcaster = displayGlucoseUnitBroadcaster - let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - let sensitivitySchedule = settingsManager.latestSettings.insulinSensitivitySchedule - - let carbHealthStore = HealthKitSampleStore( - healthStore: healthStore, - observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitCarbSamplesFromOtherApps, // At some point we should let the user decide which apps they would like to import from. - type: HealthKitSampleStore.carbType, - observationStart: Date().addingTimeInterval(-absorptionTimes.slow * 2) - ) - - self.carbStore = CarbStore( - healthKitSampleStore: carbHealthStore, - cacheStore: cacheStore, - cacheLength: localCacheDuration, - defaultAbsorptionTimes: absorptionTimes, - carbRatioSchedule: settingsManager.latestSettings.carbRatioSchedule, - insulinSensitivitySchedule: sensitivitySchedule, - overrideHistory: overrideHistory, - carbAbsorptionModel: FeatureFlags.nonlinearCarbModelEnabled ? .nonlinear : .linear, - provenanceIdentifier: HKSource.default().bundleIdentifier - ) - - let insulinModelProvider: InsulinModelProvider - if FeatureFlags.adultChildInsulinModelSelectionEnabled { - insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: settingsManager.latestSettings.defaultRapidActingModel?.presetForRapidActingInsulin) - } else { - insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) - } - - self.analyticsServicesManager = analyticsServicesManager - - let insulinHealthStore = HealthKitSampleStore( - healthStore: healthStore, - observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitDoseSamplesFromOtherApps, - type: HealthKitSampleStore.insulinQuantityType, - observationStart: Date().addingTimeInterval(-absorptionTimes.slow * 2) - ) - - self.doseStore = DoseStore( - healthKitSampleStore: insulinHealthStore, - cacheStore: cacheStore, - cacheLength: localCacheDuration, - insulinModelProvider: insulinModelProvider, - longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, - basalProfile: settingsManager.latestSettings.basalRateSchedule, - insulinSensitivitySchedule: sensitivitySchedule, - overrideHistory: overrideHistory, - lastPumpEventsReconciliation: nil, // PumpManager is nil at this point. Will update this via addPumpEvents below - provenanceIdentifier: HKSource.default().bundleIdentifier - ) - - let glucoseHealthStore = HealthKitSampleStore( - healthStore: healthStore, - observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitGlucoseSamplesFromOtherApps, - type: HealthKitSampleStore.glucoseType, - observationStart: Date().addingTimeInterval(-.hours(24)) - ) - - self.glucoseStore = GlucoseStore( - healthKitSampleStore: glucoseHealthStore, - cacheStore: cacheStore, - cacheLength: localCacheDuration, - provenanceIdentifier: HKSource.default().bundleIdentifier - ) - cgmStalenessMonitor = CGMStalenessMonitor() cgmStalenessMonitor.delegate = glucoseStore - cgmEventStore = CgmEventStore(cacheStore: cacheStore, cacheLength: localCacheDuration) - - dosingDecisionStore = DosingDecisionStore(store: cacheStore, expireAfter: localCacheDuration) - cgmHasValidSensorSession = false pumpIsAllowingAutomation = true - self.automaticDosingStatus = automaticDosingStatus - - // HealthStorePreferredGlucoseUnitDidChange will be notified once the user completes the health access form. Set to .milligramsPerDeciliter until then - displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) - - self.trustedTimeChecker = trustedTimeChecker - - crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) - alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) - - if let pumpManagerRawValue = rawPumpManager ?? UserDefaults.appGroup?.legacyPumpManagerRawValue { - pumpManager = pumpManagerFromRawValue(pumpManagerRawValue) - // Update lastPumpEventsReconciliation on DoseStore - if let lastSync = pumpManager?.lastSync { - doseStore.addPumpEvents([], lastReconciliation: lastSync) { _ in } - } - if let status = pumpManager?.status { - updatePumpIsAllowingAutomation(status: status) - } - } else { - pumpManager = nil - } - - if let cgmManagerRawValue = rawCGMManager ?? UserDefaults.appGroup?.legacyCGMManagerRawValue { - cgmManager = cgmManagerFromRawValue(cgmManagerRawValue) - - // Handle case of PumpManager providing CGM - if cgmManager == nil && pumpManagerTypeFromRawValue(cgmManagerRawValue) != nil { - cgmManager = pumpManager as? CGMManager - } - } - - //TODO The instantiation of these non-device related managers should be moved to LoopAppManager, and then LoopAppManager can wire up the connections between them. - statusExtensionManager = ExtensionDataManager(deviceDataManager: self, automaticDosingStatus: automaticDosingStatus) - - loopManager = LoopDataManager( - lastLoopCompleted: ExtensionDataManager.lastLoopCompleted, - basalDeliveryState: pumpManager?.status.basalDeliveryState, - settings: settingsManager.loopSettings, - overrideHistory: overrideHistory, - analyticsServicesManager: analyticsServicesManager, - localCacheDuration: localCacheDuration, - doseStore: doseStore, - glucoseStore: glucoseStore, - carbStore: carbStore, - dosingDecisionStore: dosingDecisionStore, - latestStoredSettingsProvider: settingsManager, - pumpInsulinType: pumpManager?.status.insulinType, - automaticDosingStatus: automaticDosingStatus, - trustedTimeOffset: { trustedTimeChecker.detectedSystemTimeOffset } - ) - cacheStore.delegate = loopManager - loopManager.presetActivationObservers.append(alertManager) - loopManager.presetActivationObservers.append(analyticsServicesManager) - - watchManager = WatchDataManager(deviceManager: self, healthStore: healthStore) - - let remoteDataServicesManager = RemoteDataServicesManager( - alertStore: alertManager.alertStore, - carbStore: carbStore, - doseStore: doseStore, - dosingDecisionStore: dosingDecisionStore, - glucoseStore: glucoseStore, - cgmEventStore: cgmEventStore, - settingsStore: settingsManager.settingsStore, - overrideHistory: overrideHistory, - insulinDeliveryStore: doseStore.insulinDeliveryStore - ) - - settingsManager.remoteDataServicesManager = remoteDataServicesManager - - servicesManager = ServicesManager( - pluginManager: pluginManager, - alertManager: alertManager, - analyticsServicesManager: analyticsServicesManager, - loggingServicesManager: loggingServicesManager, - remoteDataServicesManager: remoteDataServicesManager, - settingsManager: settingsManager, - servicesManagerDelegate: loopManager, - servicesManagerDosingDelegate: self - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, - directory: FileManager.default.exportsDirectoryURL, - historicalDuration: Bundle.main.localCacheDuration) - - loopManager.delegate = self alertManager.alertStore.delegate = self carbStore.delegate = self doseStore.delegate = self - dosingDecisionStore.delegate = self glucoseStore.delegate = self cgmEventStore.delegate = self doseStore.insulinDeliveryStore.delegate = self - remoteDataServicesManager.delegate = self setupPump() setupCGM() - + cgmStalenessMonitor.$cgmDataIsStale .combineLatest($cgmHasValidSensorSession) .map { $0 == false || $1 } @@ -460,17 +310,28 @@ final class DeviceDataManager { .removeDuplicates() .assign(to: \.automaticDosingStatus.isAutomaticDosingAllowed, on: self) .store(in: &cancellables) + } - NotificationCenter.default.addObserver(forName: .HealthStorePreferredGlucoseUnitDidChange, object: healthStore, queue: nil) { [weak self] _ in - guard let self else { - return + func instantiateDeviceManagers() { + if let pumpManagerRawValue = rawPumpManager ?? UserDefaults.appGroup?.legacyPumpManagerRawValue { + pumpManager = pumpManagerFromRawValue(pumpManagerRawValue) + // Update lastPumpEventsReconciliation on DoseStore + if let lastSync = pumpManager?.lastSync { + doseStore.addPumpEvents([], lastReconciliation: lastSync) { _ in } + } + if let status = pumpManager?.status { + updatePumpIsAllowingAutomation(status: status) } + } else { + pumpManager = nil + } - Task { @MainActor in - if let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) { - self.displayGlucosePreference.unitDidChange(to: unit) - self.notifyObserversOfDisplayGlucoseUnitChange(to: unit) - } + if let cgmManagerRawValue = rawCGMManager ?? UserDefaults.appGroup?.legacyCGMManagerRawValue { + cgmManager = cgmManagerFromRawValue(cgmManagerRawValue) + + // Handle case of PumpManager providing CGM + if cgmManager == nil && pumpManagerTypeFromRawValue(cgmManagerRawValue) != nil { + cgmManager = pumpManager as? CGMManager } } } @@ -521,7 +382,7 @@ final class DeviceDataManager { } public func saveUpdatedBasalRateSchedule(_ basalRateSchedule: BasalRateSchedule) { - var therapySettings = self.loopManager.therapySettings + var therapySettings = self.settingsManager.therapySettings therapySettings.basalRateSchedule = basalRateSchedule self.saveCompletion(therapySettings: therapySettings) } @@ -548,7 +409,7 @@ final class DeviceDataManager { return Manager.init(rawState: rawState) as? PumpManagerUI } - private func checkPumpDataAndLoop() { + private func checkPumpDataAndLoop() async { guard !crashRecoveryManager.pendingCrashRecovery else { self.log.default("Loop paused pending crash recovery acknowledgement.") return @@ -557,34 +418,48 @@ final class DeviceDataManager { self.log.default("Asserting current pump data") guard let pumpManager = pumpManager else { // Run loop, even if pump is missing, to ensure stored dosing decision - self.loopManager.loop() + await self.loopControl.loop() return } - pumpManager.ensureCurrentPumpData() { (lastSync) in - self.loopManager.loop() + let _ = await pumpManager.ensureCurrentPumpData() + await self.loopControl.loop() + } + + + /// An active high temp basal (greater than the basal schedule) is cancelled when the CGM data is unreliable. + private func receivedUnreliableCGMReading() async { + guard case .tempBasal(let tempBasal) = pumpManager?.status.basalDeliveryState else { + return + } + + guard let scheduledBasalRate = settingsManager.settings.basalRateSchedule?.value(at: tempBasal.startDate), + tempBasal.unitsPerHour > scheduledBasalRate else + { + return } + + // Cancel active high temp basal + await loopControl.cancelActiveTempBasal(for: .unreliableCGMData) } - private func processCGMReadingResult(_ manager: CGMManager, readingResult: CGMReadingResult, completion: @escaping () -> Void) { + private func processCGMReadingResult(_ manager: CGMManager, readingResult: CGMReadingResult) async { switch readingResult { case .newData(let values): - loopManager.addGlucoseSamples(values) { result in - if !values.isEmpty { - DispatchQueue.main.async { - self.cgmStalenessMonitor.cgmGlucoseSamplesAvailable(values) - } - } - completion() + do { + let _ = try await glucoseStore.addGlucoseSamples(values) + } catch { + log.error("Unable to store glucose: %{public}@", String(describing: error)) + } + if !values.isEmpty { + self.cgmStalenessMonitor.cgmGlucoseSamplesAvailable(values) } case .unreliableData: - loopManager.receivedUnreliableCGMReading() - completion() + await self.receivedUnreliableCGMReading() case .noData: - completion() + break case .error(let error): self.setLastError(error: error) - completion() } updatePumpManagerBLEHeartbeatPreference() } @@ -643,7 +518,7 @@ final class DeviceDataManager { public func cgmManagerTypeByIdentifier(_ identifier: String) -> CGMManagerUI.Type? { return pluginManager.getCGMManagerTypeByIdentifier(identifier) ?? staticCGMManagersByIdentifier[identifier] as? CGMManagerUI.Type } - + public func setupCGMManagerFromPumpManager(withIdentifier identifier: String) -> CGMManager? { guard identifier == pumpManager?.pluginIdentifier, let cgmManager = pumpManager as? CGMManager else { return nil @@ -674,9 +549,7 @@ final class DeviceDataManager { func checkDeliveryUncertaintyState() { if let pumpManager = pumpManager, pumpManager.status.deliveryIsUncertain { - DispatchQueue.main.async { - self.deliveryUncertaintyAlertManager?.showAlert() - } + self.deliveryUncertaintyAlertManager?.showAlert() } } @@ -700,14 +573,49 @@ final class DeviceDataManager { self.getHealthStoreAuthorization(completion) } } + + private func refreshCGM() async { + guard let cgmManager = cgmManager else { + return + } + + let result = await cgmManager.fetchNewDataIfNeeded() + + if case .newData = result { + self.analyticsServicesManager.didFetchNewCGMData() + } + + await self.processCGMReadingResult(cgmManager, readingResult: result) + + let lastLoopCompleted = self.loopControl.lastLoopCompleted + + if lastLoopCompleted == nil || lastLoopCompleted!.timeIntervalSinceNow < -.minutes(4.2) { + self.log.default("Triggering Loop from refreshCGM()") + await self.checkPumpDataAndLoop() + } + } + + func refreshDeviceData() async { + await refreshCGM() + + guard let pumpManager = self.pumpManager, pumpManager.isOnboarded else { + return + } + + await pumpManager.ensureCurrentPumpData() + } + + var isGlucoseValueStale: Bool { + guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return true } + + return Date().timeIntervalSince(latestGlucoseDataDate) > LoopAlgorithm.inputDataRecencyInterval + } } private extension DeviceDataManager { func setupCGM() { - dispatchPrecondition(condition: .onQueue(.main)) - cgmManager?.cgmManagerDelegate = self - cgmManager?.delegateQueue = queue + cgmManager?.delegateQueue = DispatchQueue.main reportPluginInitializationComplete() glucoseStore.managedDataInterval = cgmManager?.managedDataInterval @@ -725,7 +633,7 @@ private extension DeviceDataManager { } if let cgmManagerUI = cgmManager as? CGMManagerUI { - addDisplayGlucoseUnitObserver(cgmManagerUI) + displayGlucoseUnitBroadcaster?.addDisplayGlucoseUnitObserver(cgmManagerUI) } } @@ -733,17 +641,17 @@ private extension DeviceDataManager { dispatchPrecondition(condition: .onQueue(.main)) pumpManager?.pumpManagerDelegate = self - pumpManager?.delegateQueue = queue + pumpManager?.delegateQueue = DispatchQueue.main reportPluginInitializationComplete() doseStore.device = pumpManager?.status.device - pumpManagerHUDProvider = pumpManager?.hudProvider(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowedInsulinTypes: allowedInsulinTypes) + pumpManagerHUDProvider = (pumpManager as? PumpManagerUI)?.hudProvider(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowedInsulinTypes: allowedInsulinTypes) // Proliferate PumpModel preferences to DoseStore if let pumpRecordsBasalProfileStartEvents = pumpManager?.pumpRecordsBasalProfileStartEvents { doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents } - if let pumpManager = pumpManager { + if let pumpManager = pumpManager as? PumpManagerUI { alertManager?.addAlertResponder(managerIdentifier: pumpManager.pluginIdentifier, alertResponder: pumpManager) alertManager?.addAlertSoundVendor(managerIdentifier: pumpManager.pluginIdentifier, @@ -767,11 +675,11 @@ extension DeviceDataManager { func reportPluginInitializationComplete() { let allActivePlugins = self.allActivePlugins - for plugin in servicesManager.activeServices { + for plugin in activeServicesProvider.activeServices { plugin.initializationComplete(for: allActivePlugins) } - for plugin in statefulPluginManager.activeStatefulPlugins { + for plugin in activeStatefulPluginsProvider.activeStatefulPlugins { plugin.initializationComplete(for: allActivePlugins) } @@ -784,9 +692,9 @@ extension DeviceDataManager { } var allActivePlugins: [Pluggable] { - var allActivePlugins: [Pluggable] = servicesManager.activeServices + var allActivePlugins: [Pluggable] = activeServicesProvider.activeServices - for plugin in statefulPluginManager.activeStatefulPlugins { + for plugin in activeStatefulPluginsProvider.activeStatefulPlugins { if !allActivePlugins.contains(where: { $0.pluginIdentifier == plugin.pluginIdentifier }) { allActivePlugins.append(plugin) } @@ -816,13 +724,12 @@ extension DeviceDataManager { // MARK: - Client API extension DeviceDataManager { - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { + func enactBolus(units: Double, activationType: BolusActivationType) async throws { guard let pumpManager = pumpManager else { - completion(LoopError.configurationError(.pumpManager)) - return + throw LoopError.configurationError(.pumpManager) } - self.loopManager.addRequestedBolus(DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true)) { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in pumpManager.enactBolus(units: units, activationType: activationType) { (error) in if let error = error { self.log.error("%{public}@", String(describing: error)) @@ -836,33 +743,14 @@ extension DeviceDataManager { NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), activationType: activationType) } } - - self.loopManager.bolusRequestFailed(error) { - completion(error) - } + continuation.resume(throwing: error) } else { - self.loopManager.bolusConfirmed() { - completion(nil) - } + continuation.resume() } } - // Trigger forecast/recommendation update for remote clients - self.loopManager.updateRemoteRecommendation() } } - func enactBolus(units: Double, activationType: BolusActivationType) async throws { - return try await withCheckedThrowingContinuation { continuation in - enactBolus(units: units, activationType: activationType) { error in - if let error = error { - continuation.resume(throwing: error) - return - } - continuation.resume() - } - } - } - var pumpManagerStatus: PumpManagerStatus? { return pumpManager?.status } @@ -952,6 +840,7 @@ extension DeviceDataManager: PersistedAlertStore { precondition(alertManager != nil) alertManager.doesIssuedAlertExist(identifier: identifier, completion: completion) } + func lookupAllUnretracted(managerIdentifier: String, completion: @escaping (Swift.Result<[PersistedAlert], Error>) -> Void) { precondition(alertManager != nil) alertManager.lookupAllUnretracted(managerIdentifier: managerIdentifier, completion: completion) @@ -970,34 +859,33 @@ extension DeviceDataManager: PersistedAlertStore { // MARK: - CGMManagerDelegate extension DeviceDataManager: CGMManagerDelegate { + nonisolated func cgmManagerWantsDeletion(_ manager: CGMManager) { - dispatchPrecondition(condition: .onQueue(queue)) - - log.default("CGM manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) - DispatchQueue.main.async { + self.log.default("CGM manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) if let cgmManagerUI = self.cgmManager as? CGMManagerUI { - self.removeDisplayGlucoseUnitObserver(cgmManagerUI) + self.displayGlucoseUnitBroadcaster?.removeDisplayGlucoseUnitObserver(cgmManagerUI) } self.cgmManager = nil - self.displayGlucoseUnitObservers.cleanupDeallocatedElements() self.settingsManager.storeSettings() } } + nonisolated func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("CGMManager:%{public}@ did update with %{public}@", String(describing: type(of: manager)), String(describing: readingResult)) - processCGMReadingResult(manager, readingResult: readingResult) { + Task { @MainActor in + log.default("CGMManager:%{public}@ did update with %{public}@", String(describing: type(of: manager)), String(describing: readingResult)) + await processCGMReadingResult(manager, readingResult: readingResult) let now = Date() if case .newData = readingResult, now.timeIntervalSince(self.lastCGMLoopTrigger) > .minutes(4.2) { self.log.default("Triggering loop from new CGM data at %{public}@", String(describing: now)) self.lastCGMLoopTrigger = now - self.checkPumpDataAndLoop() + await self.checkPumpDataAndLoop() } } } + nonisolated func cgmManager(_ manager: LoopKit.CGMManager, hasNew events: [PersistedCgmEvent]) { Task { do { @@ -1009,12 +897,12 @@ extension DeviceDataManager: CGMManagerDelegate { } func startDateToFilterNewData(for manager: CGMManager) -> Date? { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) return glucoseStore.latestGlucose?.startDate } func cgmManagerDidUpdateState(_ manager: CGMManager) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) rawCGMManager = manager.rawValue } @@ -1023,6 +911,7 @@ extension DeviceDataManager: CGMManagerDelegate { return UUID().uuidString } + nonisolated func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) { DispatchQueue.main.async { if self.cgmHasValidSensorSession != status.hasValidSensorSession { @@ -1036,32 +925,37 @@ extension DeviceDataManager: CGMManagerDelegate { extension DeviceDataManager: CGMManagerOnboardingDelegate { func cgmManagerOnboarding(didCreateCGMManager cgmManager: CGMManagerUI) { - log.default("CGM manager with identifier '%{public}@' created", cgmManager.pluginIdentifier) - self.cgmManager = cgmManager + Task { @MainActor in + log.default("CGM manager with identifier '%{public}@' created", cgmManager.pluginIdentifier) + self.cgmManager = cgmManager + } } func cgmManagerOnboarding(didOnboardCGMManager cgmManager: CGMManagerUI) { precondition(cgmManager.isOnboarded) log.default("CGM manager with identifier '%{public}@' onboarded", cgmManager.pluginIdentifier) - DispatchQueue.main.async { - self.refreshDeviceData() - self.settingsManager.storeSettings() + Task { @MainActor in + await refreshDeviceData() + settingsManager.storeSettings() } } } // MARK: - PumpManagerDelegate extension DeviceDataManager: PumpManagerDelegate { + + var detectedSystemTimeOffset: TimeInterval { UserDefaults.standard.detectedSystemTimeOffset ?? 0 } + func pumpManager(_ pumpManager: PumpManager, didAdjustPumpClockBy adjustment: TimeInterval) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did adjust pump clock by %fs", String(describing: type(of: pumpManager)), adjustment) analyticsServicesManager.pumpTimeDidDrift(adjustment) } func pumpManagerDidUpdateState(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update state", String(describing: type(of: pumpManager))) rawPumpManager = pumpManager.rawValue @@ -1073,47 +967,14 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManagerBLEHeartbeatDidFire(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("PumpManager:%{public}@ did fire heartbeat", String(describing: type(of: pumpManager))) - refreshCGM() - } - - private func refreshCGM(_ completion: (() -> Void)? = nil) { - guard let cgmManager = cgmManager else { - completion?() - return - } - - cgmManager.fetchNewDataIfNeeded { (result) in - if case .newData = result { - self.analyticsServicesManager.didFetchNewCGMData() - } - - self.queue.async { - self.processCGMReadingResult(cgmManager, readingResult: result) { - if self.loopManager.lastLoopCompleted == nil || self.loopManager.lastLoopCompleted!.timeIntervalSinceNow < -.minutes(4.2) { - self.log.default("Triggering Loop from refreshCGM()") - self.checkPumpDataAndLoop() - } - completion?() - } - } - } - } - - func refreshDeviceData() { - refreshCGM() { - self.queue.async { - guard let pumpManager = self.pumpManager, pumpManager.isOnboarded else { - return - } - pumpManager.ensureCurrentPumpData(completion: nil) - } + Task { @MainActor in + log.default("PumpManager:%{public}@ did fire heartbeat", String(describing: type(of: pumpManager))) + await refreshCGM() } } func pumpManagerMustProvideBLEHeartbeat(_ pumpManager: PumpManager) -> Bool { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) return pumpManagerMustProvideBLEHeartbeat } @@ -1126,7 +987,7 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update status: %{public}@", String(describing: type(of: pumpManager)), String(describing: status)) doseStore.device = status.device @@ -1137,19 +998,11 @@ extension DeviceDataManager: PumpManagerDelegate { analyticsServicesManager.pumpBatteryWasReplaced() } - if status.basalDeliveryState != oldStatus.basalDeliveryState { - loopManager.basalDeliveryState = status.basalDeliveryState - } - updatePumpIsAllowingAutomation(status: status) // Update the pump-schedule based settings - loopManager.setScheduleTimeZone(status.timeZone) - - if status.insulinType != oldStatus.insulinType { - loopManager.pumpInsulinType = status.insulinType - } - + settingsManager.setScheduleTimeZone(status.timeZone) + if status.deliveryIsUncertain != oldStatus.deliveryIsUncertain { DispatchQueue.main.async { if status.deliveryIsUncertain { @@ -1173,26 +1026,23 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(queue)) - + dispatchPrecondition(condition: .onQueue(.main)) log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.pluginIdentifier) - DispatchQueue.main.async { - self.pumpManager = nil - self.deliveryUncertaintyAlertManager = nil - self.settingsManager.storeSettings() - } + self.pumpManager = nil + deliveryUncertaintyAlertManager = nil + settingsManager.storeSettings() } func pumpManager(_ pumpManager: PumpManager, didUpdatePumpRecordsBasalProfileStartEvents pumpRecordsBasalProfileStartEvents: Bool) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update pumpRecordsBasalProfileStartEvents to %{public}@", String(describing: type(of: pumpManager)), String(describing: pumpRecordsBasalProfileStartEvents)) doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents } func pumpManager(_ pumpManager: PumpManager, didError error: PumpManagerError) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.error("PumpManager:%{public}@ did error: %{public}@", String(describing: type(of: pumpManager)), String(describing: error)) setLastError(error: error) @@ -1205,7 +1055,7 @@ extension DeviceDataManager: PumpManagerDelegate { replacePendingEvents: Bool, completion: @escaping (_ error: Error?) -> Void) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ hasNewPumpEvents (lastReconciliation = %{public}@)", String(describing: type(of: pumpManager)), String(describing: lastReconciliation)) doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation, replacePendingEvents: replacePendingEvents) { (error) in @@ -1221,23 +1071,57 @@ extension DeviceDataManager: PumpManagerDelegate { } } - func pumpManager(_ pumpManager: PumpManager, didReadReservoirValue units: Double, at date: Date, completion: @escaping (_ result: Swift.Result<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool), Error>) -> Void) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("PumpManager:%{public}@ did read reservoir value", String(describing: type(of: pumpManager))) + func pumpManager( + _ pumpManager: PumpManager, + didReadReservoirValue units: Double, + at date: Date, + completion: @escaping (_ result: Swift.Result<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool), Error>) -> Void + ) { + Task { @MainActor in + dispatchPrecondition(condition: .onQueue(.main)) + log.default("PumpManager:%{public}@ did read reservoir value", String(describing: type(of: pumpManager))) - loopManager.addReservoirValue(units, at: date) { (result) in - switch result { - case .failure(let error): + do { + let (newValue, lastValue, areStoredValuesContinuous) = try await addReservoirValue(units, at: date) + completion(.success((newValue: newValue, lastValue: lastValue, areStoredValuesContinuous: areStoredValuesContinuous))) + } catch { self.log.error("Failed to addReservoirValue: %{public}@", String(describing: error)) completion(.failure(error)) - case .success(let (newValue, lastValue, areStoredValuesContinuous)): - completion(.success((newValue: newValue, lastValue: lastValue, areStoredValuesContinuous: areStoredValuesContinuous))) } } } + /// Adds and stores a pump reservoir volume + /// + /// - Parameters: + /// - units: The reservoir volume, in units + /// - date: The date of the volume reading + /// - completion: A closure called once upon completion + /// - result: The current state of the reservoir values: + /// - newValue: The new stored value + /// - lastValue: The previous new stored value + /// - areStoredValuesContinuous: Whether the current recent state of the stored reservoir data is considered continuous and reliable for deriving insulin effects after addition of this new value. + func addReservoirValue(_ units: Double, at date: Date) async throws -> (newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool) { + try await withCheckedThrowingContinuation { continuation in + doseStore.addReservoirValue(units, at: date) { (newValue, previousValue, areStoredValuesContinuous, error) in + if let error = error { + continuation.resume(throwing: error) + } else if let newValue = newValue { + continuation.resume(returning: ( + newValue: newValue, + lastValue: previousValue, + areStoredValuesContinuous: areStoredValuesContinuous + )) + } else { + assertionFailure() + } + } + } + } + + func startDateToFilterNewPumpEvents(for manager: PumpManager) -> Date { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) return doseStore.pumpEventQueryAfterDate } @@ -1255,12 +1139,12 @@ extension DeviceDataManager: PumpManagerOnboardingDelegate { } func pumpManagerOnboarding(didOnboardPumpManager pumpManager: PumpManagerUI) { - precondition(pumpManager.isOnboarded) - log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) + Task { @MainActor in + precondition(pumpManager.isOnboarded) + log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) - DispatchQueue.main.async { - self.refreshDeviceData() - self.settingsManager.storeSettings() + await refreshDeviceData() + settingsManager.storeSettings() } } @@ -1272,14 +1156,14 @@ extension DeviceDataManager: PumpManagerOnboardingDelegate { // MARK: - AlertStoreDelegate extension DeviceDataManager: AlertStoreDelegate { func alertStoreHasUpdatedAlertData(_ alertStore: AlertStore) { - remoteDataServicesManager.triggerUpload(for: .alert) + uploadEventListener.triggerUpload(for: .alert) } } // MARK: - CarbStoreDelegate extension DeviceDataManager: CarbStoreDelegate { func carbStoreHasUpdatedCarbData(_ carbStore: CarbStore) { - remoteDataServicesManager.triggerUpload(for: .carb) + uploadEventListener.triggerUpload(for: .carb) } func carbStore(_ carbStore: CarbStore, didError error: CarbStore.CarbStoreError) {} @@ -1288,35 +1172,35 @@ extension DeviceDataManager: CarbStoreDelegate { // MARK: - DoseStoreDelegate extension DeviceDataManager: DoseStoreDelegate { func doseStoreHasUpdatedPumpEventData(_ doseStore: DoseStore) { - remoteDataServicesManager.triggerUpload(for: .pumpEvent) + uploadEventListener.triggerUpload(for: .pumpEvent) } } // MARK: - DosingDecisionStoreDelegate extension DeviceDataManager: DosingDecisionStoreDelegate { func dosingDecisionStoreHasUpdatedDosingDecisionData(_ dosingDecisionStore: DosingDecisionStore) { - remoteDataServicesManager.triggerUpload(for: .dosingDecision) + uploadEventListener.triggerUpload(for: .dosingDecision) } } // MARK: - GlucoseStoreDelegate extension DeviceDataManager: GlucoseStoreDelegate { func glucoseStoreHasUpdatedGlucoseData(_ glucoseStore: GlucoseStore) { - remoteDataServicesManager.triggerUpload(for: .glucose) + uploadEventListener.triggerUpload(for: .glucose) } } // MARK: - InsulinDeliveryStoreDelegate extension DeviceDataManager: InsulinDeliveryStoreDelegate { func insulinDeliveryStoreHasUpdatedDoseData(_ insulinDeliveryStore: InsulinDeliveryStore) { - remoteDataServicesManager.triggerUpload(for: .dose) + uploadEventListener.triggerUpload(for: .dose) } } // MARK: - CgmEventStoreDelegate extension DeviceDataManager: CgmEventStoreDelegate { func cgmEventStoreHasUpdatedData(_ cgmEventStore: LoopKit.CgmEventStore) { - remoteDataServicesManager.triggerUpload(for: .cgmEvent) + uploadEventListener.triggerUpload(for: .cgmEvent) } } @@ -1375,55 +1259,10 @@ extension DeviceDataManager { } } -// MARK: - LoopDataManagerDelegate -extension DeviceDataManager: LoopDataManagerDelegate { - func roundBasalRate(unitsPerHour: Double) -> Double { - guard let pumpManager = pumpManager else { - return unitsPerHour - } - - return pumpManager.roundToSupportedBasalRate(unitsPerHour: unitsPerHour) +extension DeviceDataManager: BolusDurationEstimator { + func estimateBolusDuration(bolusUnits: Double) -> TimeInterval? { + pumpManager?.estimatedDuration(toBolus: bolusUnits) } - - func roundBolusVolume(units: Double) -> Double { - guard let pumpManager = pumpManager else { - return units - } - - let rounded = pumpManager.roundToSupportedBolusVolume(units: units) - self.log.default("Rounded %{public}@ to %{public}@", String(describing: units), String(describing: rounded)) - - return rounded - } - - func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? { - pumpManager?.estimatedDuration(toBolus: units) - } - - func loopDataManager( - _ manager: LoopDataManager, - didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), - completion: @escaping (LoopError?) -> Void - ) { - guard let pumpManager = pumpManager else { - completion(LoopError.configurationError(.pumpManager)) - return - } - - guard !pumpManager.status.deliveryIsUncertain else { - completion(LoopError.connectionError) - return - } - - log.default("LoopManager did recommend dose: %{public}@", String(describing: automaticDose.recommendation)) - - crashRecoveryManager.dosingStarted(dose: automaticDose.recommendation) - doseEnactor.enact(recommendation: automaticDose.recommendation, with: pumpManager) { pumpManagerError in - completion(pumpManagerError.map { .pumpManagerError($0) }) - self.crashRecoveryManager.dosingFinished() - } - } - } extension Notification.Name { @@ -1432,151 +1271,6 @@ extension Notification.Name { static let PumpEventsAdded = Notification.Name(rawValue: "com.loopKit.notification.PumpEventsAdded") } -// MARK: - ServicesManagerDosingDelegate - -extension DeviceDataManager: ServicesManagerDosingDelegate { - - func deliverBolus(amountInUnits: Double) async throws { - try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) - } - -} - -// MARK: - Critical Event Log Export - -extension DeviceDataManager { - private static var criticalEventLogHistoricalExportBackgroundTaskIdentifier: String { "com.loopkit.background-task.critical-event-log.historical-export" } - - public static func registerCriticalEventLogHistoricalExportBackgroundTask(_ handler: @escaping (BGProcessingTask) -> Void) -> Bool { - return BGTaskScheduler.shared.register(forTaskWithIdentifier: criticalEventLogHistoricalExportBackgroundTaskIdentifier, using: nil) { handler($0 as! BGProcessingTask) } - } - - public func handleCriticalEventLogHistoricalExportBackgroundTask(_ task: BGProcessingTask) { - dispatchPrecondition(condition: .notOnQueue(.main)) - - scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: true) - - let exporter = criticalEventLogExportManager.createHistoricalExporter() - - task.expirationHandler = { - self.log.default("Invoked critical event log historical export background task expiration handler - cancelling exporter") - exporter.cancel() - } - - DispatchQueue.global(qos: .background).async { - exporter.export() { error in - if let error = error { - self.log.error("Critical event log historical export errored: %{public}@", String(describing: error)) - } - - self.scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: error != nil && !exporter.isCancelled) - task.setTaskCompleted(success: error == nil) - - self.log.default("Completed critical event log historical export background task") - } - } - } - - public func scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: Bool = false) { - do { - let earliestBeginDate = isRetry ? criticalEventLogExportManager.retryExportHistoricalDate() : criticalEventLogExportManager.nextExportHistoricalDate() - let request = BGProcessingTaskRequest(identifier: Self.criticalEventLogHistoricalExportBackgroundTaskIdentifier) - request.earliestBeginDate = earliestBeginDate - request.requiresExternalPower = true - - try BGTaskScheduler.shared.submit(request) - - log.default("Scheduled critical event log historical export background task: %{public}@", ISO8601DateFormatter().string(from: earliestBeginDate)) - } catch let error { - #if IOS_SIMULATOR - log.debug("Failed to schedule critical event log export background task due to running on simulator") - #else - log.error("Failed to schedule critical event log export background task: %{public}@", String(describing: error)) - #endif - } - } - - public func removeExportsDirectory() -> Error? { - let fileManager = FileManager.default - let exportsDirectoryURL = fileManager.exportsDirectoryURL - - guard fileManager.fileExists(atPath: exportsDirectoryURL.path) else { - return nil - } - - do { - try fileManager.removeItem(at: exportsDirectoryURL) - } catch let error { - return error - } - - return nil - } -} - -// MARK: - Simulated Core Data - -extension DeviceDataManager { - func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) { - guard FeatureFlags.simulatedCoreDataEnabled else { - fatalError("\(#function) should be invoked only when simulated core data is enabled") - } - - settingsManager.settingsStore.generateSimulatedHistoricalSettingsObjects() { error in - guard error == nil else { - completion(error) - return - } - self.loopManager.generateSimulatedHistoricalCoreData() { error in - guard error == nil else { - completion(error) - return - } - self.deviceLog.generateSimulatedHistoricalDeviceLogEntries() { error in - guard error == nil else { - completion(error) - return - } - self.alertManager.alertStore.generateSimulatedHistoricalStoredAlerts(completion: completion) - } - } - } - } - - func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) { - guard FeatureFlags.simulatedCoreDataEnabled else { - fatalError("\(#function) should be invoked only when simulated core data is enabled") - } - - alertManager.alertStore.purgeHistoricalStoredAlerts() { error in - guard error == nil else { - completion(error) - return - } - self.deviceLog.purgeHistoricalDeviceLogEntries() { error in - guard error == nil else { - completion(error) - return - } - self.loopManager.purgeHistoricalCoreData { error in - guard error == nil else { - completion(error) - return - } - self.settingsManager.purgeHistoricalSettingsObjects(completion: completion) - } - } - } - } -} - -fileprivate extension FileManager { - var exportsDirectoryURL: URL { - let applicationSupportDirectory = try! url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - return applicationSupportDirectory.appendingPathComponent(Bundle.main.bundleIdentifier!).appendingPathComponent("Exports") - } -} - //MARK: - CGMStalenessMonitorDelegate protocol conformance extension GlucoseStore : CGMStalenessMonitorDelegate { } @@ -1621,22 +1315,25 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { pumpManager?.syncBasalRateSchedule(items: items, completion: completion) } - func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Swift.Result) -> Void) { - // FIRST we need to check to make sure if we have to cancel temp basal first - loopManager.maxTempBasalSavePreflight(unitsPerHour: deliveryLimits.maximumBasalRate?.doubleValue(for: .internationalUnitsPerHour)) { [weak self] error in - if let error = error { - completion(.failure(CancelTempBasalFailedError(reason: error))) - } else if let pumpManager = self?.pumpManager { - pumpManager.syncDeliveryLimits(limits: deliveryLimits, completion: completion) - } else { - completion(.success(deliveryLimits)) + func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits + { + do { + // FIRST we need to check to make sure if we have to cancel temp basal first + if let maxRate = deliveryLimits.maximumBasalRate?.doubleValue(for: .internationalUnitsPerHour), + case .tempBasal(let dose) = basalDeliveryState, + dose.unitsPerHour > maxRate + { + // Temp basal is higher than proposed rate, so should cancel + await self.loopControl.cancelActiveTempBasal(for: .maximumBasalRateChanged) } + return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits + } catch { + throw CancelTempBasalFailedError(reason: error) } } - - func saveCompletion(therapySettings: TherapySettings) { - loopManager.mutateSettings { settings in + func saveCompletion(therapySettings: TherapySettings) { + settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout @@ -1660,90 +1357,83 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { } } -extension DeviceDataManager { - func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { - let queue = DispatchQueue.main - displayGlucoseUnitObservers.insert(observer, queue: queue) - queue.async { - observer.unitDidChange(to: self.displayGlucosePreference.unit) - } +extension DeviceDataManager: DeviceSupportDelegate { + var availableSupports: [SupportUI] { [cgmManager, pumpManager].compactMap { $0 as? SupportUI } } + + func generateDiagnosticReport() async -> String { + let report = [ + "", + "## DeviceDataManager", + "* launchDate: \(self.launchDate)", + "* lastError: \(String(describing: self.lastError))", + "", + "cacheStore: \(String(reflecting: self.cacheStore))", + "", + self.cgmManager != nil ? String(reflecting: self.cgmManager!) : "cgmManager: nil", + "", + self.pumpManager != nil ? String(reflecting: self.pumpManager!) : "pumpManager: nil", + "", + await deviceLog.generateDiagnosticReport() + ] + return report.joined(separator: "\n") } +} - func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { - displayGlucoseUnitObservers.removeElement(observer) +extension DeviceDataManager: DeliveryDelegate { + var isPumpConfigured: Bool { + return pumpManager != nil } - func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { - self.displayGlucoseUnitObservers.forEach { - $0.unitDidChange(to: displayGlucoseUnit) + func roundBasalRate(unitsPerHour: Double) -> Double { + guard let pumpManager = pumpManager else { + return unitsPerHour } - } -} -extension DeviceDataManager: DeviceSupportDelegate { - var availableSupports: [SupportUI] { [cgmManager, pumpManager].compactMap { $0 as? SupportUI } } + return pumpManager.roundToSupportedBasalRate(unitsPerHour: unitsPerHour) + } - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - self.loopManager.generateDiagnosticReport { (loopReport) in + func roundBolusVolume(units: Double) -> Double { + guard let pumpManager = pumpManager else { + return units + } - let logDurationHours = 84.0 + return pumpManager.roundToSupportedBolusVolume(units: units) + } - self.alertManager.getStoredEntries(startDate: Date() - .hours(logDurationHours)) { (alertReport) in - self.deviceLog.getLogEntries(startDate: Date() - .hours(logDurationHours)) { (result) in - let deviceLogReport: String - switch result { - case .failure(let error): - deviceLogReport = "Error fetching entries: \(error)" - case .success(let entries): - deviceLogReport = entries.map { "* \($0.timestamp) \($0.managerIdentifier) \($0.deviceIdentifier ?? "") \($0.type) \($0.message)" }.joined(separator: "\n") - } + var pumpInsulinType: LoopKit.InsulinType? { + return pumpManager?.status.insulinType + } + + var isSuspended: Bool { + return pumpManager?.status.basalDeliveryState?.isSuspended ?? false + } + + func enact(_ recommendation: LoopKit.AutomaticDoseRecommendation) async throws { + guard let pumpManager = pumpManager else { + throw LoopError.configurationError(.pumpManager) + } - let report = [ - "## Build Details", - "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", - "* profileExpiration: \(BuildDetails.default.profileExpirationString)", - "* gitRevision: \(BuildDetails.default.gitRevision ?? "N/A")", - "* gitBranch: \(BuildDetails.default.gitBranch ?? "N/A")", - "* workspaceGitRevision: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", - "* workspaceGitBranch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", - "* sourceRoot: \(BuildDetails.default.sourceRoot ?? "N/A")", - "* buildDateString: \(BuildDetails.default.buildDateString ?? "N/A")", - "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", - "", - "## FeatureFlags", - "\(FeatureFlags)", - "", - alertReport, - "", - "## DeviceDataManager", - "* launchDate: \(self.launchDate)", - "* lastError: \(String(describing: self.lastError))", - "", - "cacheStore: \(String(reflecting: self.cacheStore))", - "", - self.cgmManager != nil ? String(reflecting: self.cgmManager!) : "cgmManager: nil", - "", - self.pumpManager != nil ? String(reflecting: self.pumpManager!) : "pumpManager: nil", - "", - "## Device Communication Log", - deviceLogReport, - "", - String(reflecting: self.watchManager!), - "", - String(reflecting: self.statusExtensionManager!), - "", - loopReport, - ].joined(separator: "\n") - - completion(report) - } - } + guard !pumpManager.status.deliveryIsUncertain else { + throw LoopError.connectionError } + + log.default("Enacting dose: %{public}@", String(describing: recommendation)) + + crashRecoveryManager.dosingStarted(dose: recommendation) + defer { self.crashRecoveryManager.dosingFinished() } + + try await doseEnactor.enact(recommendation: recommendation, with: pumpManager) + } + + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { + return pumpManager?.status.basalDeliveryState } } extension DeviceDataManager: DeviceStatusProvider {} -extension DeviceDataManager { - var detectedSystemTimeOffset: TimeInterval { trustedTimeChecker.detectedSystemTimeOffset } +extension DeviceDataManager: BolusStateProvider { + var bolusState: LoopKit.PumpManagerStatus.BolusState? { + return pumpManager?.status.bolusState + } } diff --git a/Loop/Managers/DoseEnactor.swift b/Loop/Managers/DoseEnactor.swift index 55c782c96c..fc533d6219 100644 --- a/Loop/Managers/DoseEnactor.swift +++ b/Loop/Managers/DoseEnactor.swift @@ -1,4 +1,4 @@ - // +// // DoseEnactor.swift // Loop // @@ -15,47 +15,17 @@ class DoseEnactor { private let log = DiagnosticLog(category: "DoseEnactor") - func enact(recommendation: AutomaticDoseRecommendation, with pumpManager: PumpManager, completion: @escaping (PumpManagerError?) -> Void) { - - dosingQueue.async { - let doseDispatchGroup = DispatchGroup() - - var tempBasalError: PumpManagerError? = nil - var bolusError: PumpManagerError? = nil - - if let basalAdjustment = recommendation.basalAdjustment { - self.log.default("Enacting recommend basal change") + func enact(recommendation: AutomaticDoseRecommendation, with pumpManager: PumpManager) async throws { - doseDispatchGroup.enter() - pumpManager.enactTempBasal(unitsPerHour: basalAdjustment.unitsPerHour, for: basalAdjustment.duration, completion: { error in - if let error = error { - tempBasalError = error - } - doseDispatchGroup.leave() - }) - } - - doseDispatchGroup.wait() + if let basalAdjustment = recommendation.basalAdjustment { + self.log.default("Enacting recommended basal change") + try await pumpManager.enactTempBasal(unitsPerHour: basalAdjustment.unitsPerHour, for: basalAdjustment.duration) + } - guard tempBasalError == nil else { - completion(tempBasalError) - return - } - - if let bolusUnits = recommendation.bolusUnits, bolusUnits > 0 { - self.log.default("Enacting recommended bolus dose") - doseDispatchGroup.enter() - pumpManager.enactBolus(units: bolusUnits, activationType: .automatic) { (error) in - if let error = error { - bolusError = error - } else { - self.log.default("PumpManager successfully issued bolus command") - } - doseDispatchGroup.leave() - } - } - doseDispatchGroup.wait() - completion(bolusError) + if let bolusUnits = recommendation.bolusUnits, bolusUnits > 0 { + self.log.default("Enacting recommended bolus dose") + try await pumpManager.enactBolus(units: bolusUnits, activationType: .automatic) } } } + diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index 9261dcfc43..09d7170237 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -10,18 +10,27 @@ import HealthKit import UIKit import LoopKit - +@MainActor final class ExtensionDataManager { unowned let deviceManager: DeviceDataManager + unowned let loopDataManager: LoopDataManager + unowned let settingsManager: SettingsManager + unowned let temporaryPresetsManager: TemporaryPresetsManager private let automaticDosingStatus: AutomaticDosingStatus init(deviceDataManager: DeviceDataManager, - automaticDosingStatus: AutomaticDosingStatus) - { + loopDataManager: LoopDataManager, + automaticDosingStatus: AutomaticDosingStatus, + settingsManager: SettingsManager, + temporaryPresetsManager: TemporaryPresetsManager + ) { self.deviceManager = deviceDataManager + self.loopDataManager = loopDataManager + self.settingsManager = settingsManager + self.temporaryPresetsManager = temporaryPresetsManager self.automaticDosingStatus = automaticDosingStatus - NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .LoopDataUpdated, object: deviceDataManager.loopManager) + NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .LoopDataUpdated, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .PumpManagerChanged, object: nil) // Wait until LoopDataManager has had a chance to initialize itself @@ -61,114 +70,112 @@ final class ExtensionDataManager { } private func update() { - createStatusContext(glucoseUnit: deviceManager.preferredGlucoseUnit) { (context) in - if let context = context { + Task { @MainActor in + if let context = await createStatusContext(glucoseUnit: deviceManager.displayGlucosePreference.unit) { ExtensionDataManager.context = context } - } - - createIntentsContext { (info) in - if let info = info, ExtensionDataManager.intentExtensionInfo?.overridePresetNames != info.overridePresetNames { + + if let info = createIntentsContext(), ExtensionDataManager.intentExtensionInfo?.overridePresetNames != info.overridePresetNames { ExtensionDataManager.intentExtensionInfo = info } } } - private func createIntentsContext(_ completion: @escaping (_ context: IntentExtensionInfo?) -> Void) { - let presets = deviceManager.loopManager.settings.overridePresets + private func createIntentsContext() -> IntentExtensionInfo? { + let presets = settingsManager.settings.overridePresets let info = IntentExtensionInfo(overridePresetNames: presets.map { $0.name }) - completion(info) + return info } - private func createStatusContext(glucoseUnit: HKUnit, _ completionHandler: @escaping (_ context: StatusExtensionContext?) -> Void) { + private func createStatusContext(glucoseUnit: HKUnit) async -> StatusExtensionContext? { let basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState - deviceManager.loopManager.getLoopState { (manager, state) in - let dataManager = self.deviceManager - var context = StatusExtensionContext() - - context.createdAt = Date() - - #if IOS_SIMULATOR - // If we're in the simulator, there's a higher likelihood that we don't have - // a fully configured app. Inject some baseline debug data to let us test the - // experience. This data will be overwritten by actual data below, if available. - context.batteryPercentage = 0.25 - context.netBasal = NetBasalContext( - rate: 2.1, - percentage: 0.6, - start: - Date(timeIntervalSinceNow: -250), - end: Date(timeIntervalSinceNow: .minutes(30)) - ) - context.predictedGlucose = PredictedGlucoseContext( - values: (1...36).map { 89.123 + Double($0 * 5) }, // 3 hours of linear data - unit: HKUnit.milligramsPerDeciliter, - startDate: Date(), - interval: TimeInterval(minutes: 5)) - - let lastLoopCompleted = Date(timeIntervalSinceNow: -TimeInterval(minutes: 0)) - #else - let lastLoopCompleted = manager.lastLoopCompleted - #endif - - context.lastLoopCompleted = lastLoopCompleted - - context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled - - context.preMealPresetAllowed = self.automaticDosingStatus.automaticDosingEnabled && manager.settings.preMealTargetRange != nil - context.preMealPresetActive = manager.settings.preMealTargetEnabled() - context.customPresetActive = manager.settings.nonPreMealOverrideEnabled() - - // Drop the first element in predictedGlucose because it is the currentGlucose - // and will have a different interval to the next element - if let predictedGlucose = state.predictedGlucoseIncludingPendingInsulin?.dropFirst(), - predictedGlucose.count > 1 { - let first = predictedGlucose[predictedGlucose.startIndex] - let second = predictedGlucose[predictedGlucose.startIndex.advanced(by: 1)] - context.predictedGlucose = PredictedGlucoseContext( - values: predictedGlucose.map { $0.quantity.doubleValue(for: glucoseUnit) }, - unit: glucoseUnit, - startDate: first.startDate, - interval: second.startDate.timeIntervalSince(first.startDate)) - } - - if let basalDeliveryState = basalDeliveryState, - let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory, - let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) - { - context.netBasal = NetBasalContext(rate: netBasal.rate, percentage: netBasal.percent, start: netBasal.start, end: netBasal.end) - } + let state = loopDataManager.algorithmState + + let dataManager = self.deviceManager + var context = StatusExtensionContext() + + context.createdAt = Date() + + #if IOS_SIMULATOR + // If we're in the simulator, there's a higher likelihood that we don't have + // a fully configured app. Inject some baseline debug data to let us test the + // experience. This data will be overwritten by actual data below, if available. + context.batteryPercentage = 0.25 + context.netBasal = NetBasalContext( + rate: 2.1, + percentage: 0.6, + start: + Date(timeIntervalSinceNow: -250), + end: Date(timeIntervalSinceNow: .minutes(30)) + ) + context.predictedGlucose = PredictedGlucoseContext( + values: (1...36).map { 89.123 + Double($0 * 5) }, // 3 hours of linear data + unit: HKUnit.milligramsPerDeciliter, + startDate: Date(), + interval: TimeInterval(minutes: 5)) + + let lastLoopCompleted = Date(timeIntervalSinceNow: -TimeInterval(minutes: 0)) + #else + let lastLoopCompleted = loopDataManager.lastLoopCompleted + #endif + + context.lastLoopCompleted = lastLoopCompleted + + context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled + + context.preMealPresetAllowed = self.automaticDosingStatus.automaticDosingEnabled && self.settingsManager.settings.preMealTargetRange != nil + context.preMealPresetActive = self.temporaryPresetsManager.preMealTargetEnabled() + context.customPresetActive = self.temporaryPresetsManager.nonPreMealOverrideEnabled() + + // Drop the first element in predictedGlucose because it is the currentGlucose + // and will have a different interval to the next element + if let predictedGlucose = state.output?.predictedGlucose.dropFirst(), + predictedGlucose.count > 1 { + let first = predictedGlucose[predictedGlucose.startIndex] + let second = predictedGlucose[predictedGlucose.startIndex.advanced(by: 1)] + context.predictedGlucose = PredictedGlucoseContext( + values: predictedGlucose.map { $0.quantity.doubleValue(for: glucoseUnit) }, + unit: glucoseUnit, + startDate: first.startDate, + interval: second.startDate.timeIntervalSince(first.startDate)) + } - context.batteryPercentage = dataManager.pumpManager?.status.pumpBatteryChargeRemaining - context.reservoirCapacity = dataManager.pumpManager?.pumpReservoirCapacity - - if let glucoseDisplay = dataManager.glucoseDisplay(for: dataManager.glucoseStore.latestGlucose) { - context.glucoseDisplay = GlucoseDisplayableContext( - isStateValid: glucoseDisplay.isStateValid, - stateDescription: glucoseDisplay.stateDescription, - trendType: glucoseDisplay.trendType, - trendRate: glucoseDisplay.trendRate, - isLocal: glucoseDisplay.isLocal, - glucoseRangeCategory: glucoseDisplay.glucoseRangeCategory - ) - } - - if let pumpManagerHUDProvider = dataManager.pumpManagerHUDProvider { - context.pumpManagerHUDViewContext = PumpManagerHUDViewContext(pumpManagerHUDViewRawValue: PumpManagerHUDViewRawValueFromHUDProvider(pumpManagerHUDProvider)) - } - - context.pumpStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.pumpStatusHighlight) - context.pumpLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.pumpLifecycleProgress) + if let basalDeliveryState = basalDeliveryState, + let basalSchedule = self.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory, + let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: self.settingsManager.settings.maximumBasalRatePerHour) + { + context.netBasal = NetBasalContext(rate: netBasal.rate, percentage: netBasal.percent, start: netBasal.start, end: netBasal.end) + } - context.cgmStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.cgmStatusHighlight) - context.cgmLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.cgmLifecycleProgress) + context.batteryPercentage = dataManager.pumpManager?.status.pumpBatteryChargeRemaining + context.reservoirCapacity = dataManager.pumpManager?.pumpReservoirCapacity + + if let glucoseDisplay = dataManager.glucoseDisplay(for: loopDataManager.latestGlucose) { + context.glucoseDisplay = GlucoseDisplayableContext( + isStateValid: glucoseDisplay.isStateValid, + stateDescription: glucoseDisplay.stateDescription, + trendType: glucoseDisplay.trendType, + trendRate: glucoseDisplay.trendRate, + isLocal: glucoseDisplay.isLocal, + glucoseRangeCategory: glucoseDisplay.glucoseRangeCategory + ) + } - context.carbsOnBoard = state.carbsOnBoard?.quantity.doubleValue(for: .gram()) - - completionHandler(context) + if let pumpManagerHUDProvider = dataManager.pumpManagerHUDProvider { + context.pumpManagerHUDViewContext = PumpManagerHUDViewContext(pumpManagerHUDViewRawValue: PumpManagerHUDViewRawValueFromHUDProvider(pumpManagerHUDProvider)) } + + context.pumpStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.pumpStatusHighlight) + context.pumpLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.pumpLifecycleProgress) + + context.cgmStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.cgmStatusHighlight) + context.cgmLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.cgmLifecycleProgress) + + context.carbsOnBoard = state.activeCarbs?.value + + return context } } diff --git a/Loop/Managers/LocalTestingScenariosManager.swift b/Loop/Managers/LocalTestingScenariosManager.swift deleted file mode 100644 index bd1e7e087a..0000000000 --- a/Loop/Managers/LocalTestingScenariosManager.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// LocalTestingScenariosManager.swift -// Loop -// -// Created by Michael Pangburn on 4/22/19. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopKit -import LoopTestingKit -import OSLog - -final class LocalTestingScenariosManager: TestingScenariosManagerRequirements, DirectoryObserver { - - unowned let deviceManager: DeviceDataManager - unowned let supportManager: SupportManager - - let log = DiagnosticLog(category: "LocalTestingScenariosManager") - - private let fileManager = FileManager.default - private let scenariosSource: URL - private var directoryObservationToken: DirectoryObservationToken? - - private(set) var scenarioURLs: [URL] = [] - var activeScenarioURL: URL? - var activeScenario: TestingScenario? - - weak var delegate: TestingScenariosManagerDelegate? { - didSet { - delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) - } - } - - var pluginManager: PluginManager { - deviceManager.pluginManager - } - - init(deviceManager: DeviceDataManager, supportManager: SupportManager) { - guard FeatureFlags.scenariosEnabled else { - fatalError("\(#function) should be invoked only when scenarios are enabled") - } - - self.deviceManager = deviceManager - self.supportManager = supportManager - self.scenariosSource = Bundle.main.bundleURL.appendingPathComponent("Scenarios") - - log.debug("Loading testing scenarios from %{public}@", scenariosSource.path) - if !fileManager.fileExists(atPath: scenariosSource.path) { - do { - try fileManager.createDirectory(at: scenariosSource, withIntermediateDirectories: false) - } catch { - log.error("%{public}@", String(describing: error)) - } - } - - directoryObservationToken = observeDirectory(at: scenariosSource) { [weak self] in - self?.reloadScenarioURLs() - } - reloadScenarioURLs() - } - - func fetchScenario(from url: URL, completion: (Result) -> Void) { - let result = Result(catching: { try TestingScenario(source: url) }) - completion(result) - } - - private func reloadScenarioURLs() { - do { - let scenarioURLs = try fileManager.contentsOfDirectory(at: scenariosSource, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" } - self.scenarioURLs = scenarioURLs - delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) - log.debug("Reloaded scenario URLs") - } catch { - log.error("%{public}@", String(describing: error)) - } - } -} diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index b8e23d0bba..2b026a384a 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -14,6 +14,8 @@ import LoopKitUI import MockKit import HealthKit import WidgetKit +import LoopCore + #if targetEnvironment(simulator) enum SimulatorError: Error { @@ -55,6 +57,7 @@ protocol WindowProvider: AnyObject { var window: UIWindow? { get } } +@MainActor class LoopAppManager: NSObject { private enum State: Int { case initialize @@ -74,6 +77,11 @@ class LoopAppManager: NSObject { private var bluetoothStateManager: BluetoothStateManager! private var alertManager: AlertManager! private var trustedTimeChecker: TrustedTimeChecker! + private var healthStore: HKHealthStore! + private var carbStore: CarbStore! + private var doseStore: DoseStore! + private var glucoseStore: GlucoseStore! + private var dosingDecisionStore: DosingDecisionStore! private var deviceDataManager: DeviceDataManager! private var onboardingManager: OnboardingManager! private var alertPermissionsChecker: AlertPermissionsChecker! @@ -84,8 +92,22 @@ class LoopAppManager: NSObject { private(set) var testingScenariosManager: TestingScenariosManager? private var resetLoopManager: ResetLoopManager! private var deeplinkManager: DeeplinkManager! - - private var overrideHistory = UserDefaults.appGroup?.overrideHistory ?? TemporaryScheduleOverrideHistory.init() + private var temporaryPresetsManager: TemporaryPresetsManager! + private var loopDataManager: LoopDataManager! + private var mealDetectionManager: MealDetectionManager! + private var statusExtensionManager: ExtensionDataManager! + private var watchManager: WatchDataManager! + private var crashRecoveryManager: CrashRecoveryManager! + private var cgmEventStore: CgmEventStore! + private var servicesManager: ServicesManager! + private var remoteDataServicesManager: RemoteDataServicesManager! + private var statefulPluginManager: StatefulPluginManager! + private var criticalEventLogExportManager: CriticalEventLogExportManager! + + // HealthStorePreferredGlucoseUnitDidChange will be notified once the user completes the health access form. Set to .milligramsPerDeciliter until then + public private(set) var displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + + private var displayGlucoseUnitObservers = WeakSynchronizedSet() private var state: State = .initialize @@ -107,43 +129,33 @@ class LoopAppManager: NSObject { INPreferences.requestSiriAuthorization { _ in } } - registerBackgroundTasks() - - if FeatureFlags.remoteCommandsEnabled { - DispatchQueue.main.async { -#if targetEnvironment(simulator) - self.remoteNotificationRegistrationDidFinish(.failure(SimulatorError.remoteNotificationsNotAvailable)) -#else - UIApplication.shared.registerForRemoteNotifications() -#endif - } - } self.state = state.next } func launch() { - dispatchPrecondition(condition: .onQueue(.main)) precondition(isLaunchPending) - resumeLaunch() + Task { + await resumeLaunch() + } } var isLaunchPending: Bool { state == .checkProtectedDataAvailable } var isLaunchComplete: Bool { state == .launchComplete } - private func resumeLaunch() { + private func resumeLaunch() async { if state == .checkProtectedDataAvailable { checkProtectedDataAvailable() } if state == .launchManagers { - launchManagers() + await launchManagers() } if state == .launchOnboarding { launchOnboarding() } if state == .launchHomeScreen { - launchHomeScreen() + await launchHomeScreen() } askUserToConfirmLoopReset() @@ -161,7 +173,7 @@ class LoopAppManager: NSObject { self.state = state.next } - private func launchManagers() { + private func launchManagers() async { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchManagers) @@ -187,48 +199,247 @@ class LoopAppManager: NSObject { alertPermissionsChecker = AlertPermissionsChecker() alertPermissionsChecker.delegate = alertManager - trustedTimeChecker = TrustedTimeChecker(alertManager: alertManager) + trustedTimeChecker = LoopTrustedTimeChecker(alertManager: alertManager) + + settingsManager = SettingsManager( + cacheStore: cacheStore, + expireAfter: localCacheDuration, + alertMuter: alertManager.alertMuter, + analyticsServicesManager: analyticsServicesManager + ) + + // Once settings manager is initialized, we can register for remote notifications + if FeatureFlags.remoteCommandsEnabled { + DispatchQueue.main.async { +#if targetEnvironment(simulator) + self.remoteNotificationRegistrationDidFinish(.failure(SimulatorError.remoteNotificationsNotAvailable)) +#else + UIApplication.shared.registerForRemoteNotifications() +#endif + } + } + + healthStore = HKHealthStore() + + let carbHealthStore = HealthKitSampleStore( + healthStore: healthStore, + observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitCarbSamplesFromOtherApps, // At some point we should let the user decide which apps they would like to import from. + type: HealthKitSampleStore.carbType, + observationStart: Date().addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) + ) + + let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes + + temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsManager) + temporaryPresetsManager.overrideHistory.delegate = self + + temporaryPresetsManager.addTemporaryPresetObserver(alertManager) + temporaryPresetsManager.addTemporaryPresetObserver(analyticsServicesManager) + + self.carbStore = CarbStore( + healthKitSampleStore: carbHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration, + defaultAbsorptionTimes: absorptionTimes, + carbAbsorptionModel: FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + ) + + let insulinHealthStore = HealthKitSampleStore( + healthStore: healthStore, + observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitDoseSamplesFromOtherApps, + type: HealthKitSampleStore.insulinQuantityType, + observationStart: Date().addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) + ) + + let insulinModelProvider: InsulinModelProvider + + if FeatureFlags.adultChildInsulinModelSelectionEnabled { + insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: settingsManager.settings.defaultRapidActingModel?.presetForRapidActingInsulin) + } else { + insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) + } + + self.doseStore = DoseStore( + healthKitSampleStore: insulinHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration, + insulinModelProvider: insulinModelProvider, + longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, + basalProfile: settingsManager.settings.basalRateSchedule, + lastPumpEventsReconciliation: nil // PumpManager is nil at this point. Will update this via addPumpEvents below + ) - settingsManager = SettingsManager(cacheStore: cacheStore, - expireAfter: localCacheDuration, - alertMuter: alertManager.alertMuter) + let glucoseHealthStore = HealthKitSampleStore( + healthStore: healthStore, + observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitGlucoseSamplesFromOtherApps, + type: HealthKitSampleStore.glucoseType, + observationStart: Date().addingTimeInterval(-.hours(24)) + ) + + self.glucoseStore = GlucoseStore( + healthKitSampleStore: glucoseHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + + dosingDecisionStore = DosingDecisionStore(store: cacheStore, expireAfter: localCacheDuration) + + + NotificationCenter.default.addObserver(forName: .HealthStorePreferredGlucoseUnitDidChange, object: healthStore, queue: nil) { [weak self] _ in + guard let self else { + return + } + + Task { @MainActor in + if let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) { + self.displayGlucosePreference.unitDidChange(to: unit) + self.notifyObserversOfDisplayGlucoseUnitChange(to: unit) + } + } + } + + let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + + loopDataManager = LoopDataManager( + lastLoopCompleted: ExtensionDataManager.context?.lastLoopCompleted, + temporaryPresetsManager: temporaryPresetsManager, + settingsProvider: settingsManager, + doseStore: doseStore, + glucoseStore: glucoseStore, + carbStore: carbStore, + dosingDecisionStore: dosingDecisionStore, + automaticDosingStatus: automaticDosingStatus, + trustedTimeOffset: { self.trustedTimeChecker.detectedSystemTimeOffset }, + analyticsServicesManager: analyticsServicesManager, + carbAbsorptionModel: carbModel + ) + + cacheStore.delegate = loopDataManager + + crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) + + Task { @MainActor in + alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) + } + + cgmEventStore = CgmEventStore(cacheStore: cacheStore, cacheLength: localCacheDuration) + + + remoteDataServicesManager = RemoteDataServicesManager( + alertStore: alertManager.alertStore, + carbStore: carbStore, + doseStore: doseStore, + dosingDecisionStore: dosingDecisionStore, + glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, + settingsStore: settingsManager.settingsStore, + overrideHistory: temporaryPresetsManager.overrideHistory, + insulinDeliveryStore: doseStore.insulinDeliveryStore + ) + + settingsManager.remoteDataServicesManager = remoteDataServicesManager + + servicesManager = ServicesManager( + pluginManager: pluginManager, + alertManager: alertManager, + analyticsServicesManager: analyticsServicesManager, + loggingServicesManager: loggingServicesManager, + remoteDataServicesManager: remoteDataServicesManager, + settingsManager: settingsManager, + servicesManagerDelegate: loopDataManager, + servicesManagerDosingDelegate: self + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) deviceDataManager = DeviceDataManager(pluginManager: pluginManager, alertManager: alertManager, settingsManager: settingsManager, - loggingServicesManager: loggingServicesManager, + healthStore: healthStore, + carbStore: carbStore, + doseStore: doseStore, + glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, + uploadEventListener: remoteDataServicesManager, + crashRecoveryManager: crashRecoveryManager, + loopControl: loopDataManager, analyticsServicesManager: analyticsServicesManager, + activeServicesProvider: servicesManager, + activeStatefulPluginsProvider: statefulPluginManager, bluetoothProvider: bluetoothStateManager, alertPresenter: self, automaticDosingStatus: automaticDosingStatus, cacheStore: cacheStore, localCacheDuration: localCacheDuration, - overrideHistory: overrideHistory, - trustedTimeChecker: trustedTimeChecker + displayGlucosePreference: displayGlucosePreference, + displayGlucoseUnitBroadcaster: self ) - settingsManager.deviceStatusProvider = deviceDataManager - settingsManager.displayGlucosePreference = deviceDataManager.displayGlucosePreference + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager - overrideHistory.delegate = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, + directory: FileManager.default.exportsDirectoryURL, + historicalDuration: localCacheDuration) + + criticalEventLogExportManager.registerBackgroundTasks() + + + statusExtensionManager = ExtensionDataManager( + deviceDataManager: deviceDataManager, + loopDataManager: loopDataManager, + automaticDosingStatus: automaticDosingStatus, + settingsManager: settingsManager, + temporaryPresetsManager: temporaryPresetsManager + ) + + watchManager = WatchDataManager( + deviceManager: deviceDataManager, + settingsManager: settingsManager, + loopDataManager: loopDataManager, + carbStore: carbStore, + glucoseStore: glucoseStore, + analyticsServicesManager: analyticsServicesManager, + temporaryPresetsManager: temporaryPresetsManager, + healthStore: healthStore + ) + + self.mealDetectionManager = MealDetectionManager( + algorithmStateProvider: loopDataManager, + settingsProvider: temporaryPresetsManager, + bolusStateProvider: deviceDataManager + ) + + loopDataManager.deliveryDelegate = deviceDataManager + + deviceDataManager.instantiateDeviceManagers() + + settingsManager.deviceStatusProvider = deviceDataManager + settingsManager.displayGlucosePreference = displayGlucosePreference SharedLogging.instance = loggingServicesManager - scheduleBackgroundTasks() + criticalEventLogExportManager.scheduleCriticalEventLogHistoricalExportBackgroundTask() + supportManager = SupportManager(pluginManager: pluginManager, deviceSupportDelegate: deviceDataManager, - servicesManager: deviceDataManager.servicesManager, + servicesManager: servicesManager, alertIssuer: alertManager) setWhitelistedDevices() onboardingManager = OnboardingManager(pluginManager: pluginManager, bluetoothProvider: bluetoothStateManager, - deviceDataManager: deviceDataManager, - statefulPluginManager: deviceDataManager.statefulPluginManager, - servicesManager: deviceDataManager.servicesManager, - loopDataManager: deviceDataManager.loopManager, + deviceDataManager: deviceDataManager, + settingsManager: settingsManager, + statefulPluginManager: statefulPluginManager, + servicesManager: servicesManager, + loopDataManager: loopDataManager, supportManager: supportManager, windowProvider: windowProvider, userDefaults: UserDefaults.appGroup!) @@ -252,23 +463,46 @@ class LoopAppManager: NSObject { } analyticsServicesManager.identify("Dosing Strategy", value: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue) - let serviceNames = deviceDataManager.servicesManager.activeServices.map { $0.pluginIdentifier } + let serviceNames = servicesManager.activeServices.map { $0.pluginIdentifier } analyticsServicesManager.identify("Services", array: serviceNames) if FeatureFlags.scenariosEnabled { - testingScenariosManager = LocalTestingScenariosManager(deviceManager: deviceDataManager, supportManager: supportManager) + testingScenariosManager = TestingScenariosManager( + deviceManager: deviceDataManager, + supportManager: supportManager, + pluginManager: pluginManager, + carbStore: carbStore, + settingsManager: settingsManager + ) } analyticsServicesManager.application(didFinishLaunchingWithOptions: launchOptions) - automaticDosingStatus.$isAutomaticDosingAllowed - .combineLatest(deviceDataManager.loopManager.$dosingEnabled) + .combineLatest(settingsManager.$dosingEnabled) .map { $0 && $1 } .assign(to: \.automaticDosingStatus.automaticDosingEnabled, on: self) .store(in: &cancellables) + state = state.next + + await loopDataManager.updateDisplayState() + + NotificationCenter.default.publisher(for: .LoopCycleCompleted) + .sink { [weak self] _ in + Task { + await self?.loopCycleDidComplete() + } + } + .store(in: &cancellables) + } + + private func loopCycleDidComplete() async { + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self.widgetLog.default("Refreshing widget. Reason: Loop completed") + WidgetCenter.shared.reloadAllTimelines() + } } private func launchOnboarding() { @@ -278,12 +512,14 @@ class LoopAppManager: NSObject { onboardingManager.launch { DispatchQueue.main.async { self.state = self.state.next - self.resumeLaunch() + Task { + await self.resumeLaunch() + } } } } - private func launchHomeScreen() { + private func launchHomeScreen() async { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchHomeScreen) @@ -296,6 +532,16 @@ class LoopAppManager: NSObject { statusTableViewController.onboardingManager = onboardingManager statusTableViewController.supportManager = supportManager statusTableViewController.testingScenariosManager = testingScenariosManager + statusTableViewController.settingsManager = settingsManager + statusTableViewController.temporaryPresetsManager = temporaryPresetsManager + statusTableViewController.loopManager = loopDataManager + statusTableViewController.diagnosticReportGenerator = self + statusTableViewController.simulatedData = self + statusTableViewController.analyticsServicesManager = analyticsServicesManager + statusTableViewController.servicesManager = servicesManager + statusTableViewController.carbStore = carbStore + statusTableViewController.doseStore = doseStore + statusTableViewController.criticalEventLogExportManager = criticalEventLogExportManager bluetoothStateManager.addBluetoothObserver(statusTableViewController) var rootNavigationController = rootViewController as? RootNavigationController @@ -306,7 +552,7 @@ class LoopAppManager: NSObject { rootNavigationController?.setViewControllers([statusTableViewController], animated: true) - deviceDataManager.refreshDeviceData() + await deviceDataManager.refreshDeviceData() handleRemoteNotificationFromLaunchOptions() @@ -325,7 +571,7 @@ class LoopAppManager: NSObject { } settingsManager?.didBecomeActive() deviceDataManager?.didBecomeActive() - alertManager.inferDeliveredLoopNotRunningNotifications() + alertManager?.inferDeliveredLoopNotRunningNotifications() widgetLog.default("Refreshing widget. Reason: App didBecomeActive") WidgetCenter.shared.reloadAllTimelines() @@ -333,7 +579,7 @@ class LoopAppManager: NSObject { // MARK: - Remote Notification - func remoteNotificationRegistrationDidFinish(_ result: Result) { + func remoteNotificationRegistrationDidFinish(_ result: Swift.Result) { if case .success(let token) = result { log.default("DeviceToken: %{public}@", token.hexadecimalString) } @@ -349,7 +595,7 @@ class LoopAppManager: NSObject { guard let notification = notification else { return false } - deviceDataManager?.servicesManager.handleRemoteNotification(notification) + servicesManager.handleRemoteNotification(notification) return true } @@ -395,20 +641,6 @@ class LoopAppManager: NSObject { } } - // MARK: - Background Tasks - - private func registerBackgroundTasks() { - if DeviceDataManager.registerCriticalEventLogHistoricalExportBackgroundTask({ self.deviceDataManager?.handleCriticalEventLogHistoricalExportBackgroundTask($0) }) { - log.debug("Critical event log export background task registered") - } else { - log.error("Critical event log export background task not registered") - } - } - - private func scheduleBackgroundTasks() { - deviceDataManager?.scheduleCriticalEventLogHistoricalExportBackgroundTask() - } - // MARK: - Private private func setWhitelistedDevices() { @@ -509,6 +741,33 @@ extension LoopAppManager: AlertPresenter { } } +protocol DisplayGlucoseUnitBroadcaster: AnyObject { + func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) + func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) +} + +extension LoopAppManager: DisplayGlucoseUnitBroadcaster { + func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { + let queue = DispatchQueue.main + displayGlucoseUnitObservers.insert(observer, queue: queue) + queue.async { + observer.unitDidChange(to: self.displayGlucosePreference.unit) + } + } + + func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { + displayGlucoseUnitObservers.removeElement(observer) + displayGlucoseUnitObservers.cleanupDeallocatedElements() + } + + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { + self.displayGlucoseUnitObservers.forEach { + $0.unitDidChange(to: displayGlucoseUnit) + } + } +} + // MARK: - DeviceOrientationController extension LoopAppManager: DeviceOrientationController { @@ -548,14 +807,12 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { let activationType = BolusActivationType(rawValue: activationTypeRawValue), startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) { - deviceDataManager?.analyticsServicesManager.didRetryBolus() + analyticsServicesManager.didRetryBolus() - deviceDataManager?.enactBolus(units: units, activationType: activationType) { (_) in - DispatchQueue.main.async { - completionHandler() - } + Task { @MainActor in + try? await deviceDataManager?.enactBolus(units: units, activationType: activationType) + completionHandler() } - return } case NotificationManager.Action.acknowledgeAlert.rawValue: let userInfo = response.notification.request.content.userInfo @@ -600,8 +857,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { extension LoopAppManager: TemporaryScheduleOverrideHistoryDelegate { func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { UserDefaults.appGroup?.overrideHistory = history - - deviceDataManager.remoteDataServicesManager.triggerUpload(for: .overrides) + remoteDataServicesManager.triggerUpload(for: .overrides) } } @@ -643,3 +899,172 @@ extension LoopAppManager: ResetLoopManagerDelegate { alertManager.presentCouldNotResetLoopAlert(error: error) } } + +// MARK: - ServicesManagerDosingDelegate + +extension LoopAppManager: ServicesManagerDosingDelegate { + func deliverBolus(amountInUnits: Double) async throws { + try await deviceDataManager.enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) + } +} + +protocol DiagnosticReportGenerator: AnyObject { + func generateDiagnosticReport() async -> String +} + + +extension LoopAppManager: DiagnosticReportGenerator { + /// Generates a diagnostic report about the current state + /// + /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue. + /// + /// - parameter completion: A closure called once the report has been generated. The closure takes a single argument of the report string. + func generateDiagnosticReport() async -> String { + + let entries: [String] = [ + "## Build Details", + "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", + "* profileExpiration: \(BuildDetails.default.profileExpirationString)", + "* gitRevision: \(BuildDetails.default.gitRevision ?? "N/A")", + "* gitBranch: \(BuildDetails.default.gitBranch ?? "N/A")", + "* workspaceGitRevision: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", + "* workspaceGitBranch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", + "* sourceRoot: \(BuildDetails.default.sourceRoot ?? "N/A")", + "* buildDateString: \(BuildDetails.default.buildDateString ?? "N/A")", + "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", + "", + "## FeatureFlags", + "\(FeatureFlags)", + "", + await alertManager.generateDiagnosticReport(), + await deviceDataManager.generateDiagnosticReport(), + "", + String(reflecting: self.watchManager), + "", + String(reflecting: self.statusExtensionManager), + "", + await loopDataManager.generateDiagnosticReport(), + "", + await self.glucoseStore.generateDiagnosticReport(), + "", + await self.carbStore.generateDiagnosticReport(), + "", + await self.carbStore.generateDiagnosticReport(), + "", + await self.mealDetectionManager.generateDiagnosticReport(), + "", + await UNUserNotificationCenter.current().generateDiagnosticReport(), + "", + UIDevice.current.generateDiagnosticReport(), + "" + ] + return entries.joined(separator: "\n") + } +} + + +// MARK: SimulatedData + +protocol SimulatedData { + func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) + func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) +} + +extension LoopAppManager: SimulatedData { + func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + settingsManager.settingsStore.generateSimulatedHistoricalSettingsObjects() { error in + guard error == nil else { + completion(error) + return + } + Task { @MainActor in + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + self.glucoseStore.generateSimulatedHistoricalGlucoseObjects() { error in + guard error == nil else { + completion(error) + return + } + self.carbStore.generateSimulatedHistoricalCarbObjects() { error in + guard error == nil else { + completion(error) + return + } + self.dosingDecisionStore.generateSimulatedHistoricalDosingDecisionObjects() { error in + guard error == nil else { + completion(error) + return + } + self.doseStore.generateSimulatedHistoricalPumpEvents() { error in + guard error == nil else { + completion(error) + return + } + self.deviceDataManager.deviceLog.generateSimulatedHistoricalDeviceLogEntries() { error in + guard error == nil else { + completion(error) + return + } + self.alertManager.alertStore.generateSimulatedHistoricalStoredAlerts(completion: completion) + } + } + } + } + } + } + } + } + + func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + alertManager.alertStore.purgeHistoricalStoredAlerts() { error in + guard error == nil else { + completion(error) + return + } + self.deviceDataManager.deviceLog.purgeHistoricalDeviceLogEntries() { error in + guard error == nil else { + completion(error) + return + } + Task { @MainActor in + self.doseStore.purgeHistoricalPumpEvents() { error in + guard error == nil else { + completion(error) + return + } + self.dosingDecisionStore.purgeHistoricalDosingDecisionObjects() { error in + guard error == nil else { + completion(error) + return + } + self.carbStore.purgeHistoricalCarbObjects() { error in + guard error == nil else { + completion(error) + return + } + self.glucoseStore.purgeHistoricalGlucoseObjects() { error in + guard error == nil else { + completion(error) + return + } + self.settingsManager.purgeHistoricalSettingsObjects(completion: completion) + } + } + } + } + } + } + } + } +} + diff --git a/Loop/Managers/LoopDataManager+CarbAbsorption.swift b/Loop/Managers/LoopDataManager+CarbAbsorption.swift new file mode 100644 index 0000000000..2d3053f08d --- /dev/null +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -0,0 +1,118 @@ +// +// LoopDataManager+CarbAbsorption.swift +// Loop +// +// Created by Pete Schwamb on 11/6/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit + +struct CarbAbsorptionReview { + var carbEntries: [StoredCarbEntry] + var carbStatuses: [CarbStatus] + var effectsVelocities: [GlucoseEffectVelocity] + var carbEffects: [GlucoseEffect] +} + +extension LoopDataManager { + + func dynamicCarbsOnBoard(from start: Date? = nil, to end: Date? = nil) async -> [CarbValue] { + if let effects = displayState.output?.effects { + return effects.carbStatus.dynamicCarbsOnBoard(from: start, to: end, absorptionModel: carbAbsorptionModel.model) + } else { + return [] + } + } + + func fetchCarbAbsorptionReview(start: Date, end: Date) async throws -> CarbAbsorptionReview { + // Need to get insulin data from any active doses that might affect this time range + var dosesStart = start.addingTimeInterval(-InsulinMath.defaultInsulinActivityDuration) + let doses = try await doseStore.getDoses( + start: dosesStart, + end: end + ) + + dosesStart = doses.map { $0.startDate }.min() ?? dosesStart + + let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: end) + + let carbEntries = try await carbStore.getCarbEntries(start: start, end: end) + + let carbRatio = try await settingsProvider.getCarbRatioHistory(startDate: start, endDate: end) + + let glucose = try await glucoseStore.getGlucoseSamples(start: start, end: end) + + let sensitivityStart = min(start, dosesStart) + + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: end) + + var overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) + + guard !sensitivity.isEmpty else { + throw LoopError.configurationError(.insulinSensitivitySchedule) + } + + let sensitivityWithOverrides = overrides.apply(over: sensitivity) { (quantity, override) in + let value = quantity.doubleValue(for: .milligramsPerDeciliter) + return HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: value / override.settings.effectiveInsulinNeedsScaleFactor + ) + } + + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) + } + let basalWithOverrides = overrides.apply(over: basal) { (value, override) in + value * override.settings.effectiveInsulinNeedsScaleFactor + } + + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) + } + let carbRatioWithOverrides = overrides.apply(over: carbRatio) { (value, override) in + value * override.settings.effectiveInsulinNeedsScaleFactor + } + + let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + + // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal + let annotatedDoses = doses.annotated(with: basal) + + let insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) + + let insulinEffects = annotatedDoses.glucoseEffects( + insulinModelProvider: insulinModelProvider, + insulinSensitivityHistory: sensitivity, + from: start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval).dateFlooredToTimeInterval(GlucoseMath.defaultDelta), + to: nil) + + // ICE + let insulinCounteractionEffects = glucose.counteractionEffects(to: insulinEffects) + + // Carb Effects + let carbStatus = carbEntries.map( + to: insulinCounteractionEffects, + carbRatio: carbRatio, + insulinSensitivity: sensitivity + ) + + let carbEffects = carbStatus.dynamicGlucoseEffects( + from: end, + to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), + carbRatios: carbRatio, + insulinSensitivities: sensitivity, + absorptionModel: carbModel.model + ) + + return CarbAbsorptionReview( + carbEntries: carbEntries, + carbStatuses: carbStatus, + effectsVelocities: insulinCounteractionEffects, + carbEffects: carbEffects + ) + } +} diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 142641066b..697007d76d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -10,150 +10,158 @@ import Foundation import Combine import HealthKit import LoopKit +import LoopKitUI import LoopCore import WidgetKit -protocol PresetActivationObserver: AnyObject { - func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) - func presetDeactivated(context: TemporaryScheduleOverride.Context) +struct AlgorithmDisplayState { + var input: LoopAlgorithmInput? + var output: LoopAlgorithmOutput? + + var activeInsulin: InsulinValue? { + guard let input, let value = output?.activeInsulin else { + return nil + } + return InsulinValue(startDate: input.predictionStart, value: value) + } + + var activeCarbs: CarbValue? { + guard let input, let value = output?.activeCarbs else { + return nil + } + return CarbValue(startDate: input.predictionStart, value: value) + } + + var asTuple: (algoInput: LoopAlgorithmInput?, algoOutput: LoopAlgorithmOutput?) { + return (algoInput: input, algoOutput: output) + } +} + +protocol DeliveryDelegate: AnyObject { + var isSuspended: Bool { get } + var pumpInsulinType: InsulinType? { get } + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { get } + var isPumpConfigured: Bool { get } + + func enact(_ recommendation: AutomaticDoseRecommendation) async throws + func enactBolus(units: Double, activationType: BolusActivationType) async throws + func roundBasalRate(unitsPerHour: Double) -> Double + func roundBolusVolume(units: Double) -> Double +} + +protocol DosingManagerDelegate { + func didMakeDosingDecision(_ decision: StoredDosingDecision) +} + +enum LoopUpdateContext: Int { + case insulin + case carbs + case glucose + case preferences + case forecast } +@MainActor final class LoopDataManager { - enum LoopUpdateContext: Int { - case insulin - case carbs - case glucose - case preferences - case loopFinished - } + nonisolated static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" - let loopLock = UnfairLock() + // Represents the current state of the loop algorithm for display + var displayState = AlgorithmDisplayState() - static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" + // Display state convenience accessors + var predictedGlucose: [PredictedGlucoseValue]? { + displayState.output?.predictedGlucose + } - private let carbStore: CarbStoreProtocol - - private let mealDetectionManager: MealDetectionManager + var tempBasalRecommendation: TempBasalRecommendation? { + displayState.output?.recommendation?.automatic?.basalAdjustment + } + + var automaticBolusRecommendation: Double? { + displayState.output?.recommendation?.automatic?.bolusUnits + } - private let doseStore: DoseStoreProtocol + var automaticRecommendation: AutomaticDoseRecommendation? { + displayState.output?.recommendation?.automatic + } - let dosingDecisionStore: DosingDecisionStoreProtocol + private(set) var lastLoopCompleted: Date? - private let glucoseStore: GlucoseStoreProtocol + var deliveryDelegate: DeliveryDelegate? - let latestStoredSettingsProvider: LatestStoredSettingsProvider + let analyticsServicesManager: AnalyticsServicesManager? + let carbStore: CarbStoreProtocol + let doseStore: DoseStoreProtocol + let temporaryPresetsManager: TemporaryPresetsManager + let settingsProvider: SettingsProvider + let dosingDecisionStore: DosingDecisionStoreProtocol + let glucoseStore: GlucoseStoreProtocol - weak var delegate: LoopDataManagerDelegate? + let logger = DiagnosticLog(category: "LoopDataManager") - private let logger = DiagnosticLog(category: "LoopDataManager") private let widgetLog = DiagnosticLog(category: "LoopWidgets") - private let analyticsServicesManager: AnalyticsServicesManager - - private let trustedTimeOffset: () -> TimeInterval + private let trustedTimeOffset: () async -> TimeInterval private let now: () -> Date private let automaticDosingStatus: AutomaticDosingStatus - lazy private var cancellables = Set() - // References to registered notification center observers private var notificationObservers: [Any] = [] - - private var overrideIntentObserver: NSKeyValueObservation? = nil - var presetActivationObservers: [PresetActivationObserver] = [] - - private var timeBasedDoseApplicationFactor: Double = 1.0 + var activeInsulin: InsulinValue? { + displayState.activeInsulin + } + var activeCarbs: CarbValue? { + displayState.activeCarbs + } - private var insulinOnBoard: InsulinValue? + var latestGlucose: GlucoseSampleValue? { + displayState.input?.glucoseHistory.last + } - deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) - } + var lastReservoirValue: ReservoirValue? { + doseStore.lastReservoirValue } + var carbAbsorptionModel: CarbAbsorptionModel + + private var lastManualBolusRecommendation: ManualBolusRecommendation? + + var usePositiveMomentumAndRCForManualBoluses: Bool + + lazy private var cancellables = Set() + init( lastLoopCompleted: Date?, - basalDeliveryState: PumpManagerStatus.BasalDeliveryState?, - settings: LoopSettings, - overrideHistory: TemporaryScheduleOverrideHistory, - analyticsServicesManager: AnalyticsServicesManager, - localCacheDuration: TimeInterval = .days(1), + temporaryPresetsManager: TemporaryPresetsManager, + settingsProvider: SettingsProvider, doseStore: DoseStoreProtocol, glucoseStore: GlucoseStoreProtocol, carbStore: CarbStoreProtocol, dosingDecisionStore: DosingDecisionStoreProtocol, - latestStoredSettingsProvider: LatestStoredSettingsProvider, now: @escaping () -> Date = { Date() }, - pumpInsulinType: InsulinType?, automaticDosingStatus: AutomaticDosingStatus, - trustedTimeOffset: @escaping () -> TimeInterval + trustedTimeOffset: @escaping () async -> TimeInterval, + analyticsServicesManager: AnalyticsServicesManager?, + carbAbsorptionModel: CarbAbsorptionModel, + usePositiveMomentumAndRCForManualBoluses: Bool = true ) { - self.analyticsServicesManager = analyticsServicesManager - self.lockedLastLoopCompleted = Locked(lastLoopCompleted) - self.lockedBasalDeliveryState = Locked(basalDeliveryState) - self.lockedSettings = Locked(settings) - self.dosingEnabled = settings.dosingEnabled - - self.overrideHistory = overrideHistory - let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - - self.overrideHistory.relevantTimeWindow = absorptionTimes.slow * 2 - - self.carbStore = carbStore + self.lastLoopCompleted = lastLoopCompleted + self.temporaryPresetsManager = temporaryPresetsManager + self.settingsProvider = settingsProvider self.doseStore = doseStore self.glucoseStore = glucoseStore - + self.carbStore = carbStore self.dosingDecisionStore = dosingDecisionStore - self.now = now - - self.latestStoredSettingsProvider = latestStoredSettingsProvider - self.mealDetectionManager = MealDetectionManager( - carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, - insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, - maximumBolus: settings.maximumBolus - ) - - self.lockedPumpInsulinType = Locked(pumpInsulinType) - self.automaticDosingStatus = automaticDosingStatus - self.trustedTimeOffset = trustedTimeOffset - - overrideIntentObserver = UserDefaults.appGroup?.observe(\.intentExtensionOverrideToSet, options: [.new], changeHandler: {[weak self] (defaults, change) in - guard let name = change.newValue??.lowercased(), let appGroup = UserDefaults.appGroup else { - return - } - - guard let preset = self?.settings.overridePresets.first(where: {$0.name.lowercased() == name}) else { - self?.logger.error("Override Intent: Unable to find override named '%s'", String(describing: name)) - return - } - - self?.logger.default("Override Intent: setting override named '%s'", String(describing: name)) - self?.mutateSettings { settings in - if let oldPreset = settings.scheduleOverride { - if let observers = self?.presetActivationObservers { - for observer in observers { - observer.presetDeactivated(context: oldPreset.context) - } - } - } - settings.scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) - if let observers = self?.presetActivationObservers { - for observer in observers { - observer.presetActivated(context: .preset(preset), duration: preset.duration) - } - } - } - // Remove the override from UserDefaults so we don't set it multiple times - appGroup.intentExtensionOverrideToSet = nil - }) + self.analyticsServicesManager = analyticsServicesManager + self.carbAbsorptionModel = carbAbsorptionModel + self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses // Required for device settings in stored dosing decisions UIDevice.current.isBatteryMonitoringEnabled = true @@ -165,13 +173,9 @@ final class LoopDataManager { object: self.carbStore, queue: nil ) { (note) -> Void in - self.dataAccessQueue.async { + Task { @MainActor in self.logger.default("Received notification of carb entries changing") - - self.carbEffect = nil - self.carbsOnBoard = nil - self.recentCarbEntries = nil - self.remoteRecommendationNeedsUpdating = true + await self.updateDisplayState() self.notify(forChange: .carbs) } }, @@ -180,12 +184,9 @@ final class LoopDataManager { object: self.glucoseStore, queue: nil ) { (note) in - self.dataAccessQueue.async { + Task { @MainActor in self.logger.default("Received notification of glucose samples changing") - - self.glucoseMomentumEffect = nil - self.remoteRecommendationNeedsUpdating = true - + await self.updateDisplayState() self.notify(forChange: .glucose) } }, @@ -194,12 +195,9 @@ final class LoopDataManager { object: self.doseStore, queue: OperationQueue.main ) { (note) in - self.dataAccessQueue.async { + Task { @MainActor in self.logger.default("Received notification of dosing changing") - - self.clearCachedInsulinEffects() - self.remoteRecommendationNeedsUpdating = true - + await self.updateDisplayState() self.notify(forChange: .insulin) } } @@ -208,318 +206,405 @@ final class LoopDataManager { // Turn off preMeal when going into closed loop off mode // Cancel any active temp basal when going into closed loop off mode // The dispatch is necessary in case this is coming from a didSet already on the settings struct. - self.automaticDosingStatus.$automaticDosingEnabled + automaticDosingStatus.$automaticDosingEnabled .removeDuplicates() .dropFirst() - .receive(on: DispatchQueue.main) - .sink { if !$0 { - self.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) + .sink { + if !$0 { + self.temporaryPresetsManager.clearOverride(matching: .preMeal) + Task { + await self.cancelActiveTempBasal(for: .automaticDosingDisabled) + } + } else { + Task { + await self.updateDisplayState() + } } - self.cancelActiveTempBasal(for: .automaticDosingDisabled) - } } + } .store(in: &cancellables) + + } - /// Loop-related settings + // MARK: - Calculation state + + fileprivate let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) + - private var lockedSettings: Locked + // MARK: - Background task management + + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid - var settings: LoopSettings { - lockedSettings.value + private func startBackgroundTask() { + endBackgroundTask() + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "PersistenceController save") { + self.endBackgroundTask() + } } - func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - var oldValue: LoopSettings! - let newValue = lockedSettings.mutate { settings in - oldValue = settings - changes(&settings) + private func endBackgroundTask() { + if backgroundTask != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid } + } - guard oldValue != newValue else { - return + func fetchData(for baseTime: Date = Date(), disablingPreMeal: Bool = false) async throws -> LoopAlgorithmInput { + // Need to fetch doses back as far as t - (DIA + DCA) for Dynamic carbs + let dosesInputHistory = CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration + + var dosesStart = baseTime.addingTimeInterval(-dosesInputHistory) + let doses = try await doseStore.getDoses( + start: dosesStart, + end: baseTime + ) + + dosesStart = doses.map { $0.startDate }.min() ?? dosesStart + + let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: baseTime) + + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) } - var invalidateCachedEffects = false + let forecastEndTime = baseTime.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(.minutes(GlucoseMath.defaultDelta)) - dosingEnabled = newValue.dosingEnabled + let carbsStart = baseTime.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) - if newValue.preMealOverride != oldValue.preMealOverride { - // The prediction isn't actually invalid, but a target range change requires recomputing recommended doses - predictedGlucose = nil + // Include future carbs in query, but filter out ones entered after basetime. The filtering is only applicable when running in a retrospective situation. + let carbEntries = try await carbStore.getCarbEntries( + start: carbsStart, + end: forecastEndTime + ).filter { + $0.userCreatedDate ?? $0.startDate < baseTime } - if newValue.scheduleOverride != oldValue.scheduleOverride { - overrideHistory.recordOverride(settings.scheduleOverride) + let carbRatio = try await settingsProvider.getCarbRatioHistory( + startDate: carbsStart, + endDate: forecastEndTime + ) - if let oldPreset = oldValue.scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetDeactivated(context: oldPreset.context) - } + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) + } - } - if let newPreset = newValue.scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetActivated(context: newPreset.context, duration: newPreset.duration) - } - } + let glucose = try await glucoseStore.getGlucoseSamples(start: carbsStart, end: baseTime) + + let sensitivityStart = min(carbsStart, dosesStart) + + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: forecastEndTime) - // Invalidate cached effects affected by the override - invalidateCachedEffects = true - - // Update the affected schedules - mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory - mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory + let target = try await settingsProvider.getTargetRangeHistory(startDate: baseTime, endDate: forecastEndTime) + + let dosingLimits = try await settingsProvider.getDosingLimits(at: baseTime) + + guard let maxBolus = dosingLimits.maxBolus else { + throw LoopError.configurationError(.maximumBolus) } - if newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule { - carbStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule - doseStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule - mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory - invalidateCachedEffects = true - analyticsServicesManager.didChangeInsulinSensitivitySchedule() + guard let maxBasalRate = dosingLimits.maxBasalRate else { + throw LoopError.configurationError(.maximumBasalRatePerHour) } - if newValue.basalRateSchedule != oldValue.basalRateSchedule { - doseStore.basalProfile = newValue.basalRateSchedule + var overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: forecastEndTime) - if let newValue = newValue.basalRateSchedule, let oldValue = oldValue.basalRateSchedule, newValue.items != oldValue.items { - analyticsServicesManager.didChangeBasalRateSchedule() - } + // Bug (https://tidepool.atlassian.net/browse/LOOP-4759) pre-meal is not recorded in override history + // So currently we handle automatic forecast by manually adding it in, and when meal bolusing, we do not do this. + // Eventually, when pre-meal is stored in override history, during meal bolusing we should scan for it and adjust the end time + if !disablingPreMeal, let preMeal = temporaryPresetsManager.preMealOverride { + overrides.append(preMeal) + overrides.sort { $0.startDate < $1.startDate } } - if newValue.carbRatioSchedule != oldValue.carbRatioSchedule { - carbStore.carbRatioSchedule = newValue.carbRatioSchedule - mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory - invalidateCachedEffects = true - analyticsServicesManager.didChangeCarbRatioSchedule() + guard !sensitivity.isEmpty else { + throw LoopError.configurationError(.insulinSensitivitySchedule) } - if newValue.defaultRapidActingModel != oldValue.defaultRapidActingModel { - if FeatureFlags.adultChildInsulinModelSelectionEnabled { - doseStore.insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: newValue.defaultRapidActingModel) - } else { - doseStore.insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) - } - invalidateCachedEffects = true - analyticsServicesManager.didChangeInsulinModel() + let sensitivityWithOverrides = overrides.apply(over: sensitivity) { (quantity, override) in + let value = quantity.doubleValue(for: .milligramsPerDeciliter) + return HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: value / override.settings.effectiveInsulinNeedsScaleFactor + ) } - if newValue.maximumBolus != oldValue.maximumBolus { - mealDetectionManager.maximumBolus = newValue.maximumBolus + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) + } + let basalWithOverrides = overrides.apply(over: basal) { (value, override) in + value * override.settings.effectiveInsulinNeedsScaleFactor } - if invalidateCachedEffects { - dataAccessQueue.async { - // Invalidate cached effects based on this schedule - self.carbEffect = nil - self.carbsOnBoard = nil - self.clearCachedInsulinEffects() - } + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) + } + let carbRatioWithOverrides = overrides.apply(over: carbRatio) { (value, override) in + value * override.settings.effectiveInsulinNeedsScaleFactor } - notify(forChange: .preferences) - analyticsServicesManager.didChangeLoopSettings(from: oldValue, to: newValue) - } + guard !target.isEmpty else { + throw LoopError.configurationError(.glucoseTargetRangeSchedule) + } + let targetWithOverrides = overrides.apply(over: target) { (range, override) in + override.settings.targetRange ?? range + } - @Published private(set) var dosingEnabled: Bool + // Create dosing strategy based on user setting + let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + ? GlucoseBasedApplicationFactorStrategy() + : ConstantApplicationFactorStrategy() - let overrideHistory: TemporaryScheduleOverrideHistory + let correctionRange = target.closestPrior(to: baseTime)?.value - // MARK: - Calculation state + let effectiveBolusApplicationFactor: Double? - fileprivate let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) + if let latestGlucose = glucose.last { + effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( + for: latestGlucose.quantity, + correctionRange: correctionRange! + ) + } else { + effectiveBolusApplicationFactor = nil + } + + return LoopAlgorithmInput( + predictionStart: baseTime, + glucoseHistory: glucose, + doses: doses, + carbEntries: carbEntries, + basal: basalWithOverrides, + sensitivity: sensitivityWithOverrides, + carbRatio: carbRatioWithOverrides, + target: targetWithOverrides, + suspendThreshold: dosingLimits.suspendThreshold, + maxBolus: maxBolus, + maxBasalRate: maxBasalRate, + useIntegralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, + carbAbsorptionModel: carbAbsorptionModel, + recommendationInsulinType: deliveryDelegate?.pumpInsulinType ?? .novolog, + recommendationType: .manualBolus, + automaticBolusApplicationFactor: effectiveBolusApplicationFactor + ) + } - private var carbEffect: [GlucoseEffect]? { - didSet { - predictedGlucose = nil + func loopingReEnabled() async { + await updateDisplayState() + self.notify(forChange: .forecast) + } - // Carb data may be back-dated, so re-calculate the retrospective glucose. - retrospectiveGlucoseDiscrepancies = nil + func updateDisplayState() async { + var newState = AlgorithmDisplayState() + do { + var input = try await fetchData(for: now()) + input.recommendationType = .manualBolus + newState.input = input + newState.output = LoopAlgorithm.run(input: input) + } catch { + let loopError = error as? LoopError ?? .unknownError(error) + logger.error("Error updating Loop state: %{public}@", String(describing: loopError)) } + displayState = newState + await updateRemoteRecommendation() } - private var insulinEffect: [GlucoseEffect]? + /// Cancel the active temp basal if it was automatically issued + func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async { + guard case .tempBasal(let dose) = deliveryDelegate?.basalDeliveryState, (dose.automatic ?? true) else { return } - private var insulinEffectIncludingPendingInsulin: [GlucoseEffect]? { - didSet { - predictedGlucoseIncludingPendingInsulin = nil - } - } + logger.default("Cancelling active temp basal for reason: %{public}@", String(describing: reason)) - private var glucoseMomentumEffect: [GlucoseEffect]? { - didSet { - predictedGlucose = nil - } - } + let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - private var retrospectiveGlucoseEffect: [GlucoseEffect] = [] { - didSet { - predictedGlucose = nil + var dosingDecision = StoredDosingDecision(reason: reason.rawValue) + dosingDecision.settings = StoredDosingDecision.Settings(settingsProvider.settings) + dosingDecision.automaticDoseRecommendation = recommendation + + do { + try await deliveryDelegate?.enact(recommendation) + } catch { + dosingDecision.appendError(error as? LoopError ?? .unknownError(error)) } + + await dosingDecisionStore.storeDosingDecision(dosingDecision) } - /// When combining retrospective glucose discrepancies, extend the window slightly as a buffer. - private let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 + func loop() async { + let loopBaseTime = now() - private var retrospectiveGlucoseDiscrepancies: [GlucoseEffect]? { - didSet { - retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies?.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) - } - } + var dosingDecision = StoredDosingDecision( + date: loopBaseTime, + reason: "loop" + ) - private var retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]? - - private var suspendInsulinDeliveryEffect: [GlucoseEffect] = [] + do { + guard let deliveryDelegate else { + preconditionFailure("Unable to dose without dosing delegate.") + } - fileprivate var predictedGlucose: [PredictedGlucoseValue]? { - didSet { - recommendedAutomaticDose = nil - predictedGlucoseIncludingPendingInsulin = nil - } - } + logger.debug("Running Loop at %{public}@", String(describing: loopBaseTime)) + NotificationCenter.default.post(name: .LoopRunning, object: self) - fileprivate var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? + var input = try await fetchData(for: loopBaseTime) - private var recentCarbEntries: [StoredCarbEntry]? + let startDate = input.predictionStart - fileprivate var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? + let dosingStrategy = settingsProvider.settings.automaticDosingStrategy + input.recommendationType = dosingStrategy.recommendationType - fileprivate var carbsOnBoard: CarbValue? + guard let latestGlucose = input.glucoseHistory.last else { + throw LoopError.missingDataError(.glucose) + } - var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { - get { - return lockedBasalDeliveryState.value - } - set { - self.logger.debug("Updating basalDeliveryState to %{public}@", String(describing: newValue)) - lockedBasalDeliveryState.value = newValue - } - } - private let lockedBasalDeliveryState: Locked - - var pumpInsulinType: InsulinType? { - get { - return lockedPumpInsulinType.value - } - set { - lockedPumpInsulinType.value = newValue - } - } - private let lockedPumpInsulinType: Locked + guard startDate.timeIntervalSince(latestGlucose.startDate) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.glucoseTooOld(date: latestGlucose.startDate) + } - fileprivate var lastRequestedBolus: DoseEntry? + guard latestGlucose.startDate.timeIntervalSince(startDate) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.invalidFutureGlucose(date: latestGlucose.startDate) + } - /// The last date at which a loop completed, from prediction to dose (if dosing is enabled) - var lastLoopCompleted: Date? { - get { - return lockedLastLoopCompleted.value - } - set { - lockedLastLoopCompleted.value = newValue - } - } - private let lockedLastLoopCompleted: Locked + var output = LoopAlgorithm.run(input: input) - fileprivate var lastLoopError: LoopError? + switch output.recommendationResult { + case .success(let recommendation): + // Round delivery amounts to pump supported amounts, + // And determine if a change in dosing should be made. - /// A timeline of average velocity of glucose change counteracting predicted insulin effects - fileprivate var insulinCounteractionEffects: [GlucoseEffectVelocity] = [] { - didSet { - carbEffect = nil - carbsOnBoard = nil - } - } + let algoRecommendation = recommendation.automatic! + logger.default("Algorithm recommendation: %{public}@", String(describing: algoRecommendation)) - // Confined to dataAccessQueue - private var lastIntegralRetrospectiveCorrectionEnabled: Bool? - private var cachedRetrospectiveCorrection: RetrospectiveCorrection? + var recommendationToEnact = algoRecommendation + // Round bolus recommendation based on pump bolus precision + if let bolus = algoRecommendation.bolusUnits, bolus > 0 { + recommendationToEnact.bolusUnits = deliveryDelegate.roundBolusVolume(units: bolus) + } - var retrospectiveCorrection: RetrospectiveCorrection { - let currentIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled - - if lastIntegralRetrospectiveCorrectionEnabled != currentIntegralRetrospectiveCorrectionEnabled || cachedRetrospectiveCorrection == nil { - lastIntegralRetrospectiveCorrectionEnabled = currentIntegralRetrospectiveCorrectionEnabled - if currentIntegralRetrospectiveCorrectionEnabled { - cachedRetrospectiveCorrection = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) - } else { - cachedRetrospectiveCorrection = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + if var basal = algoRecommendation.basalAdjustment { + basal.unitsPerHour = deliveryDelegate.roundBasalRate(unitsPerHour: basal.unitsPerHour) + + let lastTempBasal = input.doses.first { $0.type == .tempBasal && $0.startDate < input.predictionStart && $0.endDate > input.predictionStart } + let scheduledBasalRate = input.basal.closestPrior(to: loopBaseTime)!.value + let activeOverride = temporaryPresetsManager.overrideHistory.activeOverride(at: loopBaseTime) + + let basalAdjustment = basal.ifNecessary( + at: loopBaseTime, + neutralBasalRate: scheduledBasalRate, + lastTempBasal: lastTempBasal, + continuationInterval: .minutes(11), + neutralBasalRateMatchesPump: activeOverride == nil + ) + + recommendationToEnact.basalAdjustment = basalAdjustment + } + output.recommendationResult = .success(.init(automatic: recommendationToEnact)) + + if recommendationToEnact != algoRecommendation { + logger.default("Recommendation changed to: %{public}@", String(describing: recommendationToEnact)) + } + + dosingDecision.updateFrom(input: input, output: output) + + if self.automaticDosingStatus.automaticDosingEnabled { + if deliveryDelegate.isSuspended { + throw LoopError.pumpSuspended + } + + if recommendationToEnact.hasDosingChange { + logger.default("Enacting: %{public}@", String(describing: recommendationToEnact)) + try await deliveryDelegate.enact(recommendationToEnact) + } + + logger.default("loop() completed successfully.") + lastLoopCompleted = Date() + let duration = lastLoopCompleted!.timeIntervalSince(loopBaseTime) + + analyticsServicesManager?.loopDidSucceed(duration) + } else { + self.logger.default("Not adjusting dosing during open loop.") + } + + await dosingDecisionStore.storeDosingDecision(dosingDecision) + NotificationCenter.default.post(name: .LoopCycleCompleted, object: self) + + case .failure(let error): + throw error } + } catch { + logger.error("loop() did error: %{public}@", String(describing: error)) + let loopError = error as? LoopError ?? .unknownError(error) + dosingDecision.appendError(loopError) + await dosingDecisionStore.storeDosingDecision(dosingDecision) + analyticsServicesManager?.loopDidError(error: loopError) } - - return cachedRetrospectiveCorrection! - } - - func clearCachedInsulinEffects() { - insulinEffect = nil - insulinEffectIncludingPendingInsulin = nil - predictedGlucose = nil + logger.default("Loop ended") } - // MARK: - Background task management + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample? = nil, + potentialCarbEntry: NewCarbEntry? = nil, + originalCarbEntry: StoredCarbEntry? = nil + ) async throws -> ManualBolusRecommendation? { - private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + var input = try await self.fetchData(for: now(), disablingPreMeal: potentialCarbEntry != nil) + .addingGlucoseSample(sample: manualGlucoseSample) + .removingCarbEntry(carbEntry: originalCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry) - private func startBackgroundTask() { - endBackgroundTask() - backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "PersistenceController save") { - self.endBackgroundTask() - } - } + input.includePositiveVelocityAndRC = usePositiveMomentumAndRCForManualBoluses + input.recommendationType = .manualBolus - private func endBackgroundTask() { - if backgroundTask != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTask) - backgroundTask = .invalid + let output = LoopAlgorithm.run(input: input) + + switch output.recommendationResult { + case .success(let prediction): + return prediction.manual + case .failure(let error): + throw error } } - private func loopDidComplete(date: Date, dosingDecision: StoredDosingDecision, duration: TimeInterval) { - logger.default("Loop completed successfully.") - lastLoopCompleted = date - analyticsServicesManager.loopDidSucceed(duration) - dosingDecisionStore.storeDosingDecision(dosingDecision) {} - - NotificationCenter.default.post(name: .LoopCompleted, object: self) + var iobValues: [InsulinValue] { + dosesRelativeToBasal.insulinOnBoard() } - private func loopDidError(date: Date, error: LoopError, dosingDecision: StoredDosingDecision, duration: TimeInterval) { - logger.error("Loop did error: %{public}@", String(describing: error)) - lastLoopError = error - analyticsServicesManager.loopDidError(error: error) - var dosingDecisionWithError = dosingDecision - dosingDecisionWithError.appendError(error) - dosingDecisionStore.storeDosingDecision(dosingDecisionWithError) {} + var dosesRelativeToBasal: [DoseEntry] { + displayState.output?.dosesRelativeToBasal ?? [] } - // This is primarily for remote clients displaying a bolus recommendation and forecast - // Should be called after any significant change to forecast input data. - + func updateRemoteRecommendation() async { + if lastManualBolusRecommendation == nil { + lastManualBolusRecommendation = displayState.output?.recommendation?.manual + } - var remoteRecommendationNeedsUpdating: Bool = false + guard lastManualBolusRecommendation != displayState.output?.recommendation?.manual else { + // no change + return + } - func updateRemoteRecommendation() { - dataAccessQueue.async { - if self.remoteRecommendationNeedsUpdating { - var (dosingDecision, updateError) = self.update(for: .updateRemoteRecommendation) + lastManualBolusRecommendation = displayState.output?.recommendation?.manual - if let error = updateError { - self.logger.error("Error updating manual bolus recommendation: %{public}@", String(describing: error)) + if let output = displayState.output { + var dosingDecision = StoredDosingDecision(date: Date(), reason: "updateRemoteRecommendation") + dosingDecision.predictedGlucose = output.predictedGlucose + dosingDecision.insulinOnBoard = displayState.activeInsulin + dosingDecision.carbsOnBoard = displayState.activeCarbs + switch output.recommendationResult { + case .success(let recommendation): + dosingDecision.automaticDoseRecommendation = recommendation.automatic + if let recommendationDate = displayState.input?.predictionStart, let manualRec = recommendation.manual { + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: manualRec, date: recommendationDate) + } + case .failure(let error): + if let loopError = error as? LoopError { + dosingDecision.errors.append(loopError.issue) } else { - do { - if let predictedGlucoseIncludingPendingInsulin = self.predictedGlucoseIncludingPendingInsulin, - let manualBolusRecommendation = try self.recommendManualBolus(forPrediction: predictedGlucoseIncludingPendingInsulin, consideringPotentialCarbEntry: nil) - { - dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: manualBolusRecommendation, date: Date()) - self.logger.debug("Manual bolus rec = %{public}@", String(describing: dosingDecision.manualBolusRecommendation)) - self.dosingDecisionStore.storeDosingDecision(dosingDecision) {} - } - } catch { - self.logger.error("Error updating manual bolus recommendation: %{public}@", String(describing: error)) - } + dosingDecision.errors.append(.init(id: "error", details: ["description": error.localizedDescription])) } - self.remoteRecommendationNeedsUpdating = false } + + dosingDecision.controllerStatus = UIDevice.current.controllerStatus + self.logger.debug("Manual bolus rec = %{public}@", String(describing: dosingDecision.manualBolusRecommendation)) + await self.dosingDecisionStore.storeDosingDecision(dosingDecision) } } } @@ -535,37 +620,6 @@ extension LoopDataManager: PersistenceControllerDelegate { } } -// MARK: - Preferences -extension LoopDataManager { - - /// The basal rate schedule, applying recent overrides relative to the current moment in time. - var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { - return doseStore.basalProfileApplyingOverrideHistory - } - - /// The carb ratio schedule, applying recent overrides relative to the current moment in time. - var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { - return carbStore.carbRatioScheduleApplyingOverrideHistory - } - - /// The insulin sensitivity schedule, applying recent overrides relative to the current moment in time. - var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { - return carbStore.insulinSensitivityScheduleApplyingOverrideHistory - } - - /// Sets a new time zone for a the schedule-based settings - /// - /// - Parameter timeZone: The time zone - func setScheduleTimeZone(_ timeZone: TimeZone) { - self.mutateSettings { settings in - settings.basalRateSchedule?.timeZone = timeZone - settings.carbRatioSchedule?.timeZone = timeZone - settings.insulinSensitivitySchedule?.timeZone = timeZone - settings.glucoseTargetRangeSchedule?.timeZone = timeZone - } - } -} - // MARK: - Intake extension LoopDataManager { @@ -575,1572 +629,143 @@ extension LoopDataManager { /// - samples: The new glucose samples to store /// - completion: A closure called once upon completion /// - result: The stored glucose values - func addGlucoseSamples( - _ samples: [NewGlucoseSample], - completion: ((_ result: Swift.Result<[StoredGlucoseSample], Error>) -> Void)? = nil - ) { - glucoseStore.addGlucoseSamples(samples) { (result) in - self.dataAccessQueue.async { - switch result { - case .success(let samples): - if let endDate = samples.sorted(by: { $0.startDate < $1.startDate }).first?.startDate { - // Prune back any counteraction effects for recomputation - self.insulinCounteractionEffects = self.insulinCounteractionEffects.filter { $0.endDate < endDate } - } - - completion?(.success(samples)) - case .failure(let error): - completion?(.failure(error)) - } - } - } - } - - /// Take actions to address how insulin is delivered when the CGM data is unreliable - /// - /// An active high temp basal (greater than the basal schedule) is cancelled when the CGM data is unreliable. - func receivedUnreliableCGMReading() { - guard case .tempBasal(let tempBasal) = basalDeliveryState, - let scheduledBasalRate = settings.basalRateSchedule?.value(at: now()), - tempBasal.unitsPerHour > scheduledBasalRate else - { - return - } - - // Cancel active high temp basal - cancelActiveTempBasal(for: .unreliableCGMData) + func addGlucose(_ samples: [NewGlucoseSample]) async throws -> [StoredGlucoseSample] { + return try await glucoseStore.addGlucoseSamples(samples) } - private enum CancelActiveTempBasalReason: String { - case automaticDosingDisabled - case unreliableCGMData - case maximumBasalRateChanged - } - - /// Cancel the active temp basal if it was automatically issued - private func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) { - guard case .tempBasal(let dose) = basalDeliveryState, (dose.automatic ?? true) else { return } - - dataAccessQueue.async { - self.cancelActiveTempBasal(for: reason, completion: nil) - } - } - - private func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason, completion: ((Error?) -> Void)?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - recommendedAutomaticDose = (recommendation: recommendation, date: now()) - - var dosingDecision = StoredDosingDecision(reason: reason.rawValue) - dosingDecision.settings = StoredDosingDecision.Settings(latestStoredSettingsProvider.latestSettings) - dosingDecision.controllerStatus = UIDevice.current.controllerStatus - dosingDecision.automaticDoseRecommendation = recommendation - - let error = enactRecommendedAutomaticDose() - - dosingDecision.pumpManagerStatus = delegate?.pumpManagerStatus - dosingDecision.cgmManagerStatus = delegate?.cgmManagerStatus - dosingDecision.lastReservoirValue = StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue) - - if let error = error { - dosingDecision.appendError(error) - } - self.dosingDecisionStore.storeDosingDecision(dosingDecision) {} - - // Didn't actually run a loop, but this is similar to a loop() in that the automatic dosing - // was updated. - self.notify(forChange: .loopFinished) - completion?(error) - } - - - /// Adds and stores carb data, and recommends a bolus if needed - /// - /// - Parameters: - /// - carbEntry: The new carb value - /// - completion: A closure called once upon completion - /// - result: The bolus recommendation - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? = nil, completion: @escaping (_ result: Result) -> Void) { - let addCompletion: (CarbStoreResult) -> Void = { (result) in - self.dataAccessQueue.async { - switch result { - case .success(let storedCarbEntry): - // Remove the active pre-meal target override - self.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } - - self.carbEffect = nil - self.carbsOnBoard = nil - completion(.success(storedCarbEntry)) - case .failure(let error): - completion(.failure(error)) - } - } - } - - if let replacingEntry = replacingEntry { - carbStore.replaceCarbEntry(replacingEntry, withEntry: carbEntry, completion: addCompletion) - } else { - carbStore.addCarbEntry(carbEntry, completion: addCompletion) - } - } - - func deleteCarbEntry(_ oldEntry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) { - carbStore.deleteCarbEntry(oldEntry) { result in - completion(result) - } - } - - - /// Adds a bolus requested of the pump, but not confirmed. - /// - /// - Parameters: - /// - dose: The DoseEntry representing the requested bolus - /// - completion: A closure that is called after state has been updated - func addRequestedBolus(_ dose: DoseEntry, completion: (() -> Void)?) { - dataAccessQueue.async { - self.logger.debug("addRequestedBolus") - self.lastRequestedBolus = dose - self.notify(forChange: .insulin) - - completion?() - } - } - - /// Notifies the manager that the bolus is confirmed, but not fully delivered. - /// - /// - Parameters: - /// - completion: A closure that is called after state has been updated - func bolusConfirmed(completion: (() -> Void)?) { - self.dataAccessQueue.async { - self.logger.debug("bolusConfirmed") - self.lastRequestedBolus = nil - self.recommendedAutomaticDose = nil - self.clearCachedInsulinEffects() - self.notify(forChange: .insulin) - - completion?() - } - } - - /// Notifies the manager that the bolus failed. - /// - /// - Parameters: - /// - error: An error describing why the bolus request failed - /// - completion: A closure that is called after state has been updated - func bolusRequestFailed(_ error: Error, completion: (() -> Void)?) { - self.dataAccessQueue.async { - self.logger.debug("bolusRequestFailed") - self.lastRequestedBolus = nil - self.clearCachedInsulinEffects() - self.notify(forChange: .insulin) - - completion?() - } - } - - /// Logs a new external bolus insulin dose in the DoseStore and HealthKit - /// - /// - Parameters: - /// - startDate: The date the dose was started at. - /// - value: The number of Units in the dose. - /// - insulinModel: The type of insulin model that should be used for the dose. - func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType? = nil) { - let syncIdentifier = Data(UUID().uuidString.utf8).hexadecimalString - let dose = DoseEntry(type: .bolus, startDate: startDate, value: units, unit: .units, syncIdentifier: syncIdentifier, insulinType: insulinType, manuallyEntered: true) - - doseStore.addDoses([dose], from: nil) { (error) in - if error == nil { - self.recommendedAutomaticDose = nil - self.clearCachedInsulinEffects() - self.notify(forChange: .insulin) - } - } - } - - /// Adds and stores a pump reservoir volume - /// - /// - Parameters: - /// - units: The reservoir volume, in units - /// - date: The date of the volume reading - /// - completion: A closure called once upon completion - /// - result: The current state of the reservoir values: - /// - newValue: The new stored value - /// - lastValue: The previous new stored value - /// - areStoredValuesContinuous: Whether the current recent state of the stored reservoir data is considered continuous and reliable for deriving insulin effects after addition of this new value. - func addReservoirValue(_ units: Double, at date: Date, completion: @escaping (_ result: Result<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool)>) -> Void) { - doseStore.addReservoirValue(units, at: date) { (newValue, previousValue, areStoredValuesContinuous, error) in - if let error = error { - completion(.failure(error)) - } else if let newValue = newValue { - self.dataAccessQueue.async { - self.clearCachedInsulinEffects() - - if let newDoseStartDate = previousValue?.startDate { - // Prune back any counteraction effects for recomputation, after the effect delay - self.insulinCounteractionEffects = self.insulinCounteractionEffects.filterDateRange(nil, newDoseStartDate.addingTimeInterval(.minutes(10))) - } - - completion(.success(( - newValue: newValue, - lastValue: previousValue, - areStoredValuesContinuous: areStoredValuesContinuous - ))) - } - } else { - assertionFailure() - } - } - } - - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { - let dosingDecision = StoredDosingDecision(date: date, - reason: bolusDosingDecision.reason.rawValue, - settings: StoredDosingDecision.Settings(latestStoredSettingsProvider.latestSettings), - scheduleOverride: bolusDosingDecision.scheduleOverride, - controllerStatus: UIDevice.current.controllerStatus, - pumpManagerStatus: delegate?.pumpManagerStatus, - cgmManagerStatus: delegate?.cgmManagerStatus, - lastReservoirValue: StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue), - historicalGlucose: bolusDosingDecision.historicalGlucose, - originalCarbEntry: bolusDosingDecision.originalCarbEntry, - carbEntry: bolusDosingDecision.carbEntry, - manualGlucoseSample: bolusDosingDecision.manualGlucoseSample, - carbsOnBoard: bolusDosingDecision.carbsOnBoard, - insulinOnBoard: bolusDosingDecision.insulinOnBoard, - glucoseTargetRangeSchedule: bolusDosingDecision.glucoseTargetRangeSchedule, - predictedGlucose: bolusDosingDecision.predictedGlucose, - manualBolusRecommendation: bolusDosingDecision.manualBolusRecommendation, - manualBolusRequested: bolusDosingDecision.manualBolusRequested) - dosingDecisionStore.storeDosingDecision(dosingDecision) {} - } - - // Actions - - /// Runs the "loop" - /// - /// Executes an analysis of the current data, and recommends an adjustment to the current - /// temporary basal rate. - /// - func loop() { - - if let lastLoopCompleted, Date().timeIntervalSince(lastLoopCompleted) < .minutes(2) { - print("Looping too fast!") - } - - let available = loopLock.withLockIfAvailable { - loopInternal() - return true - } - if available == nil { - print("Loop attempted while already looping!") - } - } - - func loopInternal() { - - dataAccessQueue.async { - - // If time was changed to future time, and a loop completed, then time was fixed, lastLoopCompleted will prevent looping - // until the future loop time passes. Fix that here. - if let lastLoopCompleted = self.lastLoopCompleted, Date() < lastLoopCompleted, self.trustedTimeOffset() == 0 { - self.logger.error("Detected future lastLoopCompleted. Restoring.") - self.lastLoopCompleted = Date() - } - - // Partial application factor assumes 5 minute intervals. If our looping intervals are shorter, then this will be adjusted - self.timeBasedDoseApplicationFactor = 1.0 - if let lastLoopCompleted = self.lastLoopCompleted { - let timeSinceLastLoop = max(0, Date().timeIntervalSince(lastLoopCompleted)) - self.timeBasedDoseApplicationFactor = min(1, timeSinceLastLoop/TimeInterval.minutes(5)) - self.logger.default("Looping with timeBasedDoseApplicationFactor = %{public}@", String(describing: self.timeBasedDoseApplicationFactor)) - } - - self.logger.default("Loop running") - NotificationCenter.default.post(name: .LoopRunning, object: self) - - self.lastLoopError = nil - let startDate = self.now() - - var (dosingDecision, error) = self.update(for: .loop) - - if error == nil, self.automaticDosingStatus.automaticDosingEnabled == true { - error = self.enactRecommendedAutomaticDose() - } else { - self.logger.default("Not adjusting dosing during open loop.") - } - - self.finishLoop(startDate: startDate, dosingDecision: dosingDecision, error: error) - } - } - - private func finishLoop(startDate: Date, dosingDecision: StoredDosingDecision, error: LoopError? = nil) { - let date = now() - let duration = date.timeIntervalSince(startDate) - - if let error = error { - loopDidError(date: date, error: error, dosingDecision: dosingDecision, duration: duration) - } else { - loopDidComplete(date: date, dosingDecision: dosingDecision, duration: duration) - } - - logger.default("Loop ended") - notify(forChange: .loopFinished) - - if FeatureFlags.missedMealNotifications { - let samplesStart = now().addingTimeInterval(-MissedMealSettings.maxRecency) - carbStore.getGlucoseEffects(start: samplesStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in - guard - let self = self, - case .success((_, let carbEffects)) = result - 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 - } - - glucoseStore.getGlucoseSamples(start: samplesStart, end: now()) {[weak self] result in - guard - let self = self, - case .success(let glucoseSamples) = result - else { - if case .failure(let error) = result { - self?.logger.error("Failed to fetch glucose samples to check for missed meal: %{public}@", String(describing: error)) - } - return - } - - self.mealDetectionManager.generateMissedMealNotificationIfNeeded( - glucoseSamples: glucoseSamples, - insulinCounteractionEffects: self.insulinCounteractionEffects, - carbEffects: carbEffects, - pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, - bolusDurationEstimator: { [unowned self] bolusAmount in - return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) - } - ) - } - } - } - - // 5 second delay to allow stores to cache data before it is read by widget - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - self.widgetLog.default("Refreshing widget. Reason: Loop completed") - WidgetCenter.shared.reloadAllTimelines() - } - - updateRemoteRecommendation() - } - - fileprivate enum UpdateReason: String { - case loop - case getLoopState - case updateRemoteRecommendation - } - - fileprivate func update(for reason: UpdateReason) -> (StoredDosingDecision, LoopError?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - var dosingDecision = StoredDosingDecision(reason: reason.rawValue) - let latestSettings = latestStoredSettingsProvider.latestSettings - dosingDecision.settings = StoredDosingDecision.Settings(latestSettings) - dosingDecision.scheduleOverride = latestSettings.scheduleOverride - dosingDecision.controllerStatus = UIDevice.current.controllerStatus - dosingDecision.pumpManagerStatus = delegate?.pumpManagerStatus - if let pumpStatusHighlight = delegate?.pumpStatusHighlight { - dosingDecision.pumpStatusHighlight = StoredDosingDecision.StoredDeviceHighlight( - localizedMessage: pumpStatusHighlight.localizedMessage, - imageName: pumpStatusHighlight.imageName, - state: pumpStatusHighlight.state) - } - dosingDecision.cgmManagerStatus = delegate?.cgmManagerStatus - dosingDecision.lastReservoirValue = StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue) - - let warnings = Locked<[LoopWarning]>([]) - - let updateGroup = DispatchGroup() - - let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now()) - let inputDataRecencyStartDate = Date(timeInterval: -LoopAlgorithm.inputDataRecencyInterval, since: now()) - - // Fetch glucose effects as far back as we want to make retroactive analysis and historical glucose for dosing decision - var historicalGlucose: [HistoricalGlucoseValue]? - var latestGlucoseDate: Date? - updateGroup.enter() - glucoseStore.getGlucoseSamples(start: min(historicalGlucoseStartDate, inputDataRecencyStartDate), end: nil) { (result) in - switch result { - case .failure(let error): - self.logger.error("Failure getting glucose samples: %{public}@", String(describing: error)) - latestGlucoseDate = nil - warnings.append(.fetchDataWarning(.glucoseSamples(error: error))) - case .success(let samples): - historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } - latestGlucoseDate = samples.last?.startDate - } - updateGroup.leave() - } - _ = updateGroup.wait(timeout: .distantFuture) - - guard let lastGlucoseDate = latestGlucoseDate else { - dosingDecision.appendWarnings(warnings.value) - dosingDecision.appendError(.missingDataError(.glucose)) - return (dosingDecision, .missingDataError(.glucose)) - } - - let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) - let nextCounteractionEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate - let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) - - if glucoseMomentumEffect == nil { - updateGroup.enter() - glucoseStore.getRecentMomentumEffect(for: now()) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Failure getting recent momentum effect: %{public}@", String(describing: error)) - self.glucoseMomentumEffect = nil - warnings.append(.fetchDataWarning(.glucoseMomentumEffect(error: error))) - case .success(let effects): - self.glucoseMomentumEffect = effects - } - updateGroup.leave() - } - } - - if insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate { - self.logger.debug("Recomputing insulin effects") - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: now()) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Could not fetch insulin effects: %{public}@", error.localizedDescription) - self.insulinEffect = nil - warnings.append(.fetchDataWarning(.insulinEffect(error: error))) - case .success(let effects): - self.insulinEffect = effects - } - - updateGroup.leave() - } - } - - if insulinEffectIncludingPendingInsulin == nil { - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Could not fetch insulin effects including pending insulin: %{public}@", error.localizedDescription) - self.insulinEffectIncludingPendingInsulin = nil - warnings.append(.fetchDataWarning(.insulinEffectIncludingPendingInsulin(error: error))) - case .success(let effects): - self.insulinEffectIncludingPendingInsulin = effects - } - - updateGroup.leave() - } - } - - _ = updateGroup.wait(timeout: .distantFuture) - - if nextCounteractionEffectDate < lastGlucoseDate, let insulinEffect = insulinEffect { - updateGroup.enter() - self.logger.debug("Fetching counteraction effects after %{public}@", String(describing: nextCounteractionEffectDate)) - glucoseStore.getCounteractionEffects(start: nextCounteractionEffectDate, end: nil, to: insulinEffect) { (result) in - switch result { - case .failure(let error): - self.logger.error("Failure getting counteraction effects: %{public}@", String(describing: error)) - warnings.append(.fetchDataWarning(.insulinCounteractionEffect(error: error))) - case .success(let velocities): - self.insulinCounteractionEffects.append(contentsOf: velocities) - } - self.insulinCounteractionEffects = self.insulinCounteractionEffects.filterDateRange(earliestEffectDate, nil) - - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - } - - if carbEffect == nil { - updateGroup.enter() - carbStore.getGlucoseEffects( - start: retrospectiveStart, end: nil, - effectVelocities: insulinCounteractionEffects - ) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("%{public}@", String(describing: error)) - self.carbEffect = nil - self.recentCarbEntries = nil - warnings.append(.fetchDataWarning(.carbEffect(error: error))) - case .success(let (entries, effects)): - self.carbEffect = effects - self.recentCarbEntries = entries - } - - updateGroup.leave() - } - } - - if carbsOnBoard == nil { - updateGroup.enter() - carbStore.carbsOnBoard(at: now(), effectVelocities: insulinCounteractionEffects) { (result) in - switch result { - case .failure(let error): - switch error { - case .noData: - // when there is no data, carbs on board is set to 0 - self.carbsOnBoard = CarbValue(startDate: Date(), value: 0) - default: - self.carbsOnBoard = nil - warnings.append(.fetchDataWarning(.carbsOnBoard(error: error))) - } - case .success(let value): - self.carbsOnBoard = value - } - updateGroup.leave() - } - } - updateGroup.enter() - doseStore.insulinOnBoard(at: now()) { result in - switch result { - case .failure(let error): - warnings.append(.fetchDataWarning(.insulinOnBoard(error: error))) - case .success(let insulinValue): - self.insulinOnBoard = insulinValue - } - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - - if retrospectiveGlucoseDiscrepancies == nil { - do { - try updateRetrospectiveGlucoseEffect() - } catch let error { - logger.error("%{public}@", String(describing: error)) - warnings.append(.fetchDataWarning(.retrospectiveGlucoseEffect(error: error))) - } - } - - do { - try updateSuspendInsulinDeliveryEffect() - } catch let error { - logger.error("%{public}@", String(describing: error)) - } - - dosingDecision.appendWarnings(warnings.value) - - dosingDecision.date = now() - dosingDecision.historicalGlucose = historicalGlucose - dosingDecision.carbsOnBoard = carbsOnBoard - dosingDecision.insulinOnBoard = self.insulinOnBoard - dosingDecision.glucoseTargetRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() - - // These will be updated by updatePredictedGlucoseAndRecommendedDose, if possible - dosingDecision.predictedGlucose = predictedGlucose - dosingDecision.automaticDoseRecommendation = recommendedAutomaticDose?.recommendation - - // If the glucose prediction hasn't changed, then nothing has changed, so just use pre-existing recommendations - guard predictedGlucose == nil else { - - // If we still have a bolus in progress, then warn (unlikely, but possible if device comms fail) - if lastRequestedBolus != nil, dosingDecision.automaticDoseRecommendation == nil, dosingDecision.manualBolusRecommendation == nil { - dosingDecision.appendWarning(.bolusInProgress) - } - - return (dosingDecision, nil) - } - - return updatePredictedGlucoseAndRecommendedDose(with: dosingDecision) - } - - private func notify(forChange context: LoopUpdateContext) { - NotificationCenter.default.post(name: .LoopDataUpdated, - object: self, - userInfo: [ - type(of: self).LoopUpdateContextKey: context.rawValue - ] - ) - } - - /// Computes amount of insulin from boluses that have been issued and not confirmed, and - /// remaining insulin delivery from temporary basal rate adjustments above scheduled rate - /// that are still in progress. - /// - /// - Returns: The amount of pending insulin, in units - /// - Throws: LoopError.configurationError - private func getPendingInsulin() throws -> Double { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - guard let basalRates = basalRateScheduleApplyingOverrideHistory else { - throw LoopError.configurationError(.basalRateSchedule) - } - - let pendingTempBasalInsulin: Double - let date = now() - - if let basalDeliveryState = basalDeliveryState, case .tempBasal(let lastTempBasal) = basalDeliveryState, lastTempBasal.endDate > date { - let normalBasalRate = basalRates.value(at: date) - let remainingTime = lastTempBasal.endDate.timeIntervalSince(date) - let remainingUnits = (lastTempBasal.unitsPerHour - normalBasalRate) * remainingTime.hours - - pendingTempBasalInsulin = max(0, remainingUnits) - } else { - pendingTempBasalInsulin = 0 - } - - let pendingBolusAmount: Double = lastRequestedBolus?.programmedUnits ?? 0 - - // All outstanding potential insulin delivery - return pendingTempBasalInsulin + pendingBolusAmount - } - - /// - Throws: - /// - LoopError.missingDataError - /// - LoopError.configurationError - /// - LoopError.glucoseTooOld - /// - LoopError.invalidFutureGlucose - /// - LoopError.pumpDataTooOld - fileprivate func predictGlucose( - startingAt startingGlucoseOverride: GlucoseValue? = nil, - using inputs: PredictionInputEffect, - historicalInsulinEffect insulinEffectOverride: [GlucoseEffect]? = nil, - insulinCounteractionEffects insulinCounteractionEffectsOverride: [GlucoseEffectVelocity]? = nil, - historicalCarbEffect carbEffectOverride: [GlucoseEffect]? = nil, - potentialBolus: DoseEntry? = nil, - potentialCarbEntry: NewCarbEntry? = nil, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil, - includingPendingInsulin: Bool = false, - includingPositiveVelocityAndRC: Bool = true - ) throws -> [PredictedGlucoseValue] { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - guard let glucose = startingGlucoseOverride ?? self.glucoseStore.latestGlucose else { - throw LoopError.missingDataError(.glucose) - } - - let pumpStatusDate = doseStore.lastAddedPumpData - let lastGlucoseDate = glucose.startDate - - guard now().timeIntervalSince(lastGlucoseDate) <= LoopAlgorithm.inputDataRecencyInterval else { - throw LoopError.glucoseTooOld(date: glucose.startDate) - } - - guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.futureGlucoseDataInterval else { - throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) - } - - guard now().timeIntervalSince(pumpStatusDate) <= LoopAlgorithm.inputDataRecencyInterval else { - throw LoopError.pumpDataTooOld(date: pumpStatusDate) - } - - var momentum: [GlucoseEffect] = [] - var retrospectiveGlucoseEffect = self.retrospectiveGlucoseEffect - var effects: [[GlucoseEffect]] = [] - - let insulinCounteractionEffects = insulinCounteractionEffectsOverride ?? self.insulinCounteractionEffects - if inputs.contains(.carbs) { - if let potentialCarbEntry = potentialCarbEntry { - let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - - if potentialCarbEntry.startDate > lastGlucoseDate || recentCarbEntries?.isEmpty != false, replacedCarbEntry == nil { - // The potential carb effect is independent and can be summed with the existing effect - if let carbEffect = carbEffectOverride ?? self.carbEffect { - effects.append(carbEffect) - } - - let potentialCarbEffect = try carbStore.glucoseEffects( - of: [potentialCarbEntry], - startingAt: retrospectiveStart, - endingAt: nil, - effectVelocities: insulinCounteractionEffects - ) - - effects.append(potentialCarbEffect) - } else { - var recentEntries = self.recentCarbEntries ?? [] - if let replacedCarbEntry = replacedCarbEntry, let index = recentEntries.firstIndex(of: replacedCarbEntry) { - recentEntries.remove(at: index) - } - - // If the entry is in the past or an entry is replaced, DCA and RC effects must be recomputed - var entries = recentEntries.map { NewCarbEntry(quantity: $0.quantity, startDate: $0.startDate, foodType: nil, absorptionTime: $0.absorptionTime) } - entries.append(potentialCarbEntry) - entries.sort(by: { $0.startDate > $1.startDate }) - - let potentialCarbEffect = try carbStore.glucoseEffects( - of: entries, - startingAt: retrospectiveStart, - endingAt: nil, - effectVelocities: insulinCounteractionEffects - ) - - effects.append(potentialCarbEffect) - - retrospectiveGlucoseEffect = computeRetrospectiveGlucoseEffect(startingAt: glucose, carbEffects: potentialCarbEffect) - } - } else if let carbEffect = carbEffectOverride ?? self.carbEffect { - effects.append(carbEffect) - } - } - - if inputs.contains(.insulin) { - let computationInsulinEffect: [GlucoseEffect]? - if insulinEffectOverride != nil { - computationInsulinEffect = insulinEffectOverride - } else { - computationInsulinEffect = includingPendingInsulin ? self.insulinEffectIncludingPendingInsulin : self.insulinEffect - } - - if let insulinEffect = computationInsulinEffect { - effects.append(insulinEffect) - } - - if let potentialBolus = potentialBolus { - guard let sensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { - throw LoopError.configurationError(.insulinSensitivitySchedule) - } - - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) - let nextEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate - let bolusEffect = [potentialBolus] - .glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: sensitivity) - .filterDateRange(nextEffectDate, nil) - effects.append(bolusEffect) - } - } - - if inputs.contains(.momentum), let momentumEffect = self.glucoseMomentumEffect { - if !includingPositiveVelocityAndRC, let netMomentum = momentumEffect.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { - momentum = [] - } else { - momentum = momentumEffect - } - } - - if inputs.contains(.retrospection) { - if !includingPositiveVelocityAndRC, let netRC = retrospectiveGlucoseEffect.netEffect(), netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { - // positive RC is turned off - } else { - effects.append(retrospectiveGlucoseEffect) - } - } - - // Append effect of suspending insulin delivery when selected by the user on the Predicted Glucose screen (for information purposes only) - if inputs.contains(.suspend) { - effects.append(suspendInsulinDeliveryEffect) - } - - var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) - - // Dosing requires prediction entries at least as long as the insulin model duration. - // If our prediction is shorter than that, then extend it here. - let finalDate = glucose.startDate.addingTimeInterval(doseStore.longestEffectDuration) - if let last = prediction.last, last.startDate < finalDate { - prediction.append(PredictedGlucoseValue(startDate: finalDate, quantity: last.quantity)) - } - - return prediction - } - - fileprivate func predictGlucoseFromManualGlucose( - _ glucose: NewGlucoseSample, - potentialBolus: DoseEntry?, - potentialCarbEntry: NewCarbEntry?, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, - includingPendingInsulin: Bool, - considerPositiveVelocityAndRC: Bool - ) throws -> [PredictedGlucoseValue] { - let retrospectiveStart = glucose.date.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) - let nextEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate - let insulinEffectStartDate = nextEffectDate.addingTimeInterval(.minutes(-5)) - - let updateGroup = DispatchGroup() - let effectCalculationError = Locked(nil) - - var insulinEffect: [GlucoseEffect]? - let basalDosingEnd = includingPendingInsulin ? nil : now() - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: basalDosingEnd) { result in - switch result { - case .failure(let error): - effectCalculationError.mutate { $0 = error } - case .success(let effects): - insulinEffect = effects - } - - updateGroup.leave() - } - - updateGroup.wait() - - if let error = effectCalculationError.value { - throw error - } - - var insulinCounteractionEffects = self.insulinCounteractionEffects - if nextEffectDate < glucose.date, let insulinEffect = insulinEffect { - updateGroup.enter() - glucoseStore.getGlucoseSamples(start: nextEffectDate, end: nil) { result in - switch result { - case .failure(let error): - self.logger.error("Failure getting glucose samples: %{public}@", String(describing: error)) - case .success(let samples): - var samples = samples - let manualSample = StoredGlucoseSample(sample: glucose.quantitySample) - let insertionIndex = samples.partitioningIndex(where: { manualSample.startDate < $0.startDate }) - samples.insert(manualSample, at: insertionIndex) - let velocities = self.glucoseStore.counteractionEffects(for: samples, to: insulinEffect) - insulinCounteractionEffects.append(contentsOf: velocities) - } - insulinCounteractionEffects = insulinCounteractionEffects.filterDateRange(earliestEffectDate, nil) - - updateGroup.leave() - } - - updateGroup.wait() - } - - var carbEffect: [GlucoseEffect]? - updateGroup.enter() - carbStore.getGlucoseEffects( - start: retrospectiveStart, end: nil, - effectVelocities: insulinCounteractionEffects - ) { result in - switch result { - case .failure(let error): - effectCalculationError.mutate { $0 = error } - case .success(let (_, effects)): - carbEffect = effects - } - - updateGroup.leave() - } - - updateGroup.wait() - - if let error = effectCalculationError.value { - throw error - } - - return try predictGlucose( - startingAt: glucose.quantitySample, - using: [.insulin, .carbs], - historicalInsulinEffect: insulinEffect, - insulinCounteractionEffects: insulinCounteractionEffects, - historicalCarbEffect: carbEffect, - potentialBolus: potentialBolus, - potentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: replacedCarbEntry, - includingPendingInsulin: true, - includingPositiveVelocityAndRC: considerPositiveVelocityAndRC - ) - } - - fileprivate func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - guard lastRequestedBolus == nil else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - return nil - } - - let pendingInsulin = try getPendingInsulin() - let shouldIncludePendingInsulin = pendingInsulin > 0 - let prediction = try predictGlucoseFromManualGlucose(glucose, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) - return try recommendManualBolus(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) - } - - /// - Throws: LoopError.missingDataError - fileprivate func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - guard lastRequestedBolus == nil else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - return nil - } - - let pendingInsulin = try getPendingInsulin() - let shouldIncludePendingInsulin = pendingInsulin > 0 - let prediction = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - return try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) - } - - /// - Throws: - /// - LoopError.missingDataError - /// - LoopError.glucoseTooOld - /// - LoopError.invalidFutureGlucose - /// - LoopError.pumpDataTooOld - /// - LoopError.configurationError - fileprivate func recommendBolusValidatingDataRecency(forPrediction predictedGlucose: [Sample], - consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { - guard let glucose = glucoseStore.latestGlucose else { - throw LoopError.missingDataError(.glucose) - } - - let pumpStatusDate = doseStore.lastAddedPumpData - let lastGlucoseDate = glucose.startDate - - guard now().timeIntervalSince(lastGlucoseDate) <= LoopAlgorithm.inputDataRecencyInterval else { - throw LoopError.glucoseTooOld(date: glucose.startDate) - } - - guard lastGlucoseDate.timeIntervalSince(now()) <= LoopAlgorithm.inputDataRecencyInterval else { - throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) - } - - guard now().timeIntervalSince(pumpStatusDate) <= LoopAlgorithm.inputDataRecencyInterval else { - throw LoopError.pumpDataTooOld(date: pumpStatusDate) - } - - guard glucoseMomentumEffect != nil else { - throw LoopError.missingDataError(.momentumEffect) - } - - guard carbEffect != nil else { - throw LoopError.missingDataError(.carbEffect) - } - - guard insulinEffect != nil else { - throw LoopError.missingDataError(.insulinEffect) - } - - return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry) - } - - /// - Throws: LoopError.configurationError - private func recommendManualBolus(forPrediction predictedGlucose: [Sample], - consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { - guard let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { - throw LoopError.configurationError(.glucoseTargetRangeSchedule) - } - guard let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { - throw LoopError.configurationError(.insulinSensitivitySchedule) - } - guard let maxBolus = settings.maximumBolus else { - throw LoopError.configurationError(.maximumBolus) - } - - guard lastRequestedBolus == nil - else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - return nil - } - - let volumeRounder = { (_ units: Double) in - return self.delegate?.roundBolusVolume(units: units) ?? units - } - - let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) - - var recommendation = predictedGlucose.recommendedManualBolus( - to: glucoseTargetRange, - at: now(), - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity, - model: model, - maxBolus: maxBolus - ) - - // Round to pump precision - recommendation.amount = volumeRounder(recommendation.amount) - return recommendation - } - - /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. - /// - /// - Throws: LoopError.missingDataError - private func updateRetrospectiveGlucoseEffect() throws { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - // Get carb effects, otherwise clear effect and throw error - guard let carbEffects = self.carbEffect else { - retrospectiveGlucoseDiscrepancies = nil - retrospectiveGlucoseEffect = [] - throw LoopError.missingDataError(.carbEffect) - } - - // Get most recent glucose, otherwise clear effect and throw error - guard let glucose = self.glucoseStore.latestGlucose else { - retrospectiveGlucoseEffect = [] - throw LoopError.missingDataError(.glucose) - } - - // Get timeline of glucose discrepancies - retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta) - - retrospectiveGlucoseEffect = retrospectiveCorrection.computeEffect( - startingAt: glucose, - retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, - recencyInterval: LoopAlgorithm.inputDataRecencyInterval, - retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval - ) - } - - private func computeRetrospectiveGlucoseEffect(startingAt glucose: GlucoseValue, carbEffects: [GlucoseEffect]) -> [GlucoseEffect] { - - let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta) - let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) - return retrospectiveCorrection.computeEffect( - startingAt: glucose, - retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, - recencyInterval: LoopAlgorithm.inputDataRecencyInterval, - retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval - ) - } - - /// Generates a glucose prediction effect of suspending insulin delivery over duration of insulin action starting at current date - /// - /// - Throws: LoopError.configurationError - private func updateSuspendInsulinDeliveryEffect() throws { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - // Get settings, otherwise clear effect and throw error - guard - let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory - else { - suspendInsulinDeliveryEffect = [] - throw LoopError.configurationError(.insulinSensitivitySchedule) - } - guard - let basalRateSchedule = basalRateScheduleApplyingOverrideHistory - else { - suspendInsulinDeliveryEffect = [] - throw LoopError.configurationError(.basalRateSchedule) - } - - let insulinModel = doseStore.insulinModelProvider.model(for: pumpInsulinType) - let insulinActionDuration = insulinModel.effectDuration - - let startSuspend = now() - let endSuspend = startSuspend.addingTimeInterval(insulinActionDuration) - - var suspendDoses: [DoseEntry] = [] - let basalItems = basalRateSchedule.between(start: startSuspend, end: endSuspend) - - // Iterate over basal entries during suspension of insulin delivery - for (index, basalItem) in basalItems.enumerated() { - var startSuspendDoseDate: Date - var endSuspendDoseDate: Date - - if index == 0 { - startSuspendDoseDate = startSuspend - } else { - startSuspendDoseDate = basalItem.startDate - } - - if index == basalItems.count - 1 { - endSuspendDoseDate = endSuspend - } else { - endSuspendDoseDate = basalItems[index + 1].startDate - } - - let suspendDose = DoseEntry(type: .tempBasal, startDate: startSuspendDoseDate, endDate: endSuspendDoseDate, value: -basalItem.value, unit: DoseUnit.unitsPerHour) - - suspendDoses.append(suspendDose) - } - - // Calculate predicted glucose effect of suspending insulin delivery - suspendInsulinDeliveryEffect = suspendDoses.glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: insulinSensitivity).filterDateRange(startSuspend, endSuspend) - } - - /// Runs the glucose prediction on the latest effect data. - /// - /// - Throws: - /// - LoopError.configurationError - /// - LoopError.glucoseTooOld - /// - LoopError.invalidFutureGlucose - /// - LoopError.missingDataError - /// - LoopError.pumpDataTooOld - private func updatePredictedGlucoseAndRecommendedDose(with dosingDecision: StoredDosingDecision) -> (StoredDosingDecision, LoopError?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - var dosingDecision = dosingDecision - - self.logger.debug("Recomputing prediction and recommendations.") - - let startDate = now() - - guard let glucose = glucoseStore.latestGlucose else { - logger.error("Latest glucose missing") - dosingDecision.appendError(.missingDataError(.glucose)) - return (dosingDecision, .missingDataError(.glucose)) - } - - var errors = [LoopError]() - - if startDate.timeIntervalSince(glucose.startDate) > LoopAlgorithm.inputDataRecencyInterval { - errors.append(.glucoseTooOld(date: glucose.startDate)) - } - - if glucose.startDate.timeIntervalSince(startDate) > LoopAlgorithm.inputDataRecencyInterval { - errors.append(.invalidFutureGlucose(date: glucose.startDate)) - } - - let pumpStatusDate = doseStore.lastAddedPumpData - - if startDate.timeIntervalSince(pumpStatusDate) > LoopAlgorithm.inputDataRecencyInterval { - errors.append(.pumpDataTooOld(date: pumpStatusDate)) - } - - let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule() - if glucoseTargetRange == nil { - errors.append(.configurationError(.glucoseTargetRangeSchedule)) - } - - let basalRateSchedule = basalRateScheduleApplyingOverrideHistory - if basalRateSchedule == nil { - errors.append(.configurationError(.basalRateSchedule)) - } - - let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory - if insulinSensitivity == nil { - errors.append(.configurationError(.insulinSensitivitySchedule)) - } - - if carbRatioScheduleApplyingOverrideHistory == nil { - errors.append(.configurationError(.carbRatioSchedule)) - } - - let maxBasal = settings.maximumBasalRatePerHour - if maxBasal == nil { - errors.append(.configurationError(.maximumBasalRatePerHour)) - } - - let maxBolus = settings.maximumBolus - if maxBolus == nil { - errors.append(.configurationError(.maximumBolus)) - } - - if glucoseMomentumEffect == nil { - errors.append(.missingDataError(.momentumEffect)) - } - - if carbEffect == nil { - errors.append(.missingDataError(.carbEffect)) - } - - if insulinEffect == nil { - errors.append(.missingDataError(.insulinEffect)) - } - - if insulinEffectIncludingPendingInsulin == nil { - errors.append(.missingDataError(.insulinEffectIncludingPendingInsulin)) - } - - if self.insulinOnBoard == nil { - errors.append(.missingDataError(.activeInsulin)) - } - - dosingDecision.appendErrors(errors) - if let error = errors.first { - logger.error("%{public}@", String(describing: error)) - return (dosingDecision, error) - } - - var loopError: LoopError? - do { - let predictedGlucose = try predictGlucose(using: settings.enabledEffects) - self.predictedGlucose = predictedGlucose - let predictedGlucoseIncludingPendingInsulin = try predictGlucose(using: settings.enabledEffects, includingPendingInsulin: true) - self.predictedGlucoseIncludingPendingInsulin = predictedGlucoseIncludingPendingInsulin - - dosingDecision.predictedGlucose = predictedGlucose - - guard lastRequestedBolus == nil - else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - self.logger.debug("Not generating recommendations because bolus request is in progress.") - dosingDecision.appendWarning(.bolusInProgress) - return (dosingDecision, nil) - } - - let rateRounder = { (_ rate: Double) in - return self.delegate?.roundBasalRate(unitsPerHour: rate) ?? rate - } - - let lastTempBasal: DoseEntry? - - if case .some(.tempBasal(let dose)) = basalDeliveryState { - lastTempBasal = dose - } else { - lastTempBasal = nil - } - - let dosingRecommendation: AutomaticDoseRecommendation? - - // automaticDosingIOBLimit calculated from the user entered maxBolus - let automaticDosingIOBLimit = maxBolus! * 2.0 - let iobHeadroom = automaticDosingIOBLimit - self.insulinOnBoard!.value - - switch settings.automaticDosingStrategy { - case .automaticBolus: - let volumeRounder = { (_ units: Double) in - return self.delegate?.roundBolusVolume(units: units) ?? units - } - - // Create dosing strategy based on user setting - let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - ? GlucoseBasedApplicationFactorStrategy() - : ConstantApplicationFactorStrategy() - - let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() - - let effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( - for: glucose.quantity, - correctionRangeSchedule: correctionRangeSchedule!, - settings: settings - ) - - self.logger.debug(" *** Glucose: %{public}@, effectiveBolusApplicationFactor: %.2f", glucose.quantity.description, effectiveBolusApplicationFactor) - - // If a user customizes maxPartialApplicationFactor > 1; this respects maxBolus - let maxAutomaticBolus = min(iobHeadroom, maxBolus! * min(effectiveBolusApplicationFactor, 1.0)) - - dosingRecommendation = predictedGlucose.recommendedAutomaticDose( - to: glucoseTargetRange!, - at: predictedGlucose[0].startDate, - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity!, - model: doseStore.insulinModelProvider.model(for: pumpInsulinType), - basalRates: basalRateSchedule!, - maxAutomaticBolus: maxAutomaticBolus, - partialApplicationFactor: effectiveBolusApplicationFactor * self.timeBasedDoseApplicationFactor, - lastTempBasal: lastTempBasal, - volumeRounder: volumeRounder, - rateRounder: rateRounder, - isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true - ) - case .tempBasalOnly: - - let temp = predictedGlucose.recommendedTempBasal( - to: glucoseTargetRange!, - at: predictedGlucose[0].startDate, - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity!, - model: doseStore.insulinModelProvider.model(for: pumpInsulinType), - basalRates: basalRateSchedule!, - maxBasalRate: maxBasal!, - additionalActiveInsulinClamp: iobHeadroom, - lastTempBasal: lastTempBasal, - rateRounder: rateRounder, - isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true - ) - dosingRecommendation = AutomaticDoseRecommendation(basalAdjustment: temp) - } - - if let dosingRecommendation = dosingRecommendation { - self.logger.default("Recommending dose: %{public}@ at %{public}@", String(describing: dosingRecommendation), String(describing: startDate)) - recommendedAutomaticDose = (recommendation: dosingRecommendation, date: startDate) - } else { - self.logger.default("No dose recommended.") - recommendedAutomaticDose = nil - } - dosingDecision.automaticDoseRecommendation = recommendedAutomaticDose?.recommendation - } catch let error { - loopError = error as? LoopError ?? .unknownError(error) - if let loopError = loopError { - logger.error("Error attempting to predict glucose: %{public}@", String(describing: loopError)) - dosingDecision.appendError(loopError) - } - } - - return (dosingDecision, loopError) - } - - /// *This method should only be called from the `dataAccessQueue`* - private func enactRecommendedAutomaticDose() -> LoopError? { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - guard let recommendedDose = self.recommendedAutomaticDose else { - return nil - } - - guard abs(recommendedDose.date.timeIntervalSince(now())) < TimeInterval(minutes: 5) else { - return LoopError.recommendationExpired(date: recommendedDose.date) - } - - if case .suspended = basalDeliveryState { - return LoopError.pumpSuspended - } - - let updateGroup = DispatchGroup() - updateGroup.enter() - var delegateError: LoopError? - - delegate?.loopDataManager(self, didRecommend: recommendedDose) { (error) in - delegateError = error - updateGroup.leave() - } - updateGroup.wait() - - if delegateError == nil { - self.recommendedAutomaticDose = nil - } - - return delegateError - } - - /// Ensures that the current temp basal is at or below the proposed max temp basal, and if not, cancel it before proceeding. - /// Calls the completion with `nil` if successful, or an `error` if canceling the active temp basal fails. - func maxTempBasalSavePreflight(unitsPerHour: Double?, completion: @escaping (_ error: Error?) -> Void) { - guard let unitsPerHour = unitsPerHour else { - completion(nil) - return - } - dataAccessQueue.async { - switch self.basalDeliveryState { - case .some(.tempBasal(let dose)): - if dose.unitsPerHour > unitsPerHour { - // Temp basal is higher than proposed rate, so should cancel - self.cancelActiveTempBasal(for: .maximumBasalRateChanged, completion: completion) - } else { - completion(nil) - } - default: - completion(nil) - } - } - } -} - -/// Describes a view into the loop state -protocol LoopState { - /// The last-calculated carbs on board - var carbsOnBoard: CarbValue? { get } - - /// The last-calculated insulin on board - var insulinOnBoard: InsulinValue? { get } - - /// An error in the current state of the loop, or one that happened during the last attempt to loop. - var error: LoopError? { get } - - /// A timeline of average velocity of glucose change counteracting predicted insulin effects - var insulinCounteractionEffects: [GlucoseEffectVelocity] { get } - - /// The calculated timeline of predicted glucose values - var predictedGlucose: [PredictedGlucoseValue]? { get } - - /// The calculated timeline of predicted glucose values, including the effects of pending insulin - var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { get } - - /// The recommended temp basal based on predicted glucose - var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? { get } - - /// The difference in predicted vs actual glucose over a recent period - var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? { get } - - /// The total corrective glucose effect from retrospective correction - var totalRetrospectiveCorrection: HKQuantity? { get } - - /// Calculates a new prediction from the current data using the specified effect inputs - /// - /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. - /// - /// - Parameter inputs: The effect inputs to include - /// - Parameter potentialBolus: A bolus under consideration for which to include effects in the prediction - /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction - /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` - /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin - /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. - /// - Returns: An timeline of predicted glucose values - /// - Throws: LoopError.missingDataError if prediction cannot be computed - func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] - - /// Calculates a new prediction from a manual glucose entry in the context of a meal entry - /// - /// - Parameter glucose: The unstored manual glucose entry - /// - Parameter potentialBolus: A bolus under consideration for which to include effects in the prediction - /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction - /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` - /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin - /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. - /// - Returns: A timeline of predicted glucose values - func predictGlucoseFromManualGlucose( - _ glucose: NewGlucoseSample, - potentialBolus: DoseEntry?, - potentialCarbEntry: NewCarbEntry?, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, - includingPendingInsulin: Bool, - considerPositiveVelocityAndRC: Bool - ) throws -> [PredictedGlucoseValue] - - /// Computes the recommended bolus for correcting a glucose prediction, optionally considering a potential carb entry. - /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction - /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` - /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. - /// - Returns: A bolus recommendation, or `nil` if not applicable - /// - Throws: LoopError.missingDataError if recommendation cannot be computed - func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? - - /// Computes the recommended bolus for correcting a glucose prediction derived from a manual glucose entry, optionally considering a potential carb entry. - /// - Parameter glucose: The unstored manual glucose entry - /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction - /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` - /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. - /// - Returns: A bolus recommendation, or `nil` if not applicable - /// - Throws: LoopError.configurationError if recommendation cannot be computed - func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? -} - -extension LoopState { - /// Calculates a new prediction from the current data using the specified effect inputs - /// - /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. + /// Adds and stores carb data, and recommends a bolus if needed /// - /// - Parameter inputs: The effect inputs to include - /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin - /// - Returns: An timeline of predicted glucose values - /// - Throws: LoopError.missingDataError if prediction cannot be computed - func predictGlucose(using inputs: PredictionInputEffect, includingPendingInsulin: Bool = false) throws -> [GlucoseValue] { - try predictGlucose(using: inputs, potentialBolus: nil, potentialCarbEntry: nil, replacingCarbEntry: nil, includingPendingInsulin: includingPendingInsulin, considerPositiveVelocityAndRC: true) + /// - Parameters: + /// - carbEntry: The new carb value + /// - completion: A closure called once upon completion + /// - result: The bolus recommendation + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? = nil) async throws -> StoredCarbEntry { + let storedCarbEntry: StoredCarbEntry + if let replacingEntry = replacingEntry { + storedCarbEntry = try await carbStore.replaceCarbEntry(replacingEntry, withEntry: carbEntry) + } else { + storedCarbEntry = try await carbStore.addCarbEntry(carbEntry) + } + self.temporaryPresetsManager.clearOverride(matching: .preMeal) + return storedCarbEntry } -} - -extension LoopDataManager { - private struct LoopStateView: LoopState { + @discardableResult + func deleteCarbEntry(_ oldEntry: StoredCarbEntry) async throws -> Bool { + try await carbStore.deleteCarbEntry(oldEntry) + } - private let loopDataManager: LoopDataManager - private let updateError: LoopError? + /// Logs a new external bolus insulin dose in the DoseStore and HealthKit + /// + /// - Parameters: + /// - startDate: The date the dose was started at. + /// - value: The number of Units in the dose. + /// - insulinModel: The type of insulin model that should be used for the dose. + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType? = nil) async { + let syncIdentifier = Data(UUID().uuidString.utf8).hexadecimalString + let dose = DoseEntry(type: .bolus, startDate: startDate, value: units, unit: .units, syncIdentifier: syncIdentifier, insulinType: insulinType, manuallyEntered: true) - init(loopDataManager: LoopDataManager, updateError: LoopError?) { - self.loopDataManager = loopDataManager - self.updateError = updateError + do { + try await doseStore.addDoses([dose], from: nil) + self.notify(forChange: .insulin) + } catch { + logger.error("Error storing manual dose: %{public}@", error.localizedDescription) } + } - var carbsOnBoard: CarbValue? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.carbsOnBoard - } - - var insulinOnBoard: InsulinValue? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.insulinOnBoard - } + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { + let dosingDecision = StoredDosingDecision(date: date, + reason: bolusDosingDecision.reason.rawValue, + settings: StoredDosingDecision.Settings(settingsProvider.settings), + scheduleOverride: bolusDosingDecision.scheduleOverride, + controllerStatus: UIDevice.current.controllerStatus, + lastReservoirValue: StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue), + historicalGlucose: bolusDosingDecision.historicalGlucose, + originalCarbEntry: bolusDosingDecision.originalCarbEntry, + carbEntry: bolusDosingDecision.carbEntry, + manualGlucoseSample: bolusDosingDecision.manualGlucoseSample, + carbsOnBoard: bolusDosingDecision.carbsOnBoard, + insulinOnBoard: bolusDosingDecision.insulinOnBoard, + glucoseTargetRangeSchedule: bolusDosingDecision.glucoseTargetRangeSchedule, + predictedGlucose: bolusDosingDecision.predictedGlucose, + manualBolusRecommendation: bolusDosingDecision.manualBolusRecommendation, + manualBolusRequested: bolusDosingDecision.manualBolusRequested) + await dosingDecisionStore.storeDosingDecision(dosingDecision) + } - var error: LoopError? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return updateError ?? loopDataManager.lastLoopError - } + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + type(of: self).LoopUpdateContextKey: context.rawValue + ] + ) + } - var insulinCounteractionEffects: [GlucoseEffectVelocity] { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.insulinCounteractionEffects - } + /// Estimate glucose effects of suspending insulin delivery over duration of insulin action starting at the specified date + func insulinDeliveryEffect(at date: Date, insulinType: InsulinType) async throws -> [GlucoseEffect] { + let startSuspend = date + let insulinEffectDuration = LoopAlgorithm.insulinModelProvider.model(for: insulinType).effectDuration + let endSuspend = startSuspend.addingTimeInterval(insulinEffectDuration) - var predictedGlucose: [PredictedGlucoseValue]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.predictedGlucose - } + var suspendDoses: [DoseEntry] = [] - var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.predictedGlucoseIncludingPendingInsulin - } + let basal = try await settingsProvider.getBasalHistory(startDate: startSuspend, endDate: endSuspend) + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: startSuspend, endDate: endSuspend) - var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - guard loopDataManager.lastRequestedBolus == nil else { - return nil - } - return loopDataManager.recommendedAutomaticDose - } + // Iterate over basal entries during suspension of insulin delivery + for (index, basalItem) in basal.enumerated() { + var startSuspendDoseDate: Date + var endSuspendDoseDate: Date - var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.retrospectiveGlucoseDiscrepanciesSummed - } + guard basalItem.endDate > startSuspend && basalItem.startDate < endSuspend else { + continue + } - var totalRetrospectiveCorrection: HKQuantity? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.retrospectiveCorrection.totalGlucoseCorrectionEffect - } + if index == 0 { + startSuspendDoseDate = startSuspend + } else { + startSuspendDoseDate = basalItem.startDate + } - func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.predictGlucose(using: inputs, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - } + if index == basal.count - 1 { + endSuspendDoseDate = endSuspend + } else { + endSuspendDoseDate = basal[index + 1].startDate + } - func predictGlucoseFromManualGlucose( - _ glucose: NewGlucoseSample, - potentialBolus: DoseEntry?, - potentialCarbEntry: NewCarbEntry?, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, - includingPendingInsulin: Bool, - considerPositiveVelocityAndRC: Bool - ) throws -> [PredictedGlucoseValue] { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.predictGlucoseFromManualGlucose(glucose, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) - } + let suspendDose = DoseEntry(type: .tempBasal, startDate: startSuspendDoseDate, endDate: endSuspendDoseDate, value: -basalItem.value, unit: DoseUnit.unitsPerHour) - func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) + suspendDoses.append(suspendDose) } - func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.recommendBolusForManualGlucose(glucose, consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) - } + // Calculate predicted glucose effect of suspending insulin delivery + return suspendDoses.glucoseEffects( + insulinModelProvider: LoopAlgorithm.insulinModelProvider, + insulinSensitivityHistory: sensitivity + ).filterDateRange(startSuspend, endSuspend) } - /// Executes a closure with access to the current state of the loop. - /// - /// This operation is performed asynchronously and the closure will be executed on an arbitrary background queue. - /// - /// - Parameter handler: A closure called when the state is ready - /// - Parameter manager: The loop manager - /// - Parameter state: The current state of the manager. This is invalid to access outside of the closure. - func getLoopState(_ handler: @escaping (_ manager: LoopDataManager, _ state: LoopState) -> Void) { - dataAccessQueue.async { - let (_, updateError) = self.update(for: .getLoopState) - - handler(self, LoopStateView(loopDataManager: self, updateError: updateError)) - } - } - - func generateSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { - + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + var dosingDecision = BolusDosingDecision(for: .simpleBolus) - - var activeInsulin: Double? = nil - let semaphore = DispatchSemaphore(value: 0) - doseStore.insulinOnBoard(at: Date()) { (result) in - if case .success(let iobValue) = result { - activeInsulin = iobValue.value - dosingDecision.insulinOnBoard = iobValue - } - semaphore.signal() - } - semaphore.wait() - - guard let iob = activeInsulin, - let suspendThreshold = settings.suspendThreshold?.quantity, - let carbRatioSchedule = carbStore.carbRatioScheduleApplyingOverrideHistory, - let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: mealCarbs != nil), - let sensitivitySchedule = insulinSensitivityScheduleApplyingOverrideHistory + + guard let iob = displayState.activeInsulin?.value, + let suspendThreshold = settingsProvider.settings.suspendThreshold?.quantity, + let carbRatioSchedule = temporaryPresetsManager.carbRatioScheduleApplyingOverrideHistory, + let correctionRangeSchedule = temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: mealCarbs != nil), + let sensitivitySchedule = temporaryPresetsManager.insulinSensitivityScheduleApplyingOverrideHistory else { // Settings incomplete; should never get here; remove when therapy settings non-optional return nil } - - if let scheduleOverride = settings.scheduleOverride, !scheduleOverride.hasFinished() { - dosingDecision.scheduleOverride = settings.scheduleOverride + + if let scheduleOverride = temporaryPresetsManager.scheduleOverride, !scheduleOverride.hasFinished() { + dosingDecision.scheduleOverride = temporaryPresetsManager.scheduleOverride } dosingDecision.glucoseTargetRangeSchedule = correctionRangeSchedule - + var notice: BolusRecommendationNotice? = nil if let manualGlucose = manualGlucose { let glucoseValue = SimpleGlucoseValue(startDate: date, quantity: manualGlucose) @@ -2153,7 +778,7 @@ extension LoopDataManager { } } } - + let bolusAmount = SimpleBolusCalculator.recommendedInsulin( mealCarbs: mealCarbs, manualGlucose: manualGlucose, @@ -2162,169 +787,110 @@ extension LoopDataManager { correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule, at: date) - + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), notice: notice), date: Date()) - + return dosingDecision } + + } -extension LoopDataManager { - /// Generates a diagnostic report about the current state - /// - /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue. - /// - /// - parameter completion: A closure called once the report has been generated. The closure takes a single argument of the report string. - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - getLoopState { (manager, state) in - - var entries: [String] = [ - "## LoopDataManager", - "settings: \(String(reflecting: manager.settings))", - - "insulinCounteractionEffects: [", - "* GlucoseEffectVelocity(start, end, mg/dL/min)", - manager.insulinCounteractionEffects.reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: GlucoseEffectVelocity.unit))\n") - }), - "]", - - "insulinEffect: [", - "* GlucoseEffect(start, mg/dL)", - (manager.insulinEffect ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "carbEffect: [", - "* GlucoseEffect(start, mg/dL)", - (manager.carbEffect ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "predictedGlucose: [", - "* PredictedGlucoseValue(start, mg/dL)", - (state.predictedGlucoseIncludingPendingInsulin ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "integralRetrospectiveCorrectionEnabled: \(UserDefaults.standard.integralRetrospectiveCorrectionEnabled)", - - "retrospectiveGlucoseDiscrepancies: [", - "* GlucoseEffect(start, mg/dL)", - (manager.retrospectiveGlucoseDiscrepancies ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "retrospectiveGlucoseDiscrepanciesSummed: [", - "* GlucoseChange(start, end, mg/dL)", - (manager.retrospectiveGlucoseDiscrepanciesSummed ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "glucoseMomentumEffect: \(manager.glucoseMomentumEffect ?? [])", - "retrospectiveGlucoseEffect: \(manager.retrospectiveGlucoseEffect)", - "recommendedAutomaticDose: \(String(describing: state.recommendedAutomaticDose))", - "lastBolus: \(String(describing: manager.lastRequestedBolus))", - "lastLoopCompleted: \(String(describing: manager.lastLoopCompleted))", - "basalDeliveryState: \(String(describing: manager.basalDeliveryState))", - "carbsOnBoard: \(String(describing: state.carbsOnBoard))", - "insulinOnBoard: \(String(describing: manager.insulinOnBoard))", - "error: \(String(describing: state.error))", - "overrideInUserDefaults: \(String(describing: UserDefaults.appGroup?.intentExtensionOverrideToSet))", - "glucoseBasedApplicationFactorEnabled: \(UserDefaults.standard.glucoseBasedApplicationFactorEnabled)", - "", - String(reflecting: self.retrospectiveCorrection), - "", - ] +extension NewCarbEntry { + var asStoredCarbEntry: StoredCarbEntry { + StoredCarbEntry( + startDate: startDate, + quantity: quantity, + foodType: foodType, + absorptionTime: absorptionTime, + userCreatedDate: date + ) + } +} - self.glucoseStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - self.carbStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - self.doseStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - self.mealDetectionManager.generateDiagnosticReport { report in - entries.append(report) - entries.append("") - - UNUserNotificationCenter.current().generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - UIDevice.current.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - completion(entries.joined(separator: "\n")) - } - } - } - } - } - } - } +extension NewGlucoseSample { + var asStoredGlucoseStample: StoredGlucoseSample { + StoredGlucoseSample( + syncIdentifier: syncIdentifier, + syncVersion: syncVersion, + startDate: date, + quantity: quantity, + condition: condition, + trend: trend, + trendRate: trendRate, + isDisplayOnly: isDisplayOnly, + wasUserEntered: wasUserEntered, + device: device + ) } } -extension Notification.Name { - static let LoopDataUpdated = Notification.Name(rawValue: "com.loopkit.Loop.LoopDataUpdated") - static let LoopRunning = Notification.Name(rawValue: "com.loopkit.Loop.LoopRunning") - static let LoopCompleted = Notification.Name(rawValue: "com.loopkit.Loop.LoopCompleted") -} +extension LoopAlgorithmInput { -protocol LoopDataManagerDelegate: AnyObject { + func addingDose(dose: DoseEntry?) -> LoopAlgorithmInput { + var rval = self + if let dose { + rval.doses = doses + [dose] + } + return rval + } - /// Informs the delegate that an immediate basal change is recommended - /// - /// - Parameters: - /// - manager: The manager - /// - basal: The new recommended basal - /// - completion: A closure called once on completion. Will be passed a non-null error if acting on the recommendation fails. - /// - result: The enacted basal - func loopDataManager(_ manager: LoopDataManager, didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), completion: @escaping (LoopError?) -> Void) -> Void + func addingGlucoseSample(sample: NewGlucoseSample?) -> LoopAlgorithmInput { + var rval = self + if let sample { + rval.glucoseHistory.append(sample.asStoredGlucoseStample) + } + return rval + } - /// Asks the delegate to round a recommended basal rate to a supported rate - /// - /// - Parameters: - /// - 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: - /// - units: The recommended bolus in U - /// - Returns: a supported bolus volume in U. The volume returned should be the nearest deliverable volume. - func roundBolusVolume(units: Double) -> Double + func addingCarbEntry(carbEntry: NewCarbEntry?) -> LoopAlgorithmInput { + var rval = self + if let carbEntry { + rval.carbEntries = carbEntries + [carbEntry.asStoredCarbEntry] + } + return rval + } + + func removingCarbEntry(carbEntry: StoredCarbEntry?) -> LoopAlgorithmInput { + guard let carbEntry else { + return self + } + var rval = self + var currentEntries = self.carbEntries + if let index = currentEntries.firstIndex(of: carbEntry) { + currentEntries.remove(at: index) + } + rval.carbEntries = currentEntries + return rval + } - /// The pump manager status, if one exists. - var pumpManagerStatus: PumpManagerStatus? { get } + func predictGlucose(effectsOptions: AlgorithmEffectsOptions = .all) throws -> [PredictedGlucoseValue] { + let prediction = LoopAlgorithm.generatePrediction( + start: predictionStart, + glucoseHistory: glucoseHistory, + doses: doses, + carbEntries: carbEntries, + basal: basal, + sensitivity: sensitivity, + carbRatio: carbRatio, + algorithmEffectsOptions: effectsOptions, + useIntegralRetrospectiveCorrection: self.useIntegralRetrospectiveCorrection, + carbAbsorptionModel: self.carbAbsorptionModel.model + ) + return prediction.glucose + } +} - /// The pump status highlight, if one exists. - var pumpStatusHighlight: DeviceStatusHighlight? { get } +extension Notification.Name { + static let LoopDataUpdated = Notification.Name(rawValue: "com.loopkit.Loop.LoopDataUpdated") + static let LoopRunning = Notification.Name(rawValue: "com.loopkit.Loop.LoopRunning") + static let LoopCycleCompleted = Notification.Name(rawValue: "com.loopkit.Loop.LoopCycleCompleted") +} - /// The cgm manager status, if one exists. - var cgmManagerStatus: CGMManagerStatus? { get } +protocol BolusDurationEstimator: AnyObject { + func estimateBolusDuration(bolusUnits: Double) -> TimeInterval? } private extension TemporaryScheduleOverride { @@ -2363,111 +929,12 @@ private extension StoredDosingDecision.Settings { } } -// MARK: - Simulated Core Data - -extension LoopDataManager { - func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) { - guard FeatureFlags.simulatedCoreDataEnabled else { - fatalError("\(#function) should be invoked only when simulated core data is enabled") - } - - guard let glucoseStore = glucoseStore as? GlucoseStore, let carbStore = carbStore as? CarbStore, let doseStore = doseStore as? DoseStore, let dosingDecisionStore = dosingDecisionStore as? DosingDecisionStore else { - fatalError("Mock stores should not be used to generate simulated core data") - } - - glucoseStore.generateSimulatedHistoricalGlucoseObjects() { error in - guard error == nil else { - completion(error) - return - } - carbStore.generateSimulatedHistoricalCarbObjects() { error in - guard error == nil else { - completion(error) - return - } - dosingDecisionStore.generateSimulatedHistoricalDosingDecisionObjects() { error in - guard error == nil else { - completion(error) - return - } - doseStore.generateSimulatedHistoricalPumpEvents(completion: completion) - } - } - } - } - - func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) { - guard FeatureFlags.simulatedCoreDataEnabled else { - fatalError("\(#function) should be invoked only when simulated core data is enabled") - } - - guard let glucoseStore = glucoseStore as? GlucoseStore, let carbStore = carbStore as? CarbStore, let doseStore = doseStore as? DoseStore, let dosingDecisionStore = dosingDecisionStore as? DosingDecisionStore else { - fatalError("Mock stores should not be used to generate simulated core data") - } - - doseStore.purgeHistoricalPumpEvents() { error in - guard error == nil else { - completion(error) - return - } - dosingDecisionStore.purgeHistoricalDosingDecisionObjects() { error in - guard error == nil else { - completion(error) - return - } - carbStore.purgeHistoricalCarbObjects() { error in - guard error == nil else { - completion(error) - return - } - glucoseStore.purgeHistoricalGlucoseObjects(completion: completion) - } - } - } - } -} - -extension LoopDataManager { - public var therapySettings: TherapySettings { - get { - let settings = settings - return TherapySettings(glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, - correctionRangeOverrides: CorrectionRangeOverrides(preMeal: settings.preMealTargetRange, workout: settings.legacyWorkoutTargetRange), - overridePresets: settings.overridePresets, - maximumBasalRatePerHour: settings.maximumBasalRatePerHour, - maximumBolus: settings.maximumBolus, - suspendThreshold: settings.suspendThreshold, - insulinSensitivitySchedule: settings.insulinSensitivitySchedule, - carbRatioSchedule: settings.carbRatioSchedule, - basalRateSchedule: settings.basalRateSchedule, - defaultRapidActingModel: settings.defaultRapidActingModel) - } - - set { - mutateSettings { settings in - settings.defaultRapidActingModel = newValue.defaultRapidActingModel - settings.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule - settings.carbRatioSchedule = newValue.carbRatioSchedule - settings.basalRateSchedule = newValue.basalRateSchedule - settings.glucoseTargetRangeSchedule = newValue.glucoseTargetRangeSchedule - settings.preMealTargetRange = newValue.correctionRangeOverrides?.preMeal - settings.legacyWorkoutTargetRange = newValue.correctionRangeOverrides?.workout - settings.suspendThreshold = newValue.suspendThreshold - settings.maximumBolus = newValue.maximumBolus - settings.maximumBasalRatePerHour = newValue.maximumBasalRatePerHour - settings.overridePresets = newValue.overridePresets ?? [] - } - } - } -} - extension LoopDataManager: ServicesManagerDelegate { - //Overrides - + // Remote Overrides func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws { - guard let preset = settings.overridePresets.first(where: { $0.name == name }) else { + guard let preset = settingsProvider.settings.overridePresets.first(where: { $0.name == name }) else { throw EnactOverrideError.unknownPreset(name) } @@ -2476,19 +943,16 @@ extension LoopDataManager: ServicesManagerDelegate { if let duration { remoteOverride.duration = duration } - - await enactOverride(remoteOverride) + + temporaryPresetsManager.scheduleOverride = remoteOverride } func cancelCurrentOverride() async throws { - await enactOverride(nil) - } - - func enactOverride(_ override: TemporaryScheduleOverride?) async { - mutateSettings { settings in settings.scheduleOverride = override } + temporaryPresetsManager.scheduleOverride = nil } + enum EnactOverrideError: LocalizedError { case unknownPreset(String) @@ -2529,7 +993,7 @@ extension LoopDataManager: ServicesManagerDelegate { let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) - let _ = try await devliverCarbEntry(candidateCarbEntry) + let _ = try await carbStore.addCarbEntry(candidateCarbEntry) } enum CarbActionError: LocalizedError { @@ -2566,19 +1030,203 @@ extension LoopDataManager: ServicesManagerDelegate { return formatter }() } +} + +extension LoopDataManager: SimpleBolusViewModelDelegate { + + func insulinOnBoard(at date: Date) async -> LoopKit.InsulinValue? { + displayState.activeInsulin + } + + var maximumBolus: Double? { + settingsProvider.settings.maximumBolus + } - //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version - func devliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { - return try await withCheckedThrowingContinuation { continuation in - carbStore.addCarbEntry(carbEntry) { result in - switch result { - case .success(let storedCarbEntry): - continuation.resume(returning: storedCarbEntry) - case .failure(let error): - continuation.resume(throwing: error) - } + var suspendThreshold: HKQuantity? { + settingsProvider.settings.suspendThreshold?.quantity + } + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { + try await deliveryDelegate?.enactBolus(units: units, activationType: activationType) + } + +} + +extension LoopDataManager: BolusEntryViewModelDelegate { + func saveGlucose(sample: LoopKit.NewGlucoseSample) async throws -> LoopKit.StoredGlucoseSample { + let storedSamples = try await addGlucose([sample]) + return storedSamples.first! + } + + var preMealOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.preMealOverride + } + + var mostRecentGlucoseDataDate: Date? { + displayState.input?.glucoseHistory.last?.startDate + } + + var mostRecentPumpDataDate: Date? { + return doseStore.lastAddedPumpData + } + + func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? { + temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: presumingMealEntry) + } + + func generatePrediction(input: LoopAlgorithmInput) throws -> [PredictedGlucoseValue] { + try input.predictGlucose() + } +} + + +extension LoopDataManager: CarbEntryViewModelDelegate { + func scheduleOverrideEnabled(at date: Date) -> Bool { + temporaryPresetsManager.scheduleOverrideEnabled(at: date) + } + + var defaultAbsorptionTimes: LoopKit.CarbStore.DefaultAbsorptionTimes { + carbStore.defaultAbsorptionTimes + } + +} + +extension LoopDataManager: ManualDoseViewModelDelegate { + var pumpInsulinType: InsulinType? { + deliveryDelegate?.pumpInsulinType + } + + var settings: StoredSettings { + settingsProvider.settings + } + + var scheduleOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.scheduleOverride + } + + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { + return LoopAlgorithm.insulinModelProvider.model(for: type).effectDuration + } + + var algorithmDisplayState: AlgorithmDisplayState { + get async { return displayState } + } + +} + +extension AutomaticDosingStrategy { + var recommendationType: DoseRecommendationType { + switch self { + case .tempBasalOnly: + return .tempBasal + case .automaticBolus: + return .automaticBolus + } + } +} + +extension StoredDosingDecision { + mutating func updateFrom(input: LoopAlgorithmInput, output: LoopAlgorithmOutput) { + self.historicalGlucose = input.glucoseHistory.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } + switch output.recommendationResult { + case .success(let recommendation): + self.automaticDoseRecommendation = recommendation.automatic + case .failure(let error): + self.appendError(error as? LoopError ?? .unknownError(error)) + } + if let activeInsulin = output.activeInsulin { + self.insulinOnBoard = InsulinValue(startDate: input.predictionStart, value: activeInsulin) + } + if let activeCarbs = output.activeCarbs { + self.carbsOnBoard = CarbValue(startDate: input.predictionStart, value: activeCarbs) + } + self.predictedGlucose = output.predictedGlucose + } +} + +enum CancelActiveTempBasalReason: String { + case automaticDosingDisabled + case unreliableCGMData + case maximumBasalRateChanged +} + +extension LoopDataManager : AlgorithmDisplayStateProvider { + var algorithmState: AlgorithmDisplayState { + return displayState + } +} + +extension LoopDataManager: DiagnosticReportGenerator { + func generateDiagnosticReport() async -> String { + let (algoInput, algoOutput) = displayState.asTuple + + var loopError: Error? + var doseRecommendation: LoopAlgorithmDoseRecommendation? + + if let algoOutput { + switch algoOutput.recommendationResult { + case .success(let recommendation): + doseRecommendation = recommendation + case .failure(let error): + loopError = error } } + + let entries: [String] = [ + "## LoopDataManager", + "settings: \(String(reflecting: settingsProvider.settings))", + + "insulinCounteractionEffects: [", + "* GlucoseEffectVelocity(start, end, mg/dL/min)", + (algoOutput?.effects.insulinCounteraction ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: GlucoseEffectVelocity.unit))\n") + }), + "]", + + "insulinEffect: [", + "* GlucoseEffect(start, mg/dL)", + (algoOutput?.effects.insulin ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "carbEffect: [", + "* GlucoseEffect(start, mg/dL)", + (algoOutput?.effects.carbs ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "predictedGlucose: [", + "* PredictedGlucoseValue(start, mg/dL)", + (algoOutput?.predictedGlucose ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "integralRetrospectiveCorrectionEnabled: \(UserDefaults.standard.integralRetrospectiveCorrectionEnabled)", + + "retrospectiveCorrection: [", + "* GlucoseEffect(start, mg/dL)", + (algoOutput?.effects.retrospectiveCorrection ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "glucoseMomentumEffect: \(algoOutput?.effects.momentum ?? [])", + "recommendedAutomaticDose: \(String(describing: doseRecommendation))", + "lastLoopCompleted: \(String(describing: lastLoopCompleted))", + "carbsOnBoard: \(String(describing: algoOutput?.activeCarbs))", + "insulinOnBoard: \(String(describing: algoOutput?.activeInsulin))", + "error: \(String(describing: loopError))", + "overrideInUserDefaults: \(String(describing: UserDefaults.appGroup?.intentExtensionOverrideToSet))", + "glucoseBasedApplicationFactorEnabled: \(UserDefaults.standard.glucoseBasedApplicationFactorEnabled)", + "integralRetrospectiveCorrectionEanbled: \(String(describing: algoInput?.useIntegralRetrospectiveCorrection))", + "" + ] + return entries.joined(separator: "\n") + } - } + +extension LoopDataManager: LoopControl { } diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index a3922a873a..bf000d3e95 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -11,21 +11,28 @@ import HealthKit import OSLog import LoopCore import LoopKit +import Combine enum MissedMealStatus: Equatable { case hasMissedMeal(startTime: Date, carbAmount: Double) case noMissedMeal } +protocol BolusStateProvider { + var bolusState: PumpManagerStatus.BolusState? { get } +} + +protocol AlgorithmDisplayStateProvider { + var algorithmState: AlgorithmDisplayState { get async } +} + +@MainActor class MealDetectionManager { private let log = OSLog(category: "MealDetectionManager") + // All math for meal detection occurs in mg/dL, with settings being converted if in mmol/L private let unit = HKUnit.milligramsPerDeciliter - 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 { @@ -40,46 +47,84 @@ class MealDetectionManager { /// 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 + private var algorithmStateProvider: AlgorithmDisplayStateProvider + private var settingsProvider: SettingsWithOverridesProvider + private var bolusStateProvider: BolusStateProvider + + private lazy var cancellables = Set() + + // For testing only + var test_currentDate: Date? + + init( + algorithmStateProvider: AlgorithmDisplayStateProvider, + settingsProvider: SettingsWithOverridesProvider, + bolusStateProvider: BolusStateProvider ) { - self.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory - self.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory - self.maximumBolus = maximumBolus - self.test_currentDate = test_currentDate + self.algorithmStateProvider = algorithmStateProvider + self.settingsProvider = settingsProvider + self.bolusStateProvider = bolusStateProvider + + if FeatureFlags.missedMealNotifications { + NotificationCenter.default.publisher(for: .LoopCycleCompleted) + .sink { [weak self] _ in + Task { await self?.run() } + } + .store(in: &cancellables) + } } - + + func run() async { + let algoState = await algorithmStateProvider.algorithmState + guard let input = algoState.input, let output = algoState.output else { + self.log.debug("Skipping run with missing algorithm input/output") + return + } + + let date = test_currentDate ?? Date() + let samplesStart = date.addingTimeInterval(-MissedMealSettings.maxRecency) + + guard let sensitivitySchedule = settingsProvider.insulinSensitivityScheduleApplyingOverrideHistory, + let carbRatioSchedule = settingsProvider.carbRatioSchedule, + let maxBolus = settingsProvider.maximumBolus else + { + return + } + + generateMissedMealNotificationIfNeeded( + at: date, + glucoseSamples: input.glucoseHistory, + insulinCounteractionEffects: output.effects.insulinCounteraction, + carbEffects: output.effects.carbs, + sensitivitySchedule: sensitivitySchedule, + carbRatioSchedule: carbRatioSchedule, + maxBolus: maxBolus + ) + } + // MARK: Meal Detection - func hasMissedMeal(glucoseSamples: [some GlucoseSampleValue], insulinCounteractionEffects: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], completion: @escaping (MissedMealStatus) -> Void) { + func hasMissedMeal( + at date: Date, + glucoseSamples: [some GlucoseSampleValue], + insulinCounteractionEffects: [GlucoseEffectVelocity], + carbEffects: [GlucoseEffect], + sensitivitySchedule: InsulinSensitivitySchedule, + carbRatioSchedule: CarbRatioSchedule + ) -> MissedMealStatus + { let delta = TimeInterval(minutes: 5) - let intervalStart = currentDate(timeIntervalSinceNow: -MissedMealSettings.maxRecency) - let intervalEnd = currentDate(timeIntervalSinceNow: -MissedMealSettings.minRecency) - let now = self.currentDate - + let intervalStart = date.addingTimeInterval(-MissedMealSettings.maxRecency) + let intervalEnd = date.addingTimeInterval(-MissedMealSettings.minRecency) + let now = date + let filteredGlucoseValues = glucoseSamples.filter { intervalStart <= $0.startDate && $0.startDate <= now } /// Only try to detect if there's a missed meal if there are no calibration/user-entered BGs, /// since these can cause large jumps guard !filteredGlucoseValues.containsUserEntered() else { - completion(.noMissedMeal) - return + return .noMissedMeal } let filteredCarbEffects = carbEffects.filterDateRange(intervalStart, now) @@ -155,9 +200,16 @@ class MealDetectionManager { /// Find the threshold based on a minimum of `missedMealGlucoseRiseThreshold` of change per minute let minutesAgo = now.timeIntervalSince(pastTime).minutes let rateThreshold = MissedMealSettings.glucoseRiseThreshold * minutesAgo - + + let carbRatio = carbRatioSchedule.value(at: pastTime) + let insulinSensitivity = sensitivitySchedule.value(for: unit, at: pastTime) + /// 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 { + guard let mealThreshold = self.effectThreshold( + carbRatio: carbRatio, + insulinSensitivity: insulinSensitivity, + carbsInGrams: MissedMealSettings.minCarbThreshold + ) else { continue } @@ -175,24 +227,30 @@ class MealDetectionManager { let mealTimeTooRecent = now.timeIntervalSince(mealTime) < MissedMealSettings.minRecency guard !mealTimeTooRecent else { - completion(.noMissedMeal) - return + return .noMissedMeal } self.lastDetectedMissedMealTimeline = missedMealTimeline.reversed() - - let carbAmount = self.determineCarbs(mealtime: mealTime, unexpectedDeviation: unexpectedDeviation) - completion(.hasMissedMeal(startTime: mealTime, carbAmount: carbAmount ?? MissedMealSettings.minCarbThreshold)) + + let carbRatio = carbRatioSchedule.value(at: mealTime) + let insulinSensitivity = sensitivitySchedule.value(for: unit, at: mealTime) + + let carbAmount = self.determineCarbs( + carbRatio: carbRatio, + insulinSensitivity: insulinSensitivity, + unexpectedDeviation: unexpectedDeviation + ) + return .hasMissedMeal(startTime: mealTime, carbAmount: carbAmount ?? MissedMealSettings.minCarbThreshold) } - private func determineCarbs(mealtime: Date, unexpectedDeviation: Double) -> Double? { + private func determineCarbs(carbRatio: Double, insulinSensitivity: Double, 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), + let modeledCarbEffect = effectThreshold(carbRatio: carbRatio, insulinSensitivity: insulinSensitivity, carbsInGrams: carbAmount), unexpectedDeviation >= modeledCarbEffect { mealCarbs = carbAmount @@ -202,14 +260,14 @@ class MealDetectionManager { return mealCarbs } - private func effectThreshold(mealStart: Date, carbsInGrams: Double) -> Double? { - guard - let carbRatio = carbRatioScheduleApplyingOverrideHistory?.value(at: mealStart), - let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory?.value(for: unit, at: mealStart) - else { - return nil - } - + + /// Calculates effect threshold. + /// + /// - Parameters: + /// - carbRatio: Carb ratio in grams per unit in effect at the start of the meal. + /// - insulinSensitivity: Insulin sensitivity in mg/dL/U in effect at the start of the meal. + /// - carbsInGrams: Carbohydrate amount for the meal in grams + private func effectThreshold(carbRatio: Double, insulinSensitivity: Double, carbsInGrams: Double) -> Double? { return carbsInGrams / carbRatio * insulinSensitivity } @@ -220,28 +278,41 @@ class MealDetectionManager { /// - 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( + at date: Date, glucoseSamples: [some GlucoseSampleValue], insulinCounteractionEffects: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], - pendingAutobolusUnits: Double? = nil, - bolusDurationEstimator: @escaping (Double) -> TimeInterval? + sensitivitySchedule: InsulinSensitivitySchedule, + carbRatioSchedule: CarbRatioSchedule, + maxBolus: Double ) { - hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: insulinCounteractionEffects, carbEffects: carbEffects) {[weak self] status in - self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits, bolusDurationEstimator: bolusDurationEstimator) - } + let status = hasMissedMeal( + at: date, + glucoseSamples: glucoseSamples, + insulinCounteractionEffects: insulinCounteractionEffects, + carbEffects: carbEffects, + sensitivitySchedule: sensitivitySchedule, + carbRatioSchedule: carbRatioSchedule + ) + + manageMealNotifications( + at: date, + for: status + ) } // Internal for unit testing - func manageMealNotifications(for status: MissedMealStatus, pendingAutobolusUnits: Double? = nil, bolusDurationEstimator getBolusDuration: (Double) -> TimeInterval?) { + func manageMealNotifications( + at date: Date, + for status: MissedMealStatus + ) { // 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 now = date let notificationTimeTooRecent = now.timeIntervalSince(lastMissedMealNotification?.deliveryTime ?? .distantPast) < (MissedMealSettings.maxRecency - MissedMealSettings.minRecency) guard @@ -253,24 +324,17 @@ class MealDetectionManager { 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) - } - + let currentCarbRatio = settingsProvider.carbRatioSchedule!.quantity(at: now).doubleValue(for: .gram()) + let maxAllowedCarbAutofill = settingsProvider.maximumBolus! * currentCarbRatio + let clampedCarbAmount = min(carbAmount, 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 + if let estimatedBolusDuration = bolusStateProvider.bolusTimeRemaining(at: now), + estimatedBolusDuration < MissedMealSettings.maxNotificationDelay, + estimatedBolusDuration > 0 { NotificationManager.sendMissedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) lastMissedMealNotification = MissedMealNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), @@ -286,23 +350,25 @@ class MealDetectionManager { /// 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")) + func generateDiagnosticReport() async -> String { + await withCheckedContinuation { continuation in + 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") + }) + ] + + continuation.resume(returning: report.joined(separator: "\n")) + } } } @@ -313,3 +379,13 @@ fileprivate extension BidirectionalCollection where Element: GlucoseSampleValue, return containsCalibrations() || filter({ $0.wasUserEntered }).count != 0 } } + +extension BolusStateProvider { + func bolusTimeRemaining(at date: Date = Date()) -> TimeInterval? { + guard case .inProgress(let dose) = bolusState else { + return nil + } + return max(0, dose.endDate.timeIntervalSince(date)) + } +} + diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 996d147047..b91ab70614 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -19,6 +19,7 @@ enum NotificationManager { } } +@MainActor extension NotificationManager { private static var notificationCategories: Set { var categories = [UNNotificationCategory]() @@ -115,7 +116,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteBolusNotification(amount: Double) { let notification = UNMutableNotificationContent() let quantityFormatter = QuantityFormatter(for: .internationalUnit()) @@ -138,7 +138,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteBolusFailureNotification(for error: Error, amountInUnits: Double) { let notification = UNMutableNotificationContent() let quantityFormatter = QuantityFormatter(for: .internationalUnit()) @@ -159,7 +158,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteCarbEntryNotification(amountInGrams: Double) { let notification = UNMutableNotificationContent() @@ -180,7 +178,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteCarbEntryFailureNotification(for error: Error, amountInGrams: Double) { let notification = UNMutableNotificationContent() diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index b9f6c8c232..c8918a351d 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -11,6 +11,7 @@ import HealthKit import LoopKit import LoopKitUI +@MainActor class OnboardingManager { private let pluginManager: PluginManager private let bluetoothProvider: BluetoothProvider @@ -18,6 +19,7 @@ class OnboardingManager { private let statefulPluginManager: StatefulPluginManager private let servicesManager: ServicesManager private let loopDataManager: LoopDataManager + private let settingsManager: SettingsManager private let supportManager: SupportManager private weak var windowProvider: WindowProvider? private let userDefaults: UserDefaults @@ -43,6 +45,7 @@ class OnboardingManager { init(pluginManager: PluginManager, bluetoothProvider: BluetoothProvider, deviceDataManager: DeviceDataManager, + settingsManager: SettingsManager, statefulPluginManager: StatefulPluginManager, servicesManager: ServicesManager, loopDataManager: LoopDataManager, @@ -53,6 +56,7 @@ class OnboardingManager { self.pluginManager = pluginManager self.bluetoothProvider = bluetoothProvider self.deviceDataManager = deviceDataManager + self.settingsManager = settingsManager self.statefulPluginManager = statefulPluginManager self.servicesManager = servicesManager self.loopDataManager = loopDataManager @@ -62,9 +66,9 @@ class OnboardingManager { self.isSuspended = userDefaults.onboardingManagerIsSuspended - self.isComplete = userDefaults.onboardingManagerIsComplete && loopDataManager.therapySettings.isComplete + self.isComplete = userDefaults.onboardingManagerIsComplete && settingsManager.therapySettings.isComplete if !isComplete { - if loopDataManager.therapySettings.isComplete { + if settingsManager.therapySettings.isComplete { self.completedOnboardingIdentifiers = userDefaults.onboardingManagerCompletedOnboardingIdentifiers } if let activeOnboardingRawValue = userDefaults.onboardingManagerActiveOnboardingRawValue { @@ -255,12 +259,12 @@ extension OnboardingManager: OnboardingDelegate { func onboarding(_ onboarding: OnboardingUI, hasNewTherapySettings therapySettings: TherapySettings) { guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } - loopDataManager.therapySettings = therapySettings + settingsManager.therapySettings = therapySettings } func onboarding(_ onboarding: OnboardingUI, hasNewDosingEnabled dosingEnabled: Bool) { guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } - loopDataManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.dosingEnabled = dosingEnabled } } @@ -395,6 +399,11 @@ extension OnboardingManager: PumpManagerProvider { guard let pumpManager = deviceDataManager.pumpManager else { return deviceDataManager.setupPumpManager(withIdentifier: identifier, initialSettings: settings, prefersToSkipUserInteraction: prefersToSkipUserInteraction) } + + guard let pumpManager = pumpManager as? PumpManagerUI else { + return .failure(OnboardingError.invalidState) + } + guard pumpManager.pluginIdentifier == identifier else { return .failure(OnboardingError.invalidState) } @@ -442,7 +451,7 @@ extension OnboardingManager: ServiceProvider { extension OnboardingManager: TherapySettingsProvider { var onboardingTherapySettings: TherapySettings { - return loopDataManager.therapySettings + return settingsManager.therapySettings } } diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 296e3befa9..9f89aeb1a8 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -9,6 +9,7 @@ import os.log import Foundation import LoopKit +import UIKit enum RemoteDataType: String, CaseIterable { case alert = "Alert" @@ -37,6 +38,7 @@ struct UploadTaskKey: Hashable { } } +@MainActor final class RemoteDataServicesManager { public typealias RawState = [String: Any] @@ -126,7 +128,7 @@ final class RemoteDataServicesManager { private let doseStore: DoseStore - private let dosingDecisionStore: DosingDecisionStore + private let dosingDecisionStore: DosingDecisionStoreProtocol private let glucoseStore: GlucoseStore @@ -142,7 +144,7 @@ final class RemoteDataServicesManager { alertStore: AlertStore, carbStore: CarbStore, doseStore: DoseStore, - dosingDecisionStore: DosingDecisionStore, + dosingDecisionStore: DosingDecisionStoreProtocol, glucoseStore: GlucoseStore, cgmEventStore: CgmEventStore, settingsStore: SettingsStore, @@ -618,8 +620,10 @@ extension RemoteDataServicesManager { } } +extension RemoteDataServicesManager: UploadEventListener { } + protocol RemoteDataServicesManagerDelegate: AnyObject { - var shouldSyncToRemoteService: Bool {get} + var shouldSyncToRemoteService: Bool { get } } diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 2393ceb073..78867235b3 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -12,6 +12,7 @@ import LoopKitUI import LoopCore import Combine +@MainActor class ServicesManager { private let pluginManager: PluginManager @@ -121,6 +122,10 @@ class ServicesManager { return servicesLock.withLock { services } } + public func getServices() -> [Service] { + return servicesLock.withLock { services } + } + public func addActiveService(_ service: Service) { servicesLock.withLock { service.serviceDelegate = self @@ -213,10 +218,10 @@ class ServicesManager { private func beginBackgroundTask(name: String) async -> UIBackgroundTaskIdentifier? { var backgroundTask: UIBackgroundTaskIdentifier? - backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: name) { + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: name) { guard let backgroundTask = backgroundTask else {return} Task { - await UIApplication.shared.endBackgroundTask(backgroundTask) + UIApplication.shared.endBackgroundTask(backgroundTask) } self.log.error("Background Task Expired: %{public}@", name) @@ -227,7 +232,7 @@ class ServicesManager { private func endBackgroundTask(_ backgroundTask: UIBackgroundTaskIdentifier?) async { guard let backgroundTask else {return} - await UIApplication.shared.endBackgroundTask(backgroundTask) + UIApplication.shared.endBackgroundTask(backgroundTask) } } @@ -320,11 +325,11 @@ extension ServicesManager: ServiceDelegate { func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { do { try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) - await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) + NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) await remoteDataServicesManager.triggerUpload(for: .carb) analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) } catch { - await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) + NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) throw error } } @@ -345,11 +350,11 @@ extension ServicesManager: ServiceDelegate { } try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) - await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) + NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) await remoteDataServicesManager.triggerUpload(for: .dose) analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) } catch { - await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) + NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) throw error } } @@ -375,14 +380,20 @@ extension ServicesManager: ServiceDelegate { extension ServicesManager: AlertIssuer { func issueAlert(_ alert: Alert) { - alertManager.issueAlert(alert) + Task { @MainActor in + alertManager.issueAlert(alert) + } } func retractAlert(identifier: Alert.Identifier) { - alertManager.retractAlert(identifier: identifier) + Task { @MainActor in + alertManager.retractAlert(identifier: identifier) + } } } +extension ServicesManager: ActiveServicesProvider { } + // MARK: - ServiceOnboardingDelegate extension ServicesManager: ServiceOnboardingDelegate { diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index e3fdb60bf7..cbac8f6b2d 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -22,19 +22,22 @@ protocol DeviceStatusProvider { var cgmManagerStatus: CGMManagerStatus? { get } } +@MainActor class SettingsManager { let settingsStore: SettingsStore var remoteDataServicesManager: RemoteDataServicesManager? + var analyticsServicesManager: AnalyticsServicesManager? + var deviceStatusProvider: DeviceStatusProvider? var alertMuter: AlertMuter var displayGlucosePreference: DisplayGlucosePreference? - public var latestSettings: StoredSettings + public var settings: StoredSettings private var remoteNotificationRegistrationResult: Swift.Result? @@ -42,18 +45,26 @@ class SettingsManager { private let log = OSLog(category: "SettingsManager") - init(cacheStore: PersistenceController, expireAfter: TimeInterval, alertMuter: AlertMuter) + private var loopSettingsLock = UnfairLock() + + @Published private(set) var dosingEnabled: Bool + + init(cacheStore: PersistenceController, expireAfter: TimeInterval, alertMuter: AlertMuter, analyticsServicesManager: AnalyticsServicesManager? = nil) { + self.analyticsServicesManager = analyticsServicesManager + settingsStore = SettingsStore(store: cacheStore, expireAfter: expireAfter) self.alertMuter = alertMuter if let storedSettings = settingsStore.latestSettings { - latestSettings = storedSettings + settings = storedSettings } else { - log.default("SettingsStore has no latestSettings: initializing empty StoredSettings.") - latestSettings = StoredSettings() + log.default("SettingsStore has no settings: initializing empty StoredSettings.") + settings = StoredSettings() } + dosingEnabled = settings.dosingEnabled + settingsStore.delegate = self // Migrate old settings from UserDefaults @@ -69,20 +80,9 @@ class SettingsManager { UserDefaults.appGroup?.removeLegacyLoopSettings() } - NotificationCenter.default - .publisher(for: .LoopDataUpdated) - .receive(on: DispatchQueue.main) - .sink { [weak self] note in - let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue - if case .preferences = LoopDataManager.LoopUpdateContext(rawValue: context), let loopDataManager = note.object as? LoopDataManager { - self?.storeSettings(newLoopSettings: loopDataManager.settings) - } - } - .store(in: &cancellables) - self.alertMuter.$configuration .sink { [weak self] alertMuterConfiguration in - guard var notificationSettings = self?.latestSettings.notificationSettings else { return } + guard var notificationSettings = self?.settings.notificationSettings else { return } let newTemporaryMuteAlertsSetting = NotificationSettings.TemporaryMuteAlertSetting(enabled: alertMuterConfiguration.shouldMute, duration: alertMuterConfiguration.duration) if notificationSettings.temporaryMuteAlertsSetting != newTemporaryMuteAlertsSetting { notificationSettings.temporaryMuteAlertsSetting = newTemporaryMuteAlertsSetting @@ -95,21 +95,19 @@ class SettingsManager { var loopSettings: LoopSettings { get { return LoopSettings( - dosingEnabled: latestSettings.dosingEnabled, - glucoseTargetRangeSchedule: latestSettings.glucoseTargetRangeSchedule, - insulinSensitivitySchedule: latestSettings.insulinSensitivitySchedule, - basalRateSchedule: latestSettings.basalRateSchedule, - carbRatioSchedule: latestSettings.carbRatioSchedule, - preMealTargetRange: latestSettings.preMealTargetRange, - legacyWorkoutTargetRange: latestSettings.workoutTargetRange, - overridePresets: latestSettings.overridePresets, - scheduleOverride: latestSettings.scheduleOverride, - preMealOverride: latestSettings.preMealOverride, - maximumBasalRatePerHour: latestSettings.maximumBasalRatePerHour, - maximumBolus: latestSettings.maximumBolus, - suspendThreshold: latestSettings.suspendThreshold, - automaticDosingStrategy: latestSettings.automaticDosingStrategy, - defaultRapidActingModel: latestSettings.defaultRapidActingModel?.presetForRapidActingInsulin) + dosingEnabled: settings.dosingEnabled, + glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, + insulinSensitivitySchedule: settings.insulinSensitivitySchedule, + basalRateSchedule: settings.basalRateSchedule, + carbRatioSchedule: settings.carbRatioSchedule, + preMealTargetRange: settings.preMealTargetRange, + legacyWorkoutTargetRange: settings.workoutTargetRange, + overridePresets: settings.overridePresets, + maximumBasalRatePerHour: settings.maximumBasalRatePerHour, + maximumBolus: settings.maximumBolus, + suspendThreshold: settings.suspendThreshold, + automaticDosingStrategy: settings.automaticDosingStrategy, + defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin) } } @@ -124,8 +122,6 @@ class SettingsManager { preMealTargetRange: newLoopSettings.preMealTargetRange, workoutTargetRange: newLoopSettings.legacyWorkoutTargetRange, overridePresets: newLoopSettings.overridePresets, - scheduleOverride: newLoopSettings.scheduleOverride, - preMealOverride: newLoopSettings.preMealOverride, maximumBasalRatePerHour: newLoopSettings.maximumBasalRatePerHour, maximumBolus: newLoopSettings.maximumBolus, suspendThreshold: newLoopSettings.suspendThreshold, @@ -153,40 +149,98 @@ class SettingsManager { let mergedSettings = mergeSettings(newLoopSettings: newLoopSettings, notificationSettings: notificationSettings, deviceToken: deviceTokenStr) - if latestSettings == mergedSettings { + guard settings != mergedSettings else { // Skipping unchanged settings store return } - latestSettings = mergedSettings + settings = mergedSettings if remoteNotificationRegistrationResult == nil && FeatureFlags.remoteCommandsEnabled { // remote notification registration not finished return } - if latestSettings.insulinSensitivitySchedule == nil { + if settings.insulinSensitivitySchedule == nil { log.default("Saving settings with no ISF schedule.") } - settingsStore.storeSettings(latestSettings) { error in + settingsStore.storeSettings(settings) { error in if let error = error { self.log.error("Error storing settings: %{public}@", error.localizedDescription) } } } + /// Sets a new time zone for a the schedule-based settings + /// + /// - Parameter timeZone: The time zone + func setScheduleTimeZone(_ timeZone: TimeZone) { + self.mutateLoopSettings { settings in + settings.basalRateSchedule?.timeZone = timeZone + settings.carbRatioSchedule?.timeZone = timeZone + settings.insulinSensitivitySchedule?.timeZone = timeZone + settings.glucoseTargetRangeSchedule?.timeZone = timeZone + } + } + + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + LoopDataManager.LoopUpdateContextKey: context.rawValue + ] + ) + } + + func mutateLoopSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + loopSettingsLock.withLock { + let oldValue = loopSettings + var newValue = oldValue + changes(&newValue) + + guard oldValue != newValue else { + return + } + + storeSettings(newLoopSettings: newValue) + + if newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule { + analyticsServicesManager?.didChangeInsulinSensitivitySchedule() + } + + if newValue.basalRateSchedule != oldValue.basalRateSchedule { + if let newValue = newValue.basalRateSchedule, let oldValue = oldValue.basalRateSchedule, newValue.items != oldValue.items { + analyticsServicesManager?.didChangeBasalRateSchedule() + } + } + + if newValue.carbRatioSchedule != oldValue.carbRatioSchedule { + analyticsServicesManager?.didChangeCarbRatioSchedule() + } + + if newValue.defaultRapidActingModel != oldValue.defaultRapidActingModel { + analyticsServicesManager?.didChangeInsulinModel() + } + + if newValue.dosingEnabled != oldValue.dosingEnabled { + self.dosingEnabled = newValue.dosingEnabled + } + } + notify(forChange: .preferences) + } + func storeSettingsCheckingNotificationPermissions() { UNUserNotificationCenter.current().getNotificationSettings() { notificationSettings in DispatchQueue.main.async { - guard let latestSettings = self.settingsStore.latestSettings else { + guard let settings = self.settingsStore.latestSettings else { return } let temporaryMuteAlertSetting = NotificationSettings.TemporaryMuteAlertSetting(enabled: self.alertMuter.configuration.shouldMute, duration: self.alertMuter.configuration.duration) let notificationSettings = NotificationSettings(notificationSettings, temporaryMuteAlertsSetting: temporaryMuteAlertSetting) - if notificationSettings != latestSettings.notificationSettings + if notificationSettings != settings.notificationSettings { self.storeSettings(notificationSettings: notificationSettings) } @@ -206,8 +260,77 @@ class SettingsManager { func purgeHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { settingsStore.purgeHistoricalSettingsObjects(completion: completion) } + + // MARK: Historical queries + + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsStore.getBasalHistory(startDate: startDate, endDate: endDate) + } + + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsStore.getCarbRatioHistory(startDate: startDate, endDate: endDate) + } + + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsStore.getInsulinSensitivityHistory(startDate: startDate, endDate: endDate) + } + + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] { + try await settingsStore.getTargetRangeHistory(startDate: startDate, endDate: endDate) + } + + func getDosingLimits(at date: Date) async throws -> DosingLimits { + try await settingsStore.getDosingLimits(at: date) + } + } +extension SettingsManager { + public var therapySettings: TherapySettings { + get { + let settings = self.settings + return TherapySettings(glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, + correctionRangeOverrides: CorrectionRangeOverrides(preMeal: settings.preMealTargetRange, workout: settings.workoutTargetRange), + overridePresets: settings.overridePresets, + maximumBasalRatePerHour: settings.maximumBasalRatePerHour, + maximumBolus: settings.maximumBolus, + suspendThreshold: settings.suspendThreshold, + insulinSensitivitySchedule: settings.insulinSensitivitySchedule, + carbRatioSchedule: settings.carbRatioSchedule, + basalRateSchedule: settings.basalRateSchedule, + defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin) + } + + set { + mutateLoopSettings { settings in + settings.defaultRapidActingModel = newValue.defaultRapidActingModel + settings.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule + settings.carbRatioSchedule = newValue.carbRatioSchedule + settings.basalRateSchedule = newValue.basalRateSchedule + settings.glucoseTargetRangeSchedule = newValue.glucoseTargetRangeSchedule + settings.preMealTargetRange = newValue.correctionRangeOverrides?.preMeal + settings.legacyWorkoutTargetRange = newValue.correctionRangeOverrides?.workout + settings.suspendThreshold = newValue.suspendThreshold + settings.maximumBolus = newValue.maximumBolus + settings.maximumBasalRatePerHour = newValue.maximumBasalRatePerHour + settings.overridePresets = newValue.overridePresets ?? [] + } + } + } +} + +protocol SettingsProvider { + var settings: StoredSettings { get } + + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] + func getDosingLimits(at date: Date) async throws -> DosingLimits +} + +extension SettingsManager: SettingsProvider {} + // MARK: - SettingsStoreDelegate extension SettingsManager: SettingsStoreDelegate { func settingsStoreHasUpdatedSettingsData(_ settingsStore: SettingsStore) { @@ -247,3 +370,5 @@ private extension NotificationSettings { ) } } + + diff --git a/Loop/Managers/StatefulPluginManager.swift b/Loop/Managers/StatefulPluginManager.swift index 22fc035b0c..9dfa3f0ede 100644 --- a/Loop/Managers/StatefulPluginManager.swift +++ b/Loop/Managers/StatefulPluginManager.swift @@ -11,12 +11,12 @@ import LoopKitUI import LoopCore import Combine +@MainActor class StatefulPluginManager: StatefulPluggableProvider { private let pluginManager: PluginManager private let servicesManager: ServicesManager - private var statefulPlugins = [StatefulPluggable]() private let statefulPluginLock = UnfairLock() @@ -123,3 +123,5 @@ extension StatefulPluginManager: StatefulPluggableDelegate { removeActiveStatefulPlugin(plugin) } } + +extension StatefulPluginManager: ActiveStatefulPluginsProvider { } diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index a7ffef2e5e..bf41a4d3fd 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -10,47 +10,17 @@ import LoopKit import HealthKit protocol CarbStoreProtocol: AnyObject { - - var preferredUnit: HKUnit! { get } - - var delegate: CarbStoreDelegate? { get set } - - // MARK: Settings - var carbRatioSchedule: CarbRatioSchedule? { get set } - - var insulinSensitivitySchedule: InsulinSensitivitySchedule? { get set } - - var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { get } - - var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { get } - - var maximumAbsorptionTimeInterval: TimeInterval { get } - - var delta: TimeInterval { get } - + + func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] + + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry) async throws -> StoredCarbEntry + + func addCarbEntry(_ entry: NewCarbEntry) async throws -> StoredCarbEntry + + func deleteCarbEntry(_ oldEntry: StoredCarbEntry) async throws -> Bool + var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes { get } - - // MARK: Data Management - func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbStatus]>) -> Void) - - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) - - // MARK: COB & Effect Generation - func getGlucoseEffects(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity], completion: @escaping(_ result: CarbStoreResult<(entries: [StoredCarbEntry], effects: [GlucoseEffect])>) -> Void) - - func glucoseEffects(of samples: [Sample], startingAt start: Date, endingAt end: Date?, effectVelocities: [GlucoseEffectVelocity]) throws -> [GlucoseEffect] - - func getCarbsOnBoardValues(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbValue]>) -> Void) - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) + } extension CarbStore: CarbStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift index dd21ea2a1f..3bd2bcbdbb 100644 --- a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift @@ -10,52 +10,15 @@ import LoopKit import HealthKit protocol DoseStoreProtocol: AnyObject { - // MARK: settings - var basalProfile: LoopKit.BasalRateSchedule? { get set } + func getDoses(start: Date?, end: Date?) async throws -> [DoseEntry] - var insulinModelProvider: InsulinModelProvider { get set } - - var longestEffectDuration: TimeInterval { get set } + func addDoses(_ doses: [DoseEntry], from device: HKDevice?) async throws - var insulinSensitivitySchedule: LoopKit.InsulinSensitivitySchedule? { get set } - - var basalProfileApplyingOverrideHistory: BasalRateSchedule? { get } - - // MARK: store information - var lastReservoirValue: LoopKit.ReservoirValue? { get } - - var lastAddedPumpData: Date { get } - - var delegate: DoseStoreDelegate? { get set } - - var device: HKDevice? { get set } - - var pumpRecordsBasalProfileStartEvents: Bool { get set } - - var pumpEventQueryAfterDate: Date { get } - - // MARK: dose management - func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, replacePendingEvents: Bool, completion: @escaping (_ error: DoseStore.DoseStoreError?) -> Void) + var lastReservoirValue: ReservoirValue? { get } + + func getTotalUnitsDelivered(since startDate: Date) async throws -> InsulinValue - func addReservoirValue(_ unitVolume: Double, at date: Date, completion: @escaping (_ value: ReservoirValue?, _ previousValue: ReservoirValue?, _ areStoredValuesContinuous: Bool, _ error: DoseStore.DoseStoreError?) -> Void) - - func getNormalizedDoseEntries(start: Date, end: Date?, completion: @escaping (_ result: DoseStoreResult<[DoseEntry]>) -> Void) - - func executePumpEventQuery(fromQueryAnchor queryAnchor: DoseStore.QueryAnchor?, limit: Int, completion: @escaping (DoseStore.PumpEventQueryResult) -> Void) - - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) - - func addDoses(_ doses: [DoseEntry], from device: HKDevice?, completion: @escaping (_ error: Error?) -> Void) - - // MARK: IOB and insulin effect - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - - func getGlucoseEffects(start: Date, end: Date?, basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) - - func getInsulinOnBoardValues(start: Date, end: Date? , basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[InsulinValue]>) -> Void) - - func getTotalUnitsDelivered(since startDate: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - + var lastAddedPumpData: Date { get } } -extension DoseStore: DoseStoreProtocol { } +extension DoseStore: DoseStoreProtocol {} diff --git a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift index 6ff38926f9..79ba9ca090 100644 --- a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift @@ -8,8 +8,12 @@ import LoopKit -protocol DosingDecisionStoreProtocol: AnyObject { - func storeDosingDecision(_ dosingDecision: StoredDosingDecision, completion: @escaping () -> Void) +protocol DosingDecisionStoreProtocol: CriticalEventLog { + var delegate: DosingDecisionStoreDelegate? { get set } + + func storeDosingDecision(_ dosingDecision: StoredDosingDecision) async + + func executeDosingDecisionQuery(fromQueryAnchor queryAnchor: DosingDecisionStore.QueryAnchor?, limit: Int, completion: @escaping (DosingDecisionStore.DosingDecisionQueryResult) -> Void) } extension DosingDecisionStore: DosingDecisionStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift index adde73c4c7..8e15e5145f 100644 --- a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift @@ -10,30 +10,8 @@ import LoopKit import HealthKit protocol GlucoseStoreProtocol: AnyObject { - - var latestGlucose: GlucoseSampleValue? { get } - - var delegate: GlucoseStoreDelegate? { get set } - - var managedDataInterval: TimeInterval? { get set } - - // MARK: Sample Management - func addGlucoseSamples(_ samples: [NewGlucoseSample], completion: @escaping (_ result: Result<[StoredGlucoseSample], Error>) -> Void) - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ result: Result<[StoredGlucoseSample], Error>) -> Void) - - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) - - func purgeAllGlucoseSamples(healthKitPredicate: NSPredicate, completion: @escaping (Error?) -> Void) - - func executeGlucoseQuery(fromQueryAnchor queryAnchor: GlucoseStore.QueryAnchor?, limit: Int, completion: @escaping (GlucoseStore.GlucoseQueryResult) -> Void) - - // MARK: Effect Calculation - func getRecentMomentumEffect(for date: Date?, _ completion: @escaping (_ result: Result<[GlucoseEffect], Error>) -> Void) - - func getCounteractionEffects(start: Date, end: Date?, to effects: [GlucoseEffect], _ completion: @escaping (_ effects: Result<[GlucoseEffectVelocity], Error>) -> Void) - - func counteractionEffects(for samples: [Sample], to effects: [GlucoseEffect]) -> [GlucoseEffectVelocity] + func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] + func addGlucoseSamples(_ samples: [NewGlucoseSample]) async throws -> [StoredGlucoseSample] } extension GlucoseStore: GlucoseStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift b/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift index 72ead59cbc..f220ce00d6 100644 --- a/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift +++ b/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift @@ -9,7 +9,7 @@ import LoopKit protocol LatestStoredSettingsProvider: AnyObject { - var latestSettings: StoredSettings { get } + var settings: StoredSettings { get } } extension SettingsManager: LatestStoredSettingsProvider { } diff --git a/Loop/Managers/SupportManager.swift b/Loop/Managers/SupportManager.swift index 2111882e87..5e44909a8d 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -17,9 +17,10 @@ public protocol DeviceSupportDelegate { var pumpManagerStatus: LoopKit.PumpManagerStatus? { get } var cgmManagerStatus: LoopKit.CGMManagerStatus? { get } - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) + func generateDiagnosticReport() async -> String } +@MainActor public final class SupportManager { private lazy var log = DiagnosticLog(category: "SupportManager") @@ -91,7 +92,7 @@ public final class SupportManager { } .store(in: &cancellables) - NotificationCenter.default.publisher(for: .LoopCompleted) + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .sink { [weak self] _ in self?.performCheck() } @@ -234,8 +235,8 @@ extension SupportManager: SupportUIDelegate { return Bundle.main.localizedNameAndVersion } - public func generateIssueReport(completion: @escaping (String) -> Void) { - deviceSupportDelegate.generateDiagnosticReport(completion) + public func generateIssueReport() async -> String { + await deviceSupportDelegate.generateDiagnosticReport() } public func issueAlert(_ alert: LoopKit.Alert) { diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift new file mode 100644 index 0000000000..c90463885d --- /dev/null +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -0,0 +1,283 @@ +// +// TemporaryPresetsManager.swift +// Loop +// +// Created by Pete Schwamb on 11/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import os.log +import LoopCore +import HealthKit + +protocol PresetActivationObserver: AnyObject { + func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) + func presetDeactivated(context: TemporaryScheduleOverride.Context) +} + +class TemporaryPresetsManager { + + private let log = OSLog(category: "TemporaryPresetsManager") + + private var settingsProvider: SettingsProvider + + var overrideHistory = UserDefaults.appGroup?.overrideHistory ?? TemporaryScheduleOverrideHistory.init() + + private var presetActivationObservers: [PresetActivationObserver] = [] + + private var overrideIntentObserver: NSKeyValueObservation? = nil + + init(settingsProvider: SettingsProvider) { + self.settingsProvider = settingsProvider + + self.overrideHistory.relevantTimeWindow = LoopCoreConstants.defaultCarbAbsorptionTimes.slow * 2 + + scheduleOverride = overrideHistory.activeOverride(at: Date()) + + // TODO: Pre-meal is not stored in overrideHistory yet. https://tidepool.atlassian.net/browse/LOOP-4759 + //preMealOverride = overrideHistory.preMealOverride + + overrideIntentObserver = UserDefaults.appGroup?.observe( + \.intentExtensionOverrideToSet, + options: [.new], + changeHandler: + { [weak self] (defaults, change) in + self?.handleIntentOverrideAction(default: defaults, change: change) + } + ) + } + + private func handleIntentOverrideAction(default: UserDefaults, change: NSKeyValueObservedChange) { + guard let name = change.newValue??.lowercased(), + let appGroup = UserDefaults.appGroup else + { + return + } + + guard let preset = settingsProvider.settings.overridePresets.first(where: {$0.name.lowercased() == name}) else + { + log.error("Override Intent: Unable to find override named '%s'", String(describing: name)) + return + } + + log.default("Override Intent: setting override named '%s'", String(describing: name)) + scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) + + // Remove the override from UserDefaults so we don't set it multiple times + appGroup.intentExtensionOverrideToSet = nil + } + + public func addTemporaryPresetObserver(_ observer: PresetActivationObserver) { + presetActivationObservers.append(observer) + } + + public var scheduleOverride: TemporaryScheduleOverride? { + didSet { + guard oldValue != scheduleOverride else { + return + } + + if let newValue = scheduleOverride, newValue.context == .preMeal { + preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") + } + + if scheduleOverride != oldValue { + overrideHistory.recordOverride(scheduleOverride) + + if let oldPreset = oldValue { + for observer in self.presetActivationObservers { + observer.presetDeactivated(context: oldPreset.context) + } + } + if let newPreset = scheduleOverride { + for observer in self.presetActivationObservers { + observer.presetActivated(context: newPreset.context, duration: newPreset.duration) + } + } + } + + if scheduleOverride?.context == .legacyWorkout { + preMealOverride = nil + } + + notify(forChange: .preferences) + } + } + + public var preMealOverride: TemporaryScheduleOverride? { + didSet { + guard oldValue != preMealOverride else { + return + } + + if let newValue = preMealOverride, newValue.context != .preMeal || newValue.settings.insulinNeedsScaleFactor != nil { + preconditionFailure("The `preMealOverride` field should be used only for a pre-meal target range override") + } + + if preMealOverride != nil, scheduleOverride?.context == .legacyWorkout { + scheduleOverride = nil + } + + notify(forChange: .preferences) + } + } + + public var isScheduleOverrideInfiniteWorkout: Bool { + guard let scheduleOverride = scheduleOverride else { return false } + return scheduleOverride.context == .legacyWorkout && scheduleOverride.duration.isInfinite + } + + public func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { + + guard let glucoseTargetRangeSchedule = settingsProvider.settings.glucoseTargetRangeSchedule else { + return nil + } + + let preMealOverride = presumingMealEntry ? nil : self.preMealOverride + + let currentEffectiveOverride: TemporaryScheduleOverride? + switch (preMealOverride, scheduleOverride) { + case (let preMealOverride?, nil): + currentEffectiveOverride = preMealOverride + case (nil, let scheduleOverride?): + currentEffectiveOverride = scheduleOverride + case (let preMealOverride?, let scheduleOverride?): + currentEffectiveOverride = preMealOverride.scheduledEndDate > Date() + ? preMealOverride + : scheduleOverride + case (nil, nil): + currentEffectiveOverride = nil + } + + if let effectiveOverride = currentEffectiveOverride { + return glucoseTargetRangeSchedule.applyingOverride(effectiveOverride) + } else { + return glucoseTargetRangeSchedule + } + } + + public func scheduleOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public func nonPreMealOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public func preMealTargetEnabled(at date: Date = Date()) -> Bool { + return preMealOverride?.isActive(at: date) == true + } + + public func futureOverrideEnabled(relativeTo date: Date = Date()) -> Bool { + guard let scheduleOverride = scheduleOverride else { return false } + return scheduleOverride.startDate > date + } + + public func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { + preMealOverride = makePreMealOverride(beginningAt: date, for: duration) + } + + private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let preMealTargetRange = settingsProvider.settings.preMealTargetRange else { + return nil + } + return TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), + startDate: date, + duration: .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TimeInterval) { + scheduleOverride = legacyWorkoutOverride(beginningAt: date, for: duration) + preMealOverride = nil + } + + public func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let legacyWorkoutTargetRange = settingsProvider.settings.workoutTargetRange else { + return nil + } + + return TemporaryScheduleOverride( + context: .legacyWorkout, + settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), + startDate: date, + duration: duration.isInfinite ? .indefinite : .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { + if context == .preMeal { + preMealOverride = nil + return + } + + guard let scheduleOverride = scheduleOverride else { return } + + if let context = context { + if scheduleOverride.context == context { + self.scheduleOverride = nil + } + } else { + self.scheduleOverride = nil + } + } + + public var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { + if let basalSchedule = settingsProvider.settings.basalRateSchedule { + return overrideHistory.resolvingRecentBasalSchedule(basalSchedule) + } else { + return nil + } + } + + /// The insulin sensitivity schedule, applying recent overrides relative to the current moment in time. + public var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { + if let insulinSensitivitySchedule = settingsProvider.settings.insulinSensitivitySchedule { + return overrideHistory.resolvingRecentInsulinSensitivitySchedule(insulinSensitivitySchedule) + } else { + return nil + } + } + + public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { + if let carbRatioSchedule = carbRatioSchedule { + return overrideHistory.resolvingRecentCarbRatioSchedule(carbRatioSchedule) + } else { + return nil + } + } + + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + LoopDataManager.LoopUpdateContextKey: context.rawValue + ] + ) + } + +} + +public protocol SettingsWithOverridesProvider { + var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { get } + var carbRatioSchedule: CarbRatioSchedule? { get } + var maximumBolus: Double? { get } +} + +extension TemporaryPresetsManager : SettingsWithOverridesProvider { + var carbRatioSchedule: LoopKit.CarbRatioSchedule? { + settingsProvider.settings.carbRatioSchedule + } + + var maximumBolus: Double? { + settingsProvider.settings.maximumBolus + } +} diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index 94ee1e609a..eff494ebd2 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -14,30 +14,83 @@ protocol TestingScenariosManagerDelegate: AnyObject { func testingScenariosManager(_ manager: TestingScenariosManager, didUpdateScenarioURLs scenarioURLs: [URL]) } -protocol TestingScenariosManager: AnyObject { - var delegate: TestingScenariosManagerDelegate? { get set } - var activeScenarioURL: URL? { get } - var scenarioURLs: [URL] { get } - var supportManager: SupportManager { get } - func loadScenario(from url: URL, completion: @escaping (Error?) -> Void) - func loadScenario(from url: URL, advancedByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) - func loadScenario(from url: URL, rewoundByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) - func stepActiveScenarioBackward(completion: @escaping (Error?) -> Void) - func stepActiveScenarioForward(completion: @escaping (Error?) -> Void) -} +@MainActor +final class TestingScenariosManager: DirectoryObserver { -/// Describes the requirements necessary to implement TestingScenariosManager -protocol TestingScenariosManagerRequirements: TestingScenariosManager { - var deviceManager: DeviceDataManager { get } - var activeScenarioURL: URL? { get set } - var activeScenario: TestingScenario? { get set } - var log: DiagnosticLog { get } - func fetchScenario(from url: URL, completion: @escaping (Result) -> Void) -} + unowned let deviceManager: DeviceDataManager + unowned let supportManager: SupportManager + unowned let pluginManager: PluginManager + unowned let carbStore: CarbStore + unowned let settingsManager: SettingsManager + + let log = DiagnosticLog(category: "LocalTestingScenariosManager") + + private let fileManager = FileManager.default + private let scenariosSource: URL + private var directoryObservationToken: DirectoryObservationToken? + + private(set) var scenarioURLs: [URL] = [] + var activeScenarioURL: URL? + var activeScenario: TestingScenario? + + weak var delegate: TestingScenariosManagerDelegate? { + didSet { + delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) + } + } -// MARK: - TestingScenarioManager requirement implementations + init( + deviceManager: DeviceDataManager, + supportManager: SupportManager, + pluginManager: PluginManager, + carbStore: CarbStore, + settingsManager: SettingsManager + ) { + guard FeatureFlags.scenariosEnabled else { + fatalError("\(#function) should be invoked only when scenarios are enabled") + } + + self.deviceManager = deviceManager + self.supportManager = supportManager + self.pluginManager = pluginManager + self.carbStore = carbStore + self.settingsManager = settingsManager + self.scenariosSource = Bundle.main.bundleURL.appendingPathComponent("Scenarios") + + log.debug("Loading testing scenarios from %{public}@", scenariosSource.path) + if !fileManager.fileExists(atPath: scenariosSource.path) { + do { + try fileManager.createDirectory(at: scenariosSource, withIntermediateDirectories: false) + } catch { + log.error("%{public}@", String(describing: error)) + } + } + + directoryObservationToken = observeDirectory(at: scenariosSource) { [weak self] in + self?.reloadScenarioURLs() + } + reloadScenarioURLs() + } + + func fetchScenario(from url: URL, completion: (Result) -> Void) { + let result = Result(catching: { try TestingScenario(source: url) }) + completion(result) + } + + private func reloadScenarioURLs() { + do { + let scenarioURLs = try fileManager.contentsOfDirectory(at: scenariosSource, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" } + self.scenarioURLs = scenarioURLs + delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) + log.debug("Reloaded scenario URLs") + } catch { + log.error("%{public}@", String(describing: error)) + } + } +} -extension TestingScenariosManagerRequirements { +extension TestingScenariosManager { func loadScenario(from url: URL, completion: @escaping (Error?) -> Void) { loadScenario( from: url, @@ -110,7 +163,7 @@ private enum ScenarioLoadingError: LocalizedError { } } -extension TestingScenariosManagerRequirements { +extension TestingScenariosManager { private func loadScenario( from url: URL, loadingVia load: @escaping ( @@ -156,19 +209,9 @@ extension TestingScenariosManagerRequirements { } private func stepForward(_ scenario: TestingScenario, completion: @escaping (TestingScenario) -> Void) { - deviceManager.loopManager.getLoopState { _, state in - var scenario = scenario - guard let recommendedDose = state.recommendedAutomaticDose?.recommendation else { - scenario.stepForward(by: .minutes(5)) - completion(scenario) - return - } - - if let basalAdjustment = recommendedDose.basalAdjustment { - scenario.stepForward(unitsPerHour: basalAdjustment.unitsPerHour, duration: basalAdjustment.duration) - } - completion(scenario) - } + var scenario = scenario + scenario.stepForward(by: .minutes(5)) + completion(scenario) } private func loadScenario(_ scenario: TestingScenario, rewoundByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) { @@ -228,16 +271,15 @@ extension TestingScenariosManagerRequirements { return } - self.deviceManager.carbStore.addCarbEntries(instance.carbEntries) { result in - switch result { - case .success(_): + self.carbStore.addNewCarbEntries(entries: instance.carbEntries) { error in + if let error { + bail(with: error) + } else { testingPumpManager?.reservoirFillFraction = 1.0 testingPumpManager?.injectPumpEvents(instance.pumpEvents) testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) self.activeScenario = scenario completion(nil) - case .failure(let error): - bail(with: error) } } } @@ -253,9 +295,9 @@ extension TestingScenariosManagerRequirements { private func reloadPumpManager(withIdentifier pumpManagerIdentifier: String) -> TestingPumpManager { deviceManager.pumpManager = nil - guard let maximumBasalRate = deviceManager.loopManager.settings.maximumBasalRatePerHour, - let maxBolus = deviceManager.loopManager.settings.maximumBolus, - let basalSchedule = deviceManager.loopManager.settings.basalRateSchedule else + guard let maximumBasalRate = settingsManager.settings.maximumBasalRatePerHour, + let maxBolus = settingsManager.settings.maximumBolus, + let basalSchedule = settingsManager.settings.basalRateSchedule else { fatalError("Failed to reload pump manager. Missing initial settings") } @@ -311,7 +353,7 @@ extension TestingScenariosManagerRequirements { return } - self.deviceManager.carbStore.deleteAllCarbEntries() { error in + self.carbStore.deleteAllCarbEntries() { error in guard error == nil else { completion(error!) return @@ -326,37 +368,9 @@ extension TestingScenariosManagerRequirements { private extension CarbStore { - /// Errors if adding any individual entry errors. - func addCarbEntries(_ entries: [NewCarbEntry], completion: @escaping (CarbStoreResult<[StoredCarbEntry]>) -> Void) { - addCarbEntries(entries[...], completion: completion) - } - - private func addCarbEntries(_ entries: ArraySlice, completion: @escaping (CarbStoreResult<[StoredCarbEntry]>) -> Void) { - guard let entry = entries.first else { - completion(.success([])) - return - } - - addCarbEntry(entry) { individualResult in - switch individualResult { - case .success(let entry): - let remainder = entries.dropFirst() - self.addCarbEntries(remainder) { collectiveResult in - switch collectiveResult { - case .success(let entries): - completion(.success([entry] + entries)) - case .failure(let error): - completion(.failure(error)) - } - } - case .failure(let error): - completion(.failure(error)) - } - } - } /// Errors if getting carb entries errors, or if deleting any individual entry errors. - func deleteAllCarbEntries(completion: @escaping (CarbStoreError?) -> Void) { + func deleteAllCarbEntries(completion: @escaping (Error?) -> Void) { getCarbEntries() { result in switch result { case .success(let entries): @@ -367,7 +381,7 @@ private extension CarbStore { } } - private func deleteCarbEntries(_ entries: ArraySlice, completion: @escaping (CarbStoreError?) -> Void) { + private func deleteCarbEntries(_ entries: ArraySlice, completion: @escaping (Error?) -> Void) { guard let entry = entries.first else { completion(nil) return diff --git a/Loop/Managers/TrustedTimeChecker.swift b/Loop/Managers/TrustedTimeChecker.swift index 4d627b9f8f..ee2704a09f 100644 --- a/Loop/Managers/TrustedTimeChecker.swift +++ b/Loop/Managers/TrustedTimeChecker.swift @@ -9,8 +9,9 @@ import LoopKit import TrueTime import UIKit +import Combine -fileprivate extension UserDefaults { +extension UserDefaults { private enum Key: String { case detectedSystemTimeOffset = "com.loopkit.Loop.DetectedSystemTimeOffset" } @@ -25,7 +26,12 @@ fileprivate extension UserDefaults { } } -class TrustedTimeChecker { +protocol TrustedTimeChecker { + var detectedSystemTimeOffset: TimeInterval { get } +} + +@MainActor +class LoopTrustedTimeChecker: TrustedTimeChecker { private let acceptableTimeDelta = TimeInterval.seconds(120) // For NTP time checking @@ -33,9 +39,15 @@ class TrustedTimeChecker { private weak var alertManager: AlertManager? private lazy var log = DiagnosticLog(category: "TrustedTimeChecker") + lazy private var cancellables = Set() + + nonisolated var detectedSystemTimeOffset: TimeInterval { - didSet { - UserDefaults.standard.detectedSystemTimeOffset = detectedSystemTimeOffset + get { + UserDefaults.standard.detectedSystemTimeOffset ?? 0 + } + set { + UserDefaults.standard.detectedSystemTimeOffset = newValue } } @@ -48,11 +60,23 @@ class TrustedTimeChecker { #endif ntpClient.start() self.alertManager = alertManager - self.detectedSystemTimeOffset = UserDefaults.standard.detectedSystemTimeOffset ?? 0 - NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, - object: nil, queue: nil) { [weak self] _ in self?.checkTrustedTime() } - NotificationCenter.default.addObserver(forName: .LoopRunning, - object: nil, queue: nil) { [weak self] _ in self?.checkTrustedTime() } + + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .sink { [weak self] _ in + Task { + self?.checkTrustedTime() + } + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .LoopRunning) + .sink { [weak self] _ in + Task { + self?.checkTrustedTime() + } + } + .store(in: &cancellables) + checkTrustedTime() } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index bac60b71dc..dc0997b791 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -12,19 +12,41 @@ import WatchConnectivity import LoopKit import LoopCore +@MainActor final class WatchDataManager: NSObject { private unowned let deviceManager: DeviceDataManager - - init(deviceManager: DeviceDataManager, healthStore: HKHealthStore) { + private unowned let settingsManager: SettingsManager + private unowned let loopDataManager: LoopDataManager + private unowned let carbStore: CarbStore + private unowned let glucoseStore: GlucoseStore + private unowned let analyticsServicesManager: AnalyticsServicesManager? + private unowned let temporaryPresetsManager: TemporaryPresetsManager + + init( + deviceManager: DeviceDataManager, + settingsManager: SettingsManager, + loopDataManager: LoopDataManager, + carbStore: CarbStore, + glucoseStore: GlucoseStore, + analyticsServicesManager: AnalyticsServicesManager?, + temporaryPresetsManager: TemporaryPresetsManager, + healthStore: HKHealthStore + ) { self.deviceManager = deviceManager + self.settingsManager = settingsManager + self.loopDataManager = loopDataManager + self.carbStore = carbStore + self.glucoseStore = glucoseStore + self.analyticsServicesManager = analyticsServicesManager + self.temporaryPresetsManager = temporaryPresetsManager self.sleepStore = SleepStore(healthStore: healthStore) self.lastBedtimeQuery = UserDefaults.appGroup?.lastBedtimeQuery ?? .distantPast self.bedtime = UserDefaults.appGroup?.bedtime super.init() - NotificationCenter.default.addObserver(self, selector: #selector(updateWatch(_:)), name: .LoopDataUpdated, object: deviceManager.loopManager) + NotificationCenter.default.addObserver(self, selector: #selector(updateWatch(_:)), name: .LoopDataUpdated, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendSupportedBolusVolumesIfNeeded), name: .PumpManagerChanged, object: deviceManager) watchSession?.delegate = self @@ -41,7 +63,7 @@ final class WatchDataManager: NSObject { } }() - private var lastSentSettings: LoopSettings? + private var lastSentUserInfo: LoopSettingsUserInfo? private var lastSentBolusVolumes: [Double]? private var contextDosingDecisions: [Date: BolusDosingDecision] { @@ -100,8 +122,8 @@ final class WatchDataManager: NSObject { @objc private func updateWatch(_ notification: Notification) { guard - let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, - let updateContext = LoopDataManager.LoopUpdateContext(rawValue: rawUpdateContext) + let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopUpdateContext.RawValue, + let updateContext = LoopUpdateContext(rawValue: rawUpdateContext) else { return } @@ -120,7 +142,10 @@ final class WatchDataManager: NSObject { private lazy var minTrendUnit = HKUnit.milligramsPerDeciliter private func sendSettingsIfNeeded() { - let settings = deviceManager.loopManager.settings + let userInfo = LoopSettingsUserInfo( + loopSettings: settingsManager.loopSettings, + scheduleOverride: temporaryPresetsManager.scheduleOverride, + preMealOverride: temporaryPresetsManager.preMealOverride) guard let session = watchSession, session.isPaired, session.isWatchAppInstalled else { return @@ -131,12 +156,11 @@ final class WatchDataManager: NSObject { return } - guard settings != lastSentSettings else { - log.default("Skipping settings transfer due to no changes") + guard userInfo != lastSentUserInfo else { return } - lastSentSettings = settings + lastSentUserInfo = userInfo // clear any old pending settings transfers for transfer in session.outstandingUserInfoTransfers { @@ -146,9 +170,9 @@ final class WatchDataManager: NSObject { } } - let userInfo = LoopSettingsUserInfo(settings: settings).rawValue - log.default("Transferring LoopSettingsUserInfo: %{public}@", userInfo) - session.transferUserInfo(userInfo) + let rawUserInfo = userInfo.rawValue + log.default("Transferring LoopSettingsUserInfo: %{public}@", rawUserInfo) + session.transferUserInfo(rawUserInfo) } @objc private func sendSupportedBolusVolumesIfNeeded() { @@ -167,7 +191,6 @@ final class WatchDataManager: NSObject { } guard volumes != lastSentBolusVolumes else { - log.default("Skipping bolus volumes transfer due to no changes") return } @@ -187,7 +210,8 @@ final class WatchDataManager: NSObject { return } - createWatchContext { (context) in + Task { @MainActor in + let context = await createWatchContext() self.sendWatchContext(context) } } @@ -231,131 +255,116 @@ final class WatchDataManager: NSObject { } } - private func createWatchContext(recommendingBolusFor potentialCarbEntry: NewCarbEntry? = nil, _ completion: @escaping (_ context: WatchContext) -> Void) { + @MainActor + private func createWatchContext(recommendingBolusFor potentialCarbEntry: NewCarbEntry? = nil) async -> WatchContext { var dosingDecision = BolusDosingDecision(for: .watchBolus) - let loopManager = deviceManager.loopManager! - - let glucose = deviceManager.glucoseStore.latestGlucose - let reservoir = deviceManager.doseStore.lastReservoirValue + let glucose = loopDataManager.latestGlucose + let reservoir = loopDataManager.lastReservoirValue let basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState - loopManager.getLoopState { (manager, state) in - let updateGroup = DispatchGroup() + let (_, algoOutput) = loopDataManager.displayState.asTuple - let carbsOnBoard = state.carbsOnBoard + let carbsOnBoard = loopDataManager.activeCarbs - let context = WatchContext(glucose: glucose, glucoseUnit: self.deviceManager.preferredGlucoseUnit) - context.reservoir = reservoir?.unitVolume - context.loopLastRunDate = manager.lastLoopCompleted - context.cob = carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) + let context = WatchContext(glucose: glucose, glucoseUnit: self.deviceManager.displayGlucosePreference.unit) + context.reservoir = reservoir?.unitVolume + context.loopLastRunDate = loopDataManager.lastLoopCompleted + context.cob = carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) - if let glucoseDisplay = self.deviceManager.glucoseDisplay(for: glucose) { - context.glucoseTrend = glucoseDisplay.trendType - context.glucoseTrendRate = glucoseDisplay.trendRate - } + if let glucoseDisplay = self.deviceManager.glucoseDisplay(for: glucose) { + context.glucoseTrend = glucoseDisplay.trendType + context.glucoseTrendRate = glucoseDisplay.trendRate + } - dosingDecision.carbsOnBoard = carbsOnBoard + dosingDecision.carbsOnBoard = carbsOnBoard - context.cgmManagerState = self.deviceManager.cgmManager?.rawValue - - let settings = self.deviceManager.loopManager.settings + context.cgmManagerState = self.deviceManager.cgmManager?.rawValue - context.isClosedLoop = settings.dosingEnabled + let settings = self.settingsManager.loopSettings - context.potentialCarbEntry = potentialCarbEntry - if let recommendedBolus = try? state.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses) - { - context.recommendedBolusDose = recommendedBolus.amount - dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: recommendedBolus, - date: Date()) - } + context.isClosedLoop = settings.dosingEnabled - var historicalGlucose: [HistoricalGlucoseValue]? - if let glucose = glucose { - updateGroup.enter() - let historicalGlucoseStartDate = Date(timeIntervalSinceNow: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval) - self.deviceManager.glucoseStore.getGlucoseSamples(start: min(historicalGlucoseStartDate, glucose.startDate), end: nil) { (result) in - var sample: StoredGlucoseSample? - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - sample = nil - case .success(let samples): - sample = samples.last - historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } - } - context.glucose = sample?.quantity - context.glucoseDate = sample?.startDate - context.glucoseIsDisplayOnly = sample?.isDisplayOnly - context.glucoseWasUserEntered = sample?.wasUserEntered - context.glucoseSyncIdentifier = sample?.syncIdentifier - updateGroup.leave() - } - } + context.potentialCarbEntry = potentialCarbEntry - var insulinOnBoard: InsulinValue? - updateGroup.enter() - self.deviceManager.doseStore.insulinOnBoard(at: Date()) { (result) in - switch result { - case .success(let iobValue): - context.iob = iobValue.value - insulinOnBoard = iobValue - case .failure: - context.iob = nil - } - updateGroup.leave() - } + if let recommendedBolus = try? await loopDataManager.recommendManualBolus( + manualGlucoseSample: nil, + potentialCarbEntry: potentialCarbEntry, + originalCarbEntry: nil + ) { + context.recommendedBolusDose = recommendedBolus.amount + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate( + recommendation: recommendedBolus, + date: Date()) + } - _ = updateGroup.wait(timeout: .distantFuture) + var historicalGlucose: [HistoricalGlucoseValue]? - dosingDecision.historicalGlucose = historicalGlucose - dosingDecision.insulinOnBoard = insulinOnBoard + if let glucose = glucose { + var sample: StoredGlucoseSample? - if let basalDeliveryState = basalDeliveryState, - let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory, - let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) - { - context.lastNetTempBasalDose = netBasal.rate + let historicalGlucoseStartDate = Date(timeIntervalSinceNow: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval) + if let input = loopDataManager.displayState.input { + let start = min(historicalGlucoseStartDate, glucose.startDate) + let samples = input.glucoseHistory.filterDateRange(start, nil) + sample = samples.last + historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } } + context.glucose = sample?.quantity + context.glucoseDate = sample?.startDate + context.glucoseIsDisplayOnly = sample?.isDisplayOnly + context.glucoseWasUserEntered = sample?.wasUserEntered + context.glucoseSyncIdentifier = sample?.syncIdentifier + } - if let predictedGlucose = state.predictedGlucoseIncludingPendingInsulin { - // Drop the first element in predictedGlucose because it is the current glucose - let filteredPredictedGlucose = predictedGlucose.dropFirst() - if filteredPredictedGlucose.count > 0 { - context.predictedGlucose = WatchPredictedGlucose(values: Array(filteredPredictedGlucose)) - } - } + context.iob = loopDataManager.activeInsulin?.value - dosingDecision.predictedGlucose = state.predictedGlucoseIncludingPendingInsulin ?? state.predictedGlucose + dosingDecision.historicalGlucose = historicalGlucose + dosingDecision.insulinOnBoard = loopDataManager.activeInsulin - var preMealOverride = settings.preMealOverride - if preMealOverride?.hasFinished() == true { - preMealOverride = nil - } + if let basalDeliveryState = basalDeliveryState, + let basalSchedule = self.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory, + let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: self.settingsManager.settings.maximumBasalRatePerHour) + { + context.lastNetTempBasalDose = netBasal.rate + } - var scheduleOverride = settings.scheduleOverride - if scheduleOverride?.hasFinished() == true { - scheduleOverride = nil + if let predictedGlucose = algoOutput?.predictedGlucose { + // Drop the first element in predictedGlucose because it is the current glucose + let filteredPredictedGlucose = predictedGlucose.dropFirst() + if filteredPredictedGlucose.count > 0 { + context.predictedGlucose = WatchPredictedGlucose(values: Array(filteredPredictedGlucose)) } + } - dosingDecision.scheduleOverride = scheduleOverride + dosingDecision.predictedGlucose = algoOutput?.predictedGlucose - if scheduleOverride != nil || preMealOverride != nil { - dosingDecision.glucoseTargetRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) - } else { - dosingDecision.glucoseTargetRangeSchedule = settings.glucoseTargetRangeSchedule - } + var preMealOverride = self.temporaryPresetsManager.preMealOverride + if preMealOverride?.hasFinished() == true { + preMealOverride = nil + } - // Remove any expired context dosing decisions and add new - self.contextDosingDecisions = self.contextDosingDecisions.filter { (date, _) in date.timeIntervalSinceNow > self.contextDosingDecisionExpirationDuration } - self.contextDosingDecisions[context.creationDate] = dosingDecision + var scheduleOverride = self.temporaryPresetsManager.scheduleOverride + if scheduleOverride?.hasFinished() == true { + scheduleOverride = nil + } - completion(context) + dosingDecision.scheduleOverride = scheduleOverride + + if scheduleOverride != nil || preMealOverride != nil { + dosingDecision.glucoseTargetRangeSchedule = self.temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) + } else { + dosingDecision.glucoseTargetRangeSchedule = settings.glucoseTargetRangeSchedule } + + // Remove any expired context dosing decisions and add new + self.contextDosingDecisions = self.contextDosingDecisions.filter { (date, _) in date.timeIntervalSinceNow > self.contextDosingDecisionExpirationDuration } + self.contextDosingDecisions[context.creationDate] = dosingDecision + + return context } - private func addCarbEntryAndBolusFromWatchMessage(_ message: [String: Any]) { + private func addCarbEntryAndBolusFromWatchMessage(_ message: [String: Any]) async throws { guard let bolus = SetBolusUserInfo(rawValue: message as SetBolusUserInfo.RawValue) else { log.error("Could not enact bolus from from unknown message: %{public}@", String(describing: message)) return @@ -374,43 +383,30 @@ final class WatchDataManager: NSObject { dosingDecision = BolusDosingDecision(for: .watchBolus) // The user saved without waiting for recommendation (no bolus) } - func enactBolus() { - dosingDecision.manualBolusRequested = bolus.value - deviceManager.loopManager.storeManualBolusDosingDecision(dosingDecision, withDate: bolus.startDate) - - guard bolus.value > 0 else { - // Ensure active carbs is updated in the absence of a bolus - sendWatchContextIfNeeded() - return - } - - deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) { (error) in - if error == nil { - self.deviceManager.analyticsServicesManager.didBolus(source: "Watch", units: bolus.value) - } - - // When we've successfully started the bolus, send a new context with our new prediction - self.sendWatchContextIfNeeded() - - self.deviceManager.loopManager.updateRemoteRecommendation() - } - } - if let carbEntry = bolus.carbEntry { - deviceManager.loopManager.addCarbEntry(carbEntry) { (result) in - switch result { - case .success(let storedCarbEntry): - dosingDecision.carbEntry = storedCarbEntry - self.deviceManager.analyticsServicesManager.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) - enactBolus() - case .failure(let error): - self.log.error("%{public}@", String(describing: error)) - } - } + let storedCarbEntry = try await loopDataManager.addCarbEntry(carbEntry) + dosingDecision.carbEntry = storedCarbEntry + self.analyticsServicesManager?.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) } else { dosingDecision.carbEntry = nil - enactBolus() } + + dosingDecision.manualBolusRequested = bolus.value + await loopDataManager.storeManualBolusDosingDecision(dosingDecision, withDate: bolus.startDate) + + guard bolus.value > 0 else { + // Ensure active carbs is updated in the absence of a bolus + sendWatchContextIfNeeded() + return + } + + do { + try await deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) + self.analyticsServicesManager?.didBolus(source: "Watch", units: bolus.value) + } catch { } + + // When we've started the bolus, send a new context with our new prediction + self.sendWatchContextIfNeeded() } } @@ -420,7 +416,8 @@ extension WatchDataManager: WCSessionDelegate { switch message["name"] as? String { case PotentialCarbEntryUserInfo.name?: if let potentialCarbEntry = PotentialCarbEntryUserInfo(rawValue: message)?.carbEntry { - self.createWatchContext(recommendingBolusFor: potentialCarbEntry) { (context) in + Task { @MainActor in + let context = await createWatchContext(recommendingBolusFor: potentialCarbEntry) replyHandler(context.rawValue) } } else { @@ -429,31 +426,31 @@ extension WatchDataManager: WCSessionDelegate { } case SetBolusUserInfo.name?: // Add carbs if applicable; start the bolus and reply when it's successfully requested - addCarbEntryAndBolusFromWatchMessage(message) - + Task { @MainActor in + try await addCarbEntryAndBolusFromWatchMessage(message) + } // Reply immediately replyHandler([:]) + case LoopSettingsUserInfo.name?: - if let watchSettings = LoopSettingsUserInfo(rawValue: message)?.settings { + if let userInfo = LoopSettingsUserInfo(rawValue: message) { // So far we only support watch changes of temporary schedule overrides - var loopSettings = deviceManager.loopManager.settings - loopSettings.preMealOverride = watchSettings.preMealOverride - loopSettings.scheduleOverride = watchSettings.scheduleOverride + temporaryPresetsManager.preMealOverride = userInfo.preMealOverride + temporaryPresetsManager.scheduleOverride = userInfo.scheduleOverride // Prevent re-sending these updated settings back to the watch - lastSentSettings = loopSettings - deviceManager.loopManager.mutateSettings { settings in - settings = loopSettings - } + lastSentUserInfo?.preMealOverride = userInfo.preMealOverride + lastSentUserInfo?.scheduleOverride = userInfo.scheduleOverride } // Since target range affects recommended bolus, send back a new one - createWatchContext { (context) in + Task { @MainActor in + let context = await createWatchContext() replyHandler(context.rawValue) } case CarbBackfillRequestUserInfo.name?: if let userInfo = CarbBackfillRequestUserInfo(rawValue: message) { - deviceManager.carbStore.getSyncCarbObjects(start: userInfo.startDate) { (result) in + carbStore.getSyncCarbObjects(start: userInfo.startDate) { (result) in switch result { case .failure(let error): self.log.error("%{public}@", String(describing: error)) @@ -467,7 +464,7 @@ extension WatchDataManager: WCSessionDelegate { } case GlucoseBackfillRequestUserInfo.name?: if let userInfo = GlucoseBackfillRequestUserInfo(rawValue: message) { - deviceManager.glucoseStore.getSyncGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) { (result) in + glucoseStore.getSyncGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) { (result) in switch result { case .failure(let error): self.log.error("Failure getting sync glucose objects: %{public}@", String(describing: error)) @@ -480,8 +477,8 @@ extension WatchDataManager: WCSessionDelegate { replyHandler([:]) } case WatchContextRequestUserInfo.name?: - self.createWatchContext { (context) in - // Send back the updated prediction and recommended bolus + Task { @MainActor in + let context = await createWatchContext() replyHandler(context.rawValue) } default: @@ -517,12 +514,12 @@ extension WatchDataManager: WCSessionDelegate { // This might be useless, as userInfoTransfer.userInfo seems to be nil when error is non-nil. switch userInfoTransfer.userInfo["name"] as? String { case nil: - lastSentSettings = nil + lastSentUserInfo = nil sendSettingsIfNeeded() lastSentBolusVolumes = nil sendSupportedBolusVolumesIfNeeded() case LoopSettingsUserInfo.name: - lastSentSettings = nil + lastSentUserInfo = nil sendSettingsIfNeeded() case SupportedBolusVolumesUserInfo.name: lastSentBolusVolumes = nil @@ -538,7 +535,7 @@ extension WatchDataManager: WCSessionDelegate { } func sessionDidDeactivate(_ session: WCSession) { - lastSentSettings = nil + lastSentUserInfo = nil watchSession = WCSession.default watchSession?.delegate = self watchSession?.activate() @@ -555,7 +552,7 @@ extension WatchDataManager { override var debugDescription: String { var items = [ "## WatchDataManager", - "lastSentSettings: \(String(describing: lastSentSettings))", + "lastSentUserInfo: \(String(describing: lastSentUserInfo))", "lastComplicationContext: \(String(describing: lastComplicationContext))", "lastBedtimeQuery: \(String(describing: lastBedtimeQuery))", "bedtime: \(String(describing: bedtime))", diff --git a/Loop/Models/ApplicationFactorStrategy.swift b/Loop/Models/ApplicationFactorStrategy.swift index bf67935c4e..d3244ec1c2 100644 --- a/Loop/Models/ApplicationFactorStrategy.swift +++ b/Loop/Models/ApplicationFactorStrategy.swift @@ -14,7 +14,6 @@ import LoopCore protocol ApplicationFactorStrategy { func calculateDosingFactor( for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + correctionRange: ClosedRange ) -> Double } diff --git a/Loop/Models/ConstantApplicationFactorStrategy.swift b/Loop/Models/ConstantApplicationFactorStrategy.swift index 7489367cae..0ef8dc1d13 100644 --- a/Loop/Models/ConstantApplicationFactorStrategy.swift +++ b/Loop/Models/ConstantApplicationFactorStrategy.swift @@ -14,10 +14,9 @@ import LoopCore struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy { func calculateDosingFactor( for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + correctionRange: ClosedRange ) -> Double { // The original strategy uses a constant dosing factor. - return LoopAlgorithm.bolusPartialApplicationFactor + return LoopAlgorithm.defaultBolusPartialApplicationFactor } } diff --git a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift index 41caa3d773..7f03337011 100644 --- a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift +++ b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift @@ -21,12 +21,10 @@ struct GlucoseBasedApplicationFactorStrategy: ApplicationFactorStrategy { func calculateDosingFactor( for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + correctionRange: ClosedRange ) -> Double { // Calculate current glucose and lower bound target let currentGlucose = glucose.doubleValue(for: .milligramsPerDeciliter) - let correctionRange = correctionRangeSchedule.quantityRange(at: Date()) let lowerBoundTarget = correctionRange.lowerBound.doubleValue(for: .milligramsPerDeciliter) // Calculate minimum glucose sliding scale and scaling fraction diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index 015d5cc05c..6cb28cb5bd 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -17,7 +17,7 @@ enum ConfigurationErrorDetail: String, Codable { case insulinSensitivitySchedule case maximumBasalRatePerHour case maximumBolus - + func localized() -> String { switch self { case .pumpManager: @@ -45,7 +45,7 @@ enum MissingDataErrorDetail: String, Codable { case insulinEffect case activeInsulin case insulinEffectIncludingPendingInsulin - + var localizedDetail: String { switch self { case .glucose: @@ -105,6 +105,9 @@ enum LoopError: Error { // Pump Manager Error case pumpManagerError(PumpManagerError) + // Loop State loop in progress + case loopInProgress + // Some other error case unknownError(Error) } @@ -134,6 +137,8 @@ extension LoopError { return "pumpSuspended" case .pumpManagerError: return "pumpManagerError" + case .loopInProgress: + return "loopInProgress" case .unknownError: return "unknownError" } @@ -201,11 +206,13 @@ extension LoopError: LocalizedError { let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? "" return String(format: NSLocalizedString("Recommendation expired: %1$@ old", comment: "The error message when a recommendation has expired. (1: age of recommendation in minutes)"), minutes) case .pumpSuspended: - return NSLocalizedString("Pump Suspended. Automatic dosing is disabled.", comment: "The error message displayed for pumpSuspended errors.") + return NSLocalizedString("Pump Suspended. Automatic dosing is disabled.", comment: "The error message displayed for LoopError.pumpSuspended errors.") case .pumpManagerError(let pumpManagerError): return String(format: NSLocalizedString("Pump Manager Error: %1$@", comment: "The error message displayed for pump manager errors. (1: pump manager error)"), pumpManagerError.errorDescription!) + case .loopInProgress: + return NSLocalizedString("Loop is already looping.", comment: "The error message displayed for LoopError.loopInProgress errors.") case .unknownError(let error): - return String(format: NSLocalizedString("Unknown Error: %1$@", comment: "The error message displayed for unknown errors. (1: unknown error)"), error.localizedDescription) + return String(format: NSLocalizedString("Unknown Error: %1$@", comment: "The error message displayed for unknown LoopError errors. (1: unknown error)"), error.localizedDescription) } } } diff --git a/Loop/Models/PredictionInputEffect.swift b/Loop/Models/PredictionInputEffect.swift index 45fb5ea0c7..164db3a234 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -8,7 +8,7 @@ import Foundation import HealthKit - +import LoopKit struct PredictionInputEffect: OptionSet { let rawValue: Int @@ -55,3 +55,22 @@ struct PredictionInputEffect: OptionSet { } } } + +extension PredictionInputEffect { + var algorithmEffectOptions: AlgorithmEffectsOptions { + var rval = [AlgorithmEffectsOptions]() + if self.contains(.carbs) { + rval.append(.carbs) + } + if self.contains(.insulin) { + rval.append(.insulin) + } + if self.contains(.momentum) { + rval.append(.momentum) + } + if self.contains(.retrospection) { + rval.append(.retrospection) + } + return AlgorithmEffectsOptions(rval) + } +} diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index fc770192e9..378617b680 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -30,6 +30,10 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif var automaticDosingStatus: AutomaticDosingStatus! + var loopDataManager: LoopDataManager! + var carbStore: CarbStore! + var analyticsServicesManager: AnalyticsServicesManager! + override func viewDidLoad() { super.viewDidLoad() @@ -40,10 +44,10 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif let notificationCenter = NotificationCenter.default notificationObservers += [ - notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in - let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue + notificationCenter.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { [weak self] note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopUpdateContext.RawValue DispatchQueue.main.async { - switch LoopDataManager.LoopUpdateContext(rawValue: context) { + switch LoopUpdateContext(rawValue: context) { case .carbs?: self?.refreshContext.formUnion([.carbs, .glucose]) case .glucose?: @@ -53,7 +57,9 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } self?.refreshContext.update(with: .status) - self?.reloadData(animated: true) + Task { @MainActor in + await self?.reloadData(animated: true) + } } }, ] @@ -72,7 +78,9 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif tableView.rowHeight = UITableView.automaticDimension - reloadData(animated: false) + Task { @MainActor in + await reloadData(animated: false) + } } override func didReceiveMemoryWarning() { @@ -114,7 +122,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif refreshContext = RefreshContext.all } - override func reloadData(animated: Bool = false) { + override func reloadData(animated: Bool = false) async { guard active && !reloading && !self.refreshContext.isEmpty else { return } var currentContext = self.refreshContext var retryContext: Set = [] @@ -139,113 +147,73 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif charts.updateEndDate(chartStartDate.addingTimeInterval(.hours(totalHours+1))) // When there is no data, this allows presenting current hour + 1 let midnight = Calendar.current.startOfDay(for: Date()) - let listStart = min(midnight, chartStartDate, Date(timeIntervalSinceNow: -deviceManager.carbStore.maximumAbsorptionTimeInterval)) + let listStart = min(midnight, chartStartDate, Date(timeIntervalSinceNow: -carbStore.maximumAbsorptionTimeInterval)) - let reloadGroup = DispatchGroup() let shouldUpdateGlucose = currentContext.contains(.glucose) let shouldUpdateCarbs = currentContext.contains(.carbs) var carbEffects: [GlucoseEffect]? var carbStatuses: [CarbStatus]? var carbsOnBoard: CarbValue? - var carbTotal: CarbValue? var insulinCounteractionEffects: [GlucoseEffectVelocity]? - // TODO: Don't always assume currentContext.contains(.status) - reloadGroup.enter() - deviceManager.loopManager.getLoopState { (manager, state) in - if shouldUpdateGlucose || shouldUpdateCarbs { - let allInsulinCounteractionEffects = state.insulinCounteractionEffects - insulinCounteractionEffects = allInsulinCounteractionEffects.filterDateRange(chartStartDate, nil) - - reloadGroup.enter() - self.deviceManager.carbStore.getCarbStatus(start: listStart, end: nil, effectVelocities: allInsulinCounteractionEffects) { (result) in - switch result { - case .success(let status): - carbStatuses = status - carbsOnBoard = status.getClampedCarbsOnBoard() - case .failure(let error): - self.log.error("CarbStore failed to get carbStatus: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - } - - reloadGroup.leave() - } - - reloadGroup.enter() - self.deviceManager.carbStore.getGlucoseEffects(start: chartStartDate, end: nil, effectVelocities: insulinCounteractionEffects!) { (result) in - switch result { - case .success((_, let effects)): - carbEffects = effects - case .failure(let error): - carbEffects = [] - self.log.error("CarbStore failed to get glucoseEffects: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - } - reloadGroup.leave() - } + if shouldUpdateGlucose || shouldUpdateCarbs { + do { + let review = try await loopDataManager.fetchCarbAbsorptionReview(start: listStart, end: Date()) + insulinCounteractionEffects = review.effectsVelocities.filterDateRange(chartStartDate, nil) + carbStatuses = review.carbStatuses + carbsOnBoard = carbStatuses?.getClampedCarbsOnBoard() + carbEffects = review.carbEffects + } catch { + log.error("Failed to get carb absorption review: %{public}@", String(describing: error)) + retryContext.update(with: .carbs) } - - reloadGroup.leave() } if shouldUpdateCarbs { - reloadGroup.enter() - deviceManager.carbStore.getTotalCarbs(since: midnight) { (result) in - switch result { - case .success(let total): - carbTotal = total - case .failure(let error): - self.log.error("CarbStore failed to get total carbs: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - } - - reloadGroup.leave() + do { + self.carbTotal = try await carbStore.getTotalCarbs(since: midnight) + } catch { + log.error("CarbStore failed to get total carbs: %{public}@", String(describing: error)) + retryContext.update(with: .carbs) } } - reloadGroup.notify(queue: .main) { - if let carbEffects = carbEffects { - self.carbEffectChart.setCarbEffects(carbEffects) - self.charts.invalidateChart(atIndex: 0) - } - - if let insulinCounteractionEffects = insulinCounteractionEffects { - self.carbEffectChart.setInsulinCounteractionEffects(insulinCounteractionEffects) - self.charts.invalidateChart(atIndex: 0) - } - - self.charts.prerender() + if let carbEffects = carbEffects { + carbEffectChart.setCarbEffects(carbEffects) + charts.invalidateChart(atIndex: 0) + } - for case let cell as ChartTableViewCell in self.tableView.visibleCells { - cell.reloadChart() - } + if let insulinCounteractionEffects = insulinCounteractionEffects { + carbEffectChart.setInsulinCounteractionEffects(insulinCounteractionEffects) + charts.invalidateChart(atIndex: 0) + } - if shouldUpdateCarbs || shouldUpdateGlucose { - // Change to descending order for display - self.carbStatuses = carbStatuses?.reversed() ?? [] + charts.prerender() - if shouldUpdateCarbs { - self.carbTotal = carbTotal - } + for case let cell as ChartTableViewCell in self.tableView.visibleCells { + cell.reloadChart() + } - self.carbsOnBoard = carbsOnBoard + if shouldUpdateCarbs || shouldUpdateGlucose { + // Change to descending order for display + self.carbStatuses = carbStatuses?.reversed() ?? [] + self.carbsOnBoard = carbsOnBoard - self.tableView.reloadSections(IndexSet(integer: Section.entries.rawValue), with: .fade) - } + tableView.reloadSections(IndexSet(integer: Section.entries.rawValue), with: .fade) + } - if let cell = self.tableView.cellForRow(at: IndexPath(row: 0, section: Section.totals.rawValue)) as? HeaderValuesTableViewCell { - self.updateCell(cell) - } + if let cell = tableView.cellForRow(at: IndexPath(row: 0, section: Section.totals.rawValue)) as? HeaderValuesTableViewCell { + updateCell(cell) + } - self.reloading = false - let reloadNow = !self.refreshContext.isEmpty - self.refreshContext.formUnion(retryContext) + reloading = false + let reloadNow = !refreshContext.isEmpty + refreshContext.formUnion(retryContext) - // Trigger a reload if new context exists. - if reloadNow { - self.reloadData() - } + // Trigger a reload if new context exists. + if reloadNow { + await reloadData() } } @@ -450,16 +418,13 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif public override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { if editingStyle == .delete { let status = carbStatuses[indexPath.row] - deviceManager.loopManager.deleteCarbEntry(status.entry) { (result) -> Void in - DispatchQueue.main.async { - switch result { - case .success: - self.isEditing = false - break // Notification will trigger update - case .failure(let error): - self.refreshContext.update(with: .carbs) - self.present(UIAlertController(with: error), animated: true) - } + Task { @MainActor in + do { + try await loopDataManager.deleteCarbEntry(status.entry) + self.isEditing = false + } catch { + self.refreshContext.update(with: .carbs) + self.present(UIAlertController(with: error), animated: true) } } } @@ -495,7 +460,9 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif let originalCarbEntry = carbStatuses[indexPath.row].entry - let viewModel = CarbEntryViewModel(delegate: deviceManager, originalCarbEntry: originalCarbEntry) + let viewModel = CarbEntryViewModel(delegate: loopDataManager, originalCarbEntry: originalCarbEntry) + viewModel.analyticsServicesManager = analyticsServicesManager + viewModel.deliveryDelegate = deviceManager let carbEntryView = CarbEntryView(viewModel: viewModel) .environmentObject(deviceManager.displayGlucosePreference) .environment(\.dismissAction, carbEditWasCanceled) @@ -514,14 +481,16 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif // MARK: - Navigation @IBAction func presentCarbEntryScreen() { if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { - let viewModel = SimpleBolusViewModel(delegate: deviceManager, displayMealEntry: true) - let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter)) + let displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + let viewModel = SimpleBolusViewModel(delegate: loopDataManager, displayMealEntry: true, displayGlucosePreference: displayGlucosePreference) + let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(displayGlucosePreference) let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) let navigationWrapper = UINavigationController(rootViewController: hostingController) hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) } else { - let viewModel = CarbEntryViewModel(delegate: deviceManager) + let viewModel = CarbEntryViewModel(delegate: loopDataManager) + viewModel.analyticsServicesManager = analyticsServicesManager let carbEntryView = CarbEntryView(viewModel: viewModel) .environmentObject(deviceManager.displayGlucosePreference) let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) diff --git a/Loop/View Controllers/CommandResponseViewController.swift b/Loop/View Controllers/CommandResponseViewController.swift index e14c41c8a4..2bd93cc09f 100644 --- a/Loop/View Controllers/CommandResponseViewController.swift +++ b/Loop/View Controllers/CommandResponseViewController.swift @@ -13,21 +13,20 @@ import LoopKitUI extension CommandResponseViewController { typealias T = CommandResponseViewController - static func generateDiagnosticReport(deviceManager: DeviceDataManager) -> T { + static func generateDiagnosticReport(reportGenerator: DiagnosticReportGenerator) -> T { let date = Date() let vc = T(command: { (completionHandler) in - deviceManager.generateDiagnosticReport { (report) in - DispatchQueue.main.async { - completionHandler([ - "Use the Share button above to save this diagnostic report to aid investigating your problem. Issues can be filed at https://github.com/LoopKit/Loop/issues.", - "Generated: \(date)", - "", - report, - "", - ].joined(separator: "\n\n")) - } + Task { @MainActor in + let report = await reportGenerator.generateDiagnosticReport() + // TODO: https://tidepool.atlassian.net/browse/LOOP-4771 + completionHandler([ + "Use the Share button above to save this diagnostic report to aid investigating your problem. Issues can be filed at https://github.com/LoopKit/Loop/issues.", + "Generated: \(date)", + "", + report, + "", + ].joined(separator: "\n\n")) } - return NSLocalizedString("Loading...", comment: "The loading message for the diagnostic report screen") }) vc.fileName = "Loop Report \(ISO8601DateFormatter.string(from: date, timeZone: .current, formatOptions: [.withSpaceBetweenDateAndTime, .withInternetDateTime])).md" diff --git a/Loop/View Controllers/InsulinDeliveryTableViewController.swift b/Loop/View Controllers/InsulinDeliveryTableViewController.swift index c340f8f536..54ea7273d7 100644 --- a/Loop/View Controllers/InsulinDeliveryTableViewController.swift +++ b/Loop/View Controllers/InsulinDeliveryTableViewController.swift @@ -47,13 +47,8 @@ public final class InsulinDeliveryTableViewController: UITableViewController { public var enableEntryDeletion: Bool = true - var deviceManager: DeviceDataManager? { - didSet { - doseStore = deviceManager?.doseStore - } - } - - public var doseStore: DoseStore? { + var loopDataManager: LoopDataManager! + var doseStore: DoseStore! { didSet { if let doseStore = doseStore { doseStoreObserver = NotificationCenter.default.addObserver(forName: nil, object: doseStore, queue: OperationQueue.main, using: { [weak self] (note) -> Void in @@ -61,7 +56,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { switch note.name { case DoseStore.valuesDidChange: if self?.isViewLoaded == true { - self?.reloadData() + Task { @MainActor in + await self?.reloadData() + } } default: break @@ -159,13 +156,13 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } @objc func didTapEnterDoseButton(sender: AnyObject){ - guard let deviceManager = deviceManager else { + guard let loopDataManager = loopDataManager else { return } tableView.endEditing(true) - let viewModel = ManualEntryDoseViewModel(delegate: deviceManager) + let viewModel = ManualEntryDoseViewModel(delegate: loopDataManager) let bolusEntryView = ManualEntryDoseView(viewModel: viewModel) let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) let navigationWrapper = UINavigationController(rootViewController: hostingController) @@ -185,7 +182,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { private var state = State.unknown { didSet { if isViewLoaded { - reloadData() + Task { @MainActor in + await reloadData() + } } } } @@ -222,7 +221,7 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } } - private func reloadData() { + private func reloadData() async { let sinceDate = Date().addingTimeInterval(-InsulinDeliveryTableViewController.historicDataDisplayTimeInterval) switch state { case .unknown: @@ -240,52 +239,24 @@ public final class InsulinDeliveryTableViewController: UITableViewController { self.tableView.tableHeaderView?.isHidden = false self.tableView.tableFooterView = nil - switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { - case .reservoir: - doseStore?.getReservoirValues(since: sinceDate) { (result) in - DispatchQueue.main.async { () -> Void in - switch result { - case .failure(let error): - self.state = .unavailable(error) - case .success(let reservoirValues): - self.values = .reservoir(reservoirValues) - self.tableView.reloadData() - } - } - - self.updateTimelyStats(nil) - self.updateTotal() - } - case .history: - doseStore?.getPumpEventValues(since: sinceDate) { (result) in - DispatchQueue.main.async { () -> Void in - switch result { - case .failure(let error): - self.state = .unavailable(error) - case .success(let pumpEventValues): - self.values = .history(pumpEventValues) - self.tableView.reloadData() - } - } + guard let doseStore else { + return + } - self.updateTimelyStats(nil) - self.updateTotal() + do { + switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { + case .reservoir: + self.values = .reservoir(try await doseStore.getReservoirValues(since: sinceDate, limit: nil)) + case .history: + self.values = .history(try await doseStore.getPumpEventValues(since: sinceDate)) + case .manualEntryDose: + self.values = .manualEntryDoses(try await doseStore.getManuallyEnteredDoses(since: sinceDate)) } - case .manualEntryDose: - doseStore?.getManuallyEnteredDoses(since: sinceDate) { (result) in - DispatchQueue.main.async { () -> Void in - switch result { - case .failure(let error): - self.state = .unavailable(error) - case .success(let values): - self.values = .manualEntryDoses(values) - self.tableView.reloadData() - } - } - } - + self.tableView.reloadData() self.updateTimelyStats(nil) self.updateTotal() + } catch { + self.state = .unavailable(error) } } } @@ -314,35 +285,27 @@ public final class InsulinDeliveryTableViewController: UITableViewController { private func updateIOB() { if case .display = state { - doseStore?.insulinOnBoard(at: Date()) { (result) -> Void in - DispatchQueue.main.async { - switch result { - case .failure: - self.iobValueLabel.text = "…" - self.iobDateLabel.text = nil - case .success(let iob): - self.iobValueLabel.text = self.iobNumberFormatter.string(from: iob.value) - self.iobDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.IOBDateLabel", value: "at %1$@", comment: "The format string describing the date of an IOB value. The first format argument is the localized date."), self.timeFormatter.string(from: iob.startDate)) - } - } + if let activeInsulin = loopDataManager.activeInsulin { + self.iobValueLabel.text = self.iobNumberFormatter.string(from: activeInsulin.value) + self.iobDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.IOBDateLabel", value: "at %1$@", comment: "The format string describing the date of an IOB value. The first format argument is the localized date."), self.timeFormatter.string(from: activeInsulin.startDate)) + } else { + self.iobValueLabel.text = "…" + self.iobDateLabel.text = nil } } } private func updateTotal() { - if case .display = state { - let midnight = Calendar.current.startOfDay(for: Date()) - - doseStore?.getTotalUnitsDelivered(since: midnight) { (result) in - DispatchQueue.main.async { - switch result { - case .failure: - self.totalValueLabel.text = "…" - self.totalDateLabel.text = nil - case .success(let result): - self.totalValueLabel.text = NumberFormatter.localizedString(from: NSNumber(value: result.value), number: .none) - self.totalDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.totalDateLabel", value: "since %1$@", comment: "The format string describing the starting date of a total value. The first format argument is the localized date."), DateFormatter.localizedString(from: result.startDate, dateStyle: .none, timeStyle: .short)) - } + Task { @MainActor in + if case .display = state { + let midnight = Calendar.current.startOfDay(for: Date()) + + if let result = try? await doseStore?.getTotalUnitsDelivered(since: midnight) { + self.totalValueLabel.text = NumberFormatter.localizedString(from: NSNumber(value: result.value), number: .none) + self.totalDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.totalDateLabel", value: "since %1$@", comment: "The format string describing the starting date of a total value. The first format argument is the localized date."), DateFormatter.localizedString(from: result.startDate, dateStyle: .none, timeStyle: .short)) + } else { + self.totalValueLabel.text = "…" + self.totalDateLabel.text = nil } } } @@ -357,7 +320,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } @IBAction func selectedSegmentChanged(_ sender: Any) { - reloadData() + Task { @MainActor in + await reloadData() + } } @IBAction func confirmDeletion(_ sender: Any) { @@ -495,7 +460,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { if let error = error { DispatchQueue.main.async { self.present(UIAlertController(with: error), animated: true) - self.reloadData() + Task { @MainActor in + await self.reloadData() + } } } } @@ -510,7 +477,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { if let error = error { DispatchQueue.main.async { self.present(UIAlertController(with: error), animated: true) - self.reloadData() + Task { @MainActor in + await self.reloadData() + } } } } @@ -524,7 +493,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { if let error = error { DispatchQueue.main.async { self.present(UIAlertController(with: error), animated: true) - self.reloadData() + Task { @MainActor in + await self.reloadData() + } } } } diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index a460e52aaf..1f48cb0c88 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -23,6 +23,9 @@ private extension RefreshContext { class PredictionTableViewController: LoopChartsTableViewController, IdentifiableClass { private let log = OSLog(category: "PredictionTableViewController") + var settingsManager: SettingsManager! + var loopDataManager: LoopDataManager! + override func viewDidLoad() { super.viewDidLoad() @@ -34,10 +37,10 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable let notificationCenter = NotificationCenter.default notificationObservers += [ - notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in - let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue + notificationCenter.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { [weak self] note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopUpdateContext.RawValue DispatchQueue.main.async { - switch LoopDataManager.LoopUpdateContext(rawValue: context) { + switch LoopUpdateContext(rawValue: context) { case .preferences?: self?.refreshContext.formUnion([.status, .targets]) case .glucose?: @@ -46,7 +49,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable break } - self?.reloadData(animated: true) + Task { + await self?.reloadData(animated: true) + } } }, ] @@ -98,7 +103,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable refreshContext = RefreshContext.all } - override func reloadData(animated: Bool = false) { + override func reloadData(animated: Bool = false) async { guard active && visible && !refreshContext.isEmpty else { return } refreshContext.remove(.size(.zero)) @@ -108,84 +113,69 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable let date = Date(timeIntervalSinceNow: -TimeInterval(hours: 1)) chartStartDate = calendar.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date - let reloadGroup = DispatchGroup() var glucoseSamples: [StoredGlucoseSample]? var totalRetrospectiveCorrection: HKQuantity? - if self.refreshContext.remove(.glucose) != nil { - reloadGroup.enter() - deviceManager.glucoseStore.getGlucoseSamples(start: self.chartStartDate, end: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - glucoseSamples = nil - case .success(let samples): - glucoseSamples = samples - } - reloadGroup.leave() - } - } - // For now, do this every time _ = self.refreshContext.remove(.status) - reloadGroup.enter() - deviceManager.loopManager.getLoopState { (manager, state) in - self.retrospectiveGlucoseDiscrepancies = state.retrospectiveGlucoseDiscrepancies - totalRetrospectiveCorrection = state.totalRetrospectiveCorrection - self.glucoseChart.setPredictedGlucoseValues(state.predictedGlucoseIncludingPendingInsulin ?? []) - - do { - let glucose = try state.predictGlucose(using: self.selectedInputs, includingPendingInsulin: true) - self.glucoseChart.setAlternatePredictedGlucoseValues(glucose) - } catch { - self.refreshContext.update(with: .status) - self.glucoseChart.setAlternatePredictedGlucoseValues([]) - } + let (algoInput, algoOutput) = await loopDataManager.algorithmDisplayState.asTuple - if let lastPoint = self.glucoseChart.alternatePredictedGlucosePoints?.last?.y { - self.eventualGlucoseDescription = String(describing: lastPoint) - } else { - self.eventualGlucoseDescription = nil - } + if self.refreshContext.remove(.glucose) != nil, let algoInput { + glucoseSamples = algoInput.glucoseHistory.filterDateRange(self.chartStartDate, nil) + } - if self.refreshContext.remove(.targets) != nil { - self.glucoseChart.targetGlucoseSchedule = manager.settings.glucoseTargetRangeSchedule - } + self.retrospectiveGlucoseDiscrepancies = algoOutput?.effects.retrospectiveGlucoseDiscrepancies + totalRetrospectiveCorrection = algoOutput?.effects.totalGlucoseCorrectionEffect + + self.glucoseChart.setPredictedGlucoseValues(algoOutput?.predictedGlucose ?? []) - reloadGroup.leave() + do { + let glucose = try algoInput?.predictGlucose(effectsOptions: self.selectedInputs.algorithmEffectOptions) ?? [] + self.glucoseChart.setAlternatePredictedGlucoseValues(glucose) + } catch { + self.refreshContext.update(with: .status) + self.glucoseChart.setAlternatePredictedGlucoseValues([]) } - reloadGroup.notify(queue: .main) { - if let glucoseSamples = glucoseSamples { - self.glucoseChart.setGlucoseValues(glucoseSamples) - } - self.charts.invalidateChart(atIndex: 0) + if let lastPoint = self.glucoseChart.alternatePredictedGlucosePoints?.last?.y { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + self.eventualGlucoseDescription = nil + } - if let totalRetrospectiveCorrection = totalRetrospectiveCorrection { - self.totalRetrospectiveCorrection = totalRetrospectiveCorrection - } + if self.refreshContext.remove(.targets) != nil { + self.glucoseChart.targetGlucoseSchedule = self.settingsManager.settings.glucoseTargetRangeSchedule + } - self.charts.prerender() + if let glucoseSamples = glucoseSamples { + self.glucoseChart.setGlucoseValues(glucoseSamples) + } + self.charts.invalidateChart(atIndex: 0) - self.tableView.beginUpdates() - for cell in self.tableView.visibleCells { - switch cell { - case let cell as ChartTableViewCell: - cell.reloadChart() + if let totalRetrospectiveCorrection = totalRetrospectiveCorrection { + self.totalRetrospectiveCorrection = totalRetrospectiveCorrection + } - if let indexPath = self.tableView.indexPath(for: cell) { - self.tableView(self.tableView, updateTitleFor: cell, at: indexPath) - } - case let cell as PredictionInputEffectTableViewCell: - if let indexPath = self.tableView.indexPath(for: cell) { - self.tableView(self.tableView, updateTextFor: cell, at: indexPath) - } - default: - break + self.charts.prerender() + + self.tableView.beginUpdates() + for cell in self.tableView.visibleCells { + switch cell { + case let cell as ChartTableViewCell: + cell.reloadChart() + + if let indexPath = self.tableView.indexPath(for: cell) { + self.tableView(self.tableView, updateTitleFor: cell, at: indexPath) } + case let cell as PredictionInputEffectTableViewCell: + if let indexPath = self.tableView.indexPath(for: cell) { + self.tableView(self.tableView, updateTextFor: cell, at: indexPath) + } + default: + break } - self.tableView.endUpdates() } + self.tableView.endUpdates() } // MARK: - UITableViewDataSource @@ -263,7 +253,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable if input == .retrospection, let lastDiscrepancy = retrospectiveGlucoseDiscrepancies?.last, - let currentGlucose = deviceManager.glucoseStore.latestGlucose + let currentGlucose = loopDataManager.latestGlucose { let formatter = QuantityFormatter(for: glucoseChart.glucoseUnit) let predicted = HKQuantity(unit: glucoseChart.glucoseUnit, doubleValue: currentGlucose.quantity.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit)) @@ -326,6 +316,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable tableView.deselectRow(at: indexPath, animated: true) refreshContext.update(with: .status) - reloadData() + + Task { + await reloadData() + } } } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 84bc9428c6..41935ed1f2 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -25,6 +25,7 @@ private extension RefreshContext { static let all: Set = [.status, .glucose, .insulin, .carbs, .targets] } +@MainActor final class StatusTableViewController: LoopChartsTableViewController { private let log = OSLog(category: "StatusTableViewController") @@ -39,10 +40,31 @@ final class StatusTableViewController: LoopChartsTableViewController { var alertPermissionsChecker: AlertPermissionsChecker! + var settingsManager: SettingsManager! + + var temporaryPresetsManager: TemporaryPresetsManager! + + var loopManager: LoopDataManager! + var alertMuter: AlertMuter! var supportManager: SupportManager! + var diagnosticReportGenerator: DiagnosticReportGenerator! + + var analyticsServicesManager: AnalyticsServicesManager? + + var servicesManager: ServicesManager! + + var simulatedData: SimulatedData! + + var carbStore: CarbStore! + + var doseStore: DoseStore! + + var criticalEventLogExportManager: CriticalEventLogExportManager! + + lazy private var cancellables = Set() override func viewDidLoad() { @@ -67,10 +89,10 @@ final class StatusTableViewController: LoopChartsTableViewController { let notificationCenter = NotificationCenter.default notificationObservers += [ - notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in - let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue - let context = LoopDataManager.LoopUpdateContext(rawValue: rawContext) - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { note in + let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopUpdateContext.RawValue + let context = LoopUpdateContext(rawValue: rawContext) + Task { @MainActor [weak self] in switch context { case .none, .insulin?: self?.refreshContext.formUnion([.status, .insulin]) @@ -80,40 +102,40 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.refreshContext.update(with: .carbs) case .glucose?: self?.refreshContext.formUnion([.glucose, .carbs]) - case .loopFinished?: - self?.refreshContext.update(with: .insulin) + default: + break } self?.hudView?.loopCompletionHUD.loopInProgress = false self?.log.debug("[reloadData] from notification with context %{public}@", String(describing: context)) - self?.reloadData(animated: true) + await self?.reloadData(animated: true) } - + WidgetCenter.shared.reloadAllTimelines() }, - notificationCenter.addObserver(forName: .LoopRunning, object: deviceManager.loopManager, queue: nil) { [weak self] _ in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .LoopRunning, object: nil, queue: nil) { _ in + Task { @MainActor [weak self] in self?.hudView?.loopCompletionHUD.loopInProgress = true } }, - notificationCenter.addObserver(forName: .PumpManagerChanged, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .PumpManagerChanged, object: deviceManager, queue: nil) { (notification: Notification) in + Task { @MainActor [weak self] in self?.registerPumpManager() self?.configurePumpManagerHUDViews() self?.updateToolbarItems() } }, - notificationCenter.addObserver(forName: .CGMManagerChanged, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .CGMManagerChanged, object: deviceManager, queue: nil) { (notification: Notification) in + Task { @MainActor [weak self] in self?.registerCGMManager() self?.configureCGMManagerHUDViews() self?.updateToolbarItems() } }, - notificationCenter.addObserver(forName: .PumpEventsAdded, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .PumpEventsAdded, object: deviceManager, queue: nil) { (notification: Notification) in + Task { @MainActor [weak self] in self?.refreshContext.update(with: .insulin) - self?.reloadData(animated: true) + await self?.reloadData(animated: true) } }, ] @@ -125,11 +147,12 @@ final class StatusTableViewController: LoopChartsTableViewController { alertMuter.$configuration .removeDuplicates() - .receive(on: RunLoop.main) .dropFirst() .sink { _ in - self.refreshContext.update(with: .status) - self.reloadData(animated: true) + Task { @MainActor in + self.refreshContext.update(with: .status) + await self.reloadData(animated: true) + } } .store(in: &cancellables) @@ -172,11 +195,12 @@ final class StatusTableViewController: LoopChartsTableViewController { onboardingManager.$isComplete .merge(with: onboardingManager.$isSuspended) - .receive(on: RunLoop.main) .sink { [weak self] _ in - self?.refreshContext.update(with: .status) - self?.reloadData(animated: true) - self?.updateToolbarItems() + Task { @MainActor in + self?.refreshContext.update(with: .status) + await self?.reloadData(animated: true) + self?.updateToolbarItems() + } } .store(in: &cancellables) } @@ -187,15 +211,15 @@ final class StatusTableViewController: LoopChartsTableViewController { if !appearedOnce { appearedOnce = true - DispatchQueue.main.async { + Task { @MainActor in self.log.debug("[reloadData] after HealthKit authorization") - self.reloadData() + await self.reloadData() } } onscreen = true - deviceManager.analyticsServicesManager.didDisplayStatusScreen() + analyticsServicesManager?.didDisplayStatusScreen() deviceManager.checkDeliveryUncertaintyState() } @@ -249,8 +273,10 @@ final class StatusTableViewController: LoopChartsTableViewController { default: break } - refreshContext.update(with: .status) - reloadData(animated: true) + Task { @MainActor in + refreshContext.update(with: .status) + await reloadData(animated: true) + } } } } @@ -307,9 +333,11 @@ final class StatusTableViewController: LoopChartsTableViewController { public var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil { didSet { if oldValue != basalDeliveryState { - log.debug("New basalDeliveryState: %@", String(describing: basalDeliveryState)) - refreshContext.update(with: .status) - reloadData(animated: true) + Task { @MainActor in + log.debug("New basalDeliveryState: %@", String(describing: basalDeliveryState)) + refreshContext.update(with: .status) + await reloadData(animated: true) + } } } } @@ -359,7 +387,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let availableWidth = (refreshContext.newSize ?? tableView.bounds.size).width - charts.fixedHorizontalMargin let totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour)) - let futureHours = ceil(deviceManager.doseStore.longestEffectDuration.hours) + let futureHours = ceil(doseStore.longestEffectDuration.hours) let historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) let date = Date(timeIntervalSinceNow: -TimeInterval(hours: historyHours)) @@ -372,10 +400,10 @@ final class StatusTableViewController: LoopChartsTableViewController { charts.updateEndDate(charts.maxEndDate) } - override func reloadData(animated: Bool = false) { + override func reloadData(animated: Bool = false) async { dispatchPrecondition(condition: .onQueue(.main)) // This should be kept up to date immediately - hudView?.loopCompletionHUD.lastLoopCompleted = deviceManager.loopManager.lastLoopCompleted + hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted guard !reloading && !deviceManager.authorizationRequired else { return @@ -402,11 +430,9 @@ final class StatusTableViewController: LoopChartsTableViewController { log.debug("Reloading data with context: %@", String(describing: refreshContext)) let currentContext = refreshContext - var retryContext: Set = [] refreshContext = [] reloading = true - let reloadGroup = DispatchGroup() var glucoseSamples: [StoredGlucoseSample]? var predictedGlucoseValues: [GlucoseValue]? var iobValues: [InsulinValue]? @@ -418,231 +444,171 @@ final class StatusTableViewController: LoopChartsTableViewController { let basalDeliveryState = self.basalDeliveryState let automaticDosingEnabled = automaticDosingStatus.automaticDosingEnabled - // TODO: Don't always assume currentContext.contains(.status) - reloadGroup.enter() - deviceManager.loopManager.getLoopState { (manager, state) -> Void in - predictedGlucoseValues = state.predictedGlucoseIncludingPendingInsulin ?? [] - - // Retry this refresh again if predicted glucose isn't available - if state.predictedGlucose == nil { - retryContext.update(with: .status) - } - - /// Update the status HUDs immediately - let lastLoopError = state.error + let state = await loopManager.algorithmDisplayState + predictedGlucoseValues = state.output?.predictedGlucose ?? [] - // Net basal rate HUD - let netBasal: NetBasal? - if let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory { - netBasal = basalDeliveryState?.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) - } else { - netBasal = nil - } - self.log.debug("Update net basal to %{public}@", String(describing: netBasal)) + /// Update the status HUDs immediately + let lastLoopError: Error? + if let output = state.output, case .failure(let error) = output.recommendationResult { + lastLoopError = error + } else { + lastLoopError = nil + } - DispatchQueue.main.async { - self.lastLoopError = lastLoopError + // Net basal rate HUD + let netBasal: NetBasal? + if let basalSchedule = temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory { + netBasal = basalDeliveryState?.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: settingsManager.settings.maximumBasalRatePerHour) + } else { + netBasal = nil + } + self.log.debug("Update net basal to %{public}@", String(describing: netBasal)) - if let netBasal = netBasal { - self.hudView?.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.start) - } - } + self.lastLoopError = lastLoopError - if currentContext.contains(.carbs) { - reloadGroup.enter() - self.deviceManager.carbStore.getCarbsOnBoardValues(start: startDate, end: nil, effectVelocities: state.insulinCounteractionEffects) { (result) in - switch result { - case .failure(let error): - self.log.error("CarbStore failed to get carbs on board values: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - cobValues = [] - case .success(let values): - cobValues = values - } - reloadGroup.leave() - } - } - // always check for cob - carbsOnBoard = state.carbsOnBoard?.quantity + if let netBasal = netBasal { + self.hudView?.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.start) + } - reloadGroup.leave() + if currentContext.contains(.carbs) { + cobValues = await loopManager.dynamicCarbsOnBoard(from: startDate) } + // always check for cob + carbsOnBoard = loopManager.activeCarbs?.quantity + if currentContext.contains(.glucose) { - reloadGroup.enter() - deviceManager.glucoseStore.getGlucoseSamples(start: startDate, end: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - glucoseSamples = nil - case .success(let samples): - glucoseSamples = samples - } - reloadGroup.leave() + do { + glucoseSamples = try await loopManager.glucoseStore.getGlucoseSamples(start: startDate, end: nil) + } catch { + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) + glucoseSamples = nil } } if currentContext.contains(.insulin) { - reloadGroup.enter() - deviceManager.doseStore.getInsulinOnBoardValues(start: startDate, end: nil, basalDosingEnd: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("DoseStore failed to get insulin on board values: %{public}@", String(describing: error)) - retryContext.update(with: .insulin) - iobValues = [] - case .success(let values): - iobValues = values - } - reloadGroup.leave() - } - - reloadGroup.enter() - deviceManager.doseStore.getNormalizedDoseEntries(start: startDate, end: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("DoseStore failed to get normalized dose entries: %{public}@", String(describing: error)) - retryContext.update(with: .insulin) - doseEntries = [] - case .success(let doses): - doseEntries = doses - } - reloadGroup.leave() - } - - reloadGroup.enter() - deviceManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())) { (result) in - switch result { - case .failure: - retryContext.update(with: .insulin) - totalDelivery = nil - case .success(let total): - totalDelivery = total.value - } - - reloadGroup.leave() - } + doseEntries = loopManager.dosesRelativeToBasal.trimmed(from: startDate) + iobValues = loopManager.iobValues.trimmed(from: startDate) + totalDelivery = try? await loopManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())).value } updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) - if deviceManager.loopManager.settings.preMealTargetRange == nil { + if settingsManager.settings.preMealTargetRange == nil { preMealMode = nil } else { - preMealMode = deviceManager.loopManager.settings.preMealTargetEnabled() + preMealMode = temporaryPresetsManager.preMealTargetEnabled() } - if !FeatureFlags.sensitivityOverridesEnabled, deviceManager.loopManager.settings.legacyWorkoutTargetRange == nil { + if !FeatureFlags.sensitivityOverridesEnabled, settingsManager.settings.workoutTargetRange == nil { workoutMode = nil } else { - workoutMode = deviceManager.loopManager.settings.nonPreMealOverrideEnabled() + workoutMode = temporaryPresetsManager.nonPreMealOverrideEnabled() } - reloadGroup.notify(queue: .main) { - /// Update the chart data + /// Update the chart data - // Glucose - if let glucoseSamples = glucoseSamples { - self.statusCharts.setGlucoseValues(glucoseSamples) - } - if (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled), let predictedGlucoseValues = predictedGlucoseValues { - self.statusCharts.setPredictedGlucoseValues(predictedGlucoseValues) - } else { - self.statusCharts.setPredictedGlucoseValues([]) - } - if !FeatureFlags.predictedGlucoseChartClampEnabled, - let lastPoint = self.statusCharts.glucose.predictedGlucosePoints.last?.y - { - self.eventualGlucoseDescription = String(describing: lastPoint) - } else { - // if the predicted glucose values are clamped, the eventually glucose description should not be displayed, since it may not align with what is being charted. - self.eventualGlucoseDescription = nil - } - if currentContext.contains(.targets) { - self.statusCharts.targetGlucoseSchedule = self.deviceManager.loopManager.settings.glucoseTargetRangeSchedule - self.statusCharts.preMealOverride = self.deviceManager.loopManager.settings.preMealOverride - self.statusCharts.scheduleOverride = self.deviceManager.loopManager.settings.scheduleOverride - } - if self.statusCharts.scheduleOverride?.hasFinished() == true { - self.statusCharts.scheduleOverride = nil - } + // Glucose + if let glucoseSamples = glucoseSamples { + self.statusCharts.setGlucoseValues(glucoseSamples) + } + if (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled), let predictedGlucoseValues = predictedGlucoseValues { + self.statusCharts.setPredictedGlucoseValues(predictedGlucoseValues) + } else { + self.statusCharts.setPredictedGlucoseValues([]) + } + if !FeatureFlags.predictedGlucoseChartClampEnabled, + let lastPoint = self.statusCharts.glucose.predictedGlucosePoints.last?.y + { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + // if the predicted glucose values are clamped, the eventually glucose description should not be displayed, since it may not align with what is being charted. + self.eventualGlucoseDescription = nil + } + if currentContext.contains(.targets) { + self.statusCharts.targetGlucoseSchedule = settingsManager.settings.glucoseTargetRangeSchedule + self.statusCharts.preMealOverride = temporaryPresetsManager.preMealOverride + self.statusCharts.scheduleOverride = temporaryPresetsManager.scheduleOverride + } + if self.statusCharts.scheduleOverride?.hasFinished() == true { + self.statusCharts.scheduleOverride = nil + } - let charts = self.statusCharts + let charts = self.statusCharts - // Active Insulin - if let iobValues = iobValues { - charts.setIOBValues(iobValues) - } + // Active Insulin + if let iobValues = iobValues { + charts.setIOBValues(iobValues) + } - // Show the larger of the value either before or after the current date - if let maxValue = charts.iob.iobPoints.allElementsAdjacent(to: Date()).max(by: { - return $0.y.scalar < $1.y.scalar - }) { - self.currentIOBDescription = String(describing: maxValue.y) - } else { - self.currentIOBDescription = nil - } + // Show the larger of the value either before or after the current date + if let maxValue = charts.iob.iobPoints.allElementsAdjacent(to: Date()).max(by: { + return $0.y.scalar < $1.y.scalar + }) { + self.currentIOBDescription = String(describing: maxValue.y) + } else { + self.currentIOBDescription = nil + } - // Insulin Delivery - if let doseEntries = doseEntries { - charts.setDoseEntries(doseEntries) - } - if let totalDelivery = totalDelivery { - self.totalDelivery = totalDelivery - } + // Insulin Delivery + if let doseEntries = doseEntries { + charts.setDoseEntries(doseEntries) + } + if let totalDelivery = totalDelivery { + self.totalDelivery = totalDelivery + } - // Active Carbohydrates - if let cobValues = cobValues { - charts.setCOBValues(cobValues) - } - if let index = charts.cob.cobPoints.closestIndex(priorTo: Date()) { - self.currentCOBDescription = String(describing: charts.cob.cobPoints[index].y) - } else if let carbsOnBoard = carbsOnBoard { - self.currentCOBDescription = self.carbFormatter.string(from: carbsOnBoard) - } else { - self.currentCOBDescription = nil - } + // Active Carbohydrates + if let cobValues = cobValues { + charts.setCOBValues(cobValues) + } + if let index = charts.cob.cobPoints.closestIndex(priorTo: Date()) { + self.currentCOBDescription = String(describing: charts.cob.cobPoints[index].y) + } else if let carbsOnBoard = carbsOnBoard { + self.currentCOBDescription = self.carbFormatter.string(from: carbsOnBoard) + } else { + self.currentCOBDescription = nil + } - self.tableView.beginUpdates() - if let hudView = self.hudView { - // CGM Status - if let glucose = self.deviceManager.glucoseStore.latestGlucose { - let unit = self.statusCharts.glucose.glucoseUnit - hudView.cgmStatusHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), - at: glucose.startDate, - unit: unit, - staleGlucoseAge: LoopAlgorithm.inputDataRecencyInterval, - glucoseDisplay: self.deviceManager.glucoseDisplay(for: glucose), - wasUserEntered: glucose.wasUserEntered, - isDisplayOnly: glucose.isDisplayOnly) - } - hudView.cgmStatusHUD.presentStatusHighlight(self.deviceManager.cgmStatusHighlight) - hudView.cgmStatusHUD.presentStatusBadge(self.deviceManager.cgmStatusBadge) - hudView.cgmStatusHUD.lifecycleProgress = self.deviceManager.cgmLifecycleProgress - - // Pump Status - hudView.pumpStatusHUD.presentStatusHighlight(self.deviceManager.pumpStatusHighlight) - hudView.pumpStatusHUD.presentStatusBadge(self.deviceManager.pumpStatusBadge) - hudView.pumpStatusHUD.lifecycleProgress = self.deviceManager.pumpLifecycleProgress + self.tableView.beginUpdates() + if let hudView = self.hudView { + // CGM Status + if let glucose = self.loopManager.latestGlucose { + let unit = self.statusCharts.glucose.glucoseUnit + hudView.cgmStatusHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), + at: glucose.startDate, + unit: unit, + staleGlucoseAge: LoopAlgorithm.inputDataRecencyInterval, + glucoseDisplay: self.deviceManager.glucoseDisplay(for: glucose), + wasUserEntered: glucose.wasUserEntered, + isDisplayOnly: glucose.isDisplayOnly) } + hudView.cgmStatusHUD.presentStatusHighlight(self.deviceManager.cgmStatusHighlight) + hudView.cgmStatusHUD.presentStatusBadge(self.deviceManager.cgmStatusBadge) + hudView.cgmStatusHUD.lifecycleProgress = self.deviceManager.cgmLifecycleProgress - // Show/hide the table view rows - let statusRowMode = self.determineStatusRowMode() + // Pump Status + hudView.pumpStatusHUD.presentStatusHighlight(self.deviceManager.pumpStatusHighlight) + hudView.pumpStatusHUD.presentStatusBadge(self.deviceManager.pumpStatusBadge) + hudView.pumpStatusHUD.lifecycleProgress = self.deviceManager.pumpLifecycleProgress + } + + // Show/hide the table view rows + let statusRowMode = self.determineStatusRowMode() - self.updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) + updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) - self.redrawCharts() + redrawCharts() - self.tableView.endUpdates() + tableView.endUpdates() - self.reloading = false - let reloadNow = !self.refreshContext.isEmpty - self.refreshContext.formUnion(retryContext) + reloading = false + let reloadNow = !self.refreshContext.isEmpty - // Trigger a reload if new context exists. - if reloadNow { - self.log.debug("[reloadData] due to context change during previous reload") - self.reloadData() - } + // Trigger a reload if new context exists. + if reloadNow { + log.debug("[reloadData] due to context change during previous reload") + await reloadData() } } @@ -723,11 +689,11 @@ final class StatusTableViewController: LoopChartsTableViewController { statusRowMode = .onboardingSuspended } else if onboardingManager.isComplete, deviceManager.isGlucoseValueStale { statusRowMode = .recommendManualGlucoseEntry - } else if let scheduleOverride = deviceManager.loopManager.settings.scheduleOverride, + } else if let scheduleOverride = temporaryPresetsManager.scheduleOverride, !scheduleOverride.hasFinished() { statusRowMode = .scheduleOverrideEnabled(scheduleOverride) - } else if let premealOverride = deviceManager.loopManager.settings.preMealOverride, + } else if let premealOverride = temporaryPresetsManager.preMealOverride, !premealOverride.hasFinished() { statusRowMode = .scheduleOverrideEnabled(premealOverride) @@ -837,14 +803,14 @@ final class StatusTableViewController: LoopChartsTableViewController { } private lazy var preMealModeAllowed: Bool = { onboardingManager.isComplete && - (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && deviceManager.loopManager.settings.preMealTargetRange != nil + (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && settingsManager.settings.preMealTargetRange != nil }() private func updatePresetModeAvailability(automaticDosingEnabled: Bool) { preMealModeAllowed = onboardingManager.isComplete && - (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && deviceManager.loopManager.settings.preMealTargetRange != nil + (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && settingsManager.settings.preMealTargetRange != nil workoutModeAllowed = onboardingManager.isComplete && workoutMode != nil updateToolbarItems() } @@ -1213,7 +1179,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case .pumpSuspended(let resuming) where !resuming: updateBannerAndHUDandStatusRows(statusRowMode: .pumpSuspended(resuming: true) , newSize: nil, animated: true) deviceManager.pumpManager?.resumeDelivery() { (error) in - DispatchQueue.main.async { + Task { @MainActor in if let error = error { let alert = UIAlertController(with: error, title: NSLocalizedString("Failed to Resume Insulin Delivery", comment: "The alert title for a resume error")) self.present(alert, animated: true, completion: nil) @@ -1224,7 +1190,7 @@ final class StatusTableViewController: LoopChartsTableViewController { self.updateBannerAndHUDandStatusRows(statusRowMode: self.determineStatusRowMode(), newSize: nil, animated: true) self.refreshContext.update(with: .insulin) self.log.debug("[reloadData] after manually resuming suspend") - self.reloadData() + await self.reloadData() } } } @@ -1328,22 +1294,28 @@ final class StatusTableViewController: LoopChartsTableViewController { vc.isOnboardingComplete = onboardingManager.isComplete vc.automaticDosingStatus = automaticDosingStatus vc.deviceManager = deviceManager + vc.loopDataManager = loopManager + vc.analyticsServicesManager = analyticsServicesManager + vc.carbStore = carbStore vc.hidesBottomBarWhenPushed = true case let vc as InsulinDeliveryTableViewController: - vc.deviceManager = deviceManager + vc.loopDataManager = loopManager + vc.doseStore = doseStore vc.hidesBottomBarWhenPushed = true vc.enableEntryDeletion = FeatureFlags.entryDeletionEnabled vc.headerValueLabelColor = .insulinTintColor case let vc as OverrideSelectionViewController: - if deviceManager.loopManager.settings.futureOverrideEnabled() { - vc.scheduledOverride = deviceManager.loopManager.settings.scheduleOverride + if temporaryPresetsManager.futureOverrideEnabled() { + vc.scheduledOverride = temporaryPresetsManager.scheduleOverride } - vc.presets = deviceManager.loopManager.settings.overridePresets + vc.presets = loopManager.settings.overridePresets vc.glucoseUnit = statusCharts.glucose.glucoseUnit - vc.overrideHistory = deviceManager.loopManager.overrideHistory.getEvents() + vc.overrideHistory = temporaryPresetsManager.overrideHistory.getEvents() vc.delegate = self case let vc as PredictionTableViewController: vc.deviceManager = deviceManager + vc.settingsManager = settingsManager + vc.loopDataManager = loopManager default: break } @@ -1360,7 +1332,7 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentCarbEntryScreen(_ activity: NSUserActivity?) { let navigationWrapper: UINavigationController if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { - let viewModel = SimpleBolusViewModel(delegate: deviceManager, displayMealEntry: true) + let viewModel = SimpleBolusViewModel(delegate: loopManager, displayMealEntry: true, displayGlucosePreference: deviceManager.displayGlucosePreference) if let activity = activity { viewModel.restoreUserActivityState(activity) } @@ -1370,7 +1342,9 @@ final class StatusTableViewController: LoopChartsTableViewController { hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) } else { - let viewModel = CarbEntryViewModel(delegate: deviceManager) + let viewModel = CarbEntryViewModel(delegate: loopManager) + viewModel.deliveryDelegate = deviceManager + viewModel.analyticsServicesManager = loopManager.analyticsServicesManager if let activity { viewModel.restoreUserActivityState(activity) } @@ -1379,7 +1353,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) present(hostingController, animated: true) } - deviceManager.analyticsServicesManager.didDisplayCarbEntryScreen() + analyticsServicesManager?.didDisplayCarbEntryScreen() } @IBAction func presentBolusScreen() { @@ -1391,24 +1365,21 @@ final class StatusTableViewController: LoopChartsTableViewController { if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { SimpleBolusView( viewModel: SimpleBolusViewModel( - delegate: deviceManager, - displayMealEntry: false + delegate: loopManager, + displayMealEntry: false, + displayGlucosePreference: deviceManager.displayGlucosePreference ) ) .environmentObject(deviceManager.displayGlucosePreference) } else { let viewModel: BolusEntryViewModel = { let viewModel = BolusEntryViewModel( - delegate: deviceManager, + delegate: loopManager, screenWidth: UIScreen.main.bounds.width, isManualGlucoseEntryEnabled: enableManualGlucoseEntry ) - - Task { @MainActor in - await viewModel.generateRecommendationAndStartObserving() - } - - viewModel.analyticsServicesManager = deviceManager.analyticsServicesManager + viewModel.deliveryDelegate = deviceManager + viewModel.analyticsServicesManager = analyticsServicesManager return viewModel }() @@ -1428,7 +1399,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let navigationWrapper = UINavigationController(rootViewController: hostingController) hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) - deviceManager.analyticsServicesManager.didDisplayBolusScreen() + analyticsServicesManager?.didDisplayBolusScreen() } private func createPreMealButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { @@ -1466,25 +1437,17 @@ final class StatusTableViewController: LoopChartsTableViewController { } @IBAction func premealButtonTapped(_ sender: UIBarButtonItem) { - togglePreMealMode(confirm: false) + togglePreMealMode() } - func togglePreMealMode(confirm: Bool = true) { + func togglePreMealMode() { if preMealMode == true { - if confirm { - let alert = UIAlertController(title: "Disable Pre-Meal Preset?", message: "This will remove any currently applied pre-meal preset.", preferredStyle: .alert) - alert.addCancelAction() - alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in - self?.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } - })) - present(alert, animated: true) - } else { - deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } - } + let alert = UIAlertController(title: "Disable Pre-Meal Preset?", message: "This will remove any currently applied pre-meal preset.", preferredStyle: .alert) + alert.addCancelAction() + alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in + self?.temporaryPresetsManager.preMealOverride = nil + })) + present(alert, animated: true) } else { presentPreMealModeAlertController() } @@ -1496,41 +1459,26 @@ final class StatusTableViewController: LoopChartsTableViewController { guard self.workoutMode != true else { // allow cell animation when switching between presets - self.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } + self.temporaryPresetsManager.scheduleOverride = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.deviceManager.loopManager.mutateSettings { settings in - settings.enablePreMealOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enablePreMealOverride(at: startDate, for: duration) } return } - - self.deviceManager.loopManager.mutateSettings { settings in - settings.enablePreMealOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enablePreMealOverride(at: startDate, for: duration) }) present(vc, animated: true, completion: nil) } - func presentCustomPresets(confirm: Bool = true) { + func presentCustomPresets() { if workoutMode == true { - if confirm { - let alert = UIAlertController(title: "Disable Preset?", message: "This will remove any currently applied preset.", preferredStyle: .alert) - alert.addCancelAction() - alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in - self?.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } - })) - present(alert, animated: true) - } else { - deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } - } + let alert = UIAlertController(title: "Disable Preset?", message: "This will remove any currently applied preset.", preferredStyle: .alert) + alert.addCancelAction() + alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in + self?.temporaryPresetsManager.scheduleOverride = nil + })) + present(alert, animated: true) } else { if FeatureFlags.sensitivityOverridesEnabled { performSegue(withIdentifier: OverrideSelectionViewController.className, sender: toolbarItems![6]) @@ -1546,27 +1494,21 @@ final class StatusTableViewController: LoopChartsTableViewController { guard self.preMealMode != true else { // allow cell animation when switching between presets - self.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } + self.temporaryPresetsManager.clearOverride(matching: .preMeal) DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.deviceManager.loopManager.mutateSettings { settings in - settings.enableLegacyWorkoutOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: duration) } return } - self.deviceManager.loopManager.mutateSettings { settings in - settings.enableLegacyWorkoutOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: duration) }) present(vc, animated: true, completion: nil) } @IBAction func toggleWorkoutMode(_ sender: UIBarButtonItem) { - presentCustomPresets(confirm: false) + presentCustomPresets() } @IBAction func onSettingsTapped(_ sender: UIBarButtonItem) { @@ -1585,7 +1527,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } : nil } let pumpViewModel = PumpManagerViewModel( - image: { [weak self] in self?.deviceManager.pumpManager?.smallImage }, + image: { [weak self] in (self?.deviceManager.pumpManager as? PumpManagerUI)?.smallImage }, name: { [weak self] in self?.deviceManager.pumpManager?.localizedTitle ?? "" }, isSetUp: { [weak self] in self?.deviceManager.pumpManager?.isOnboarded == true }, availableDevices: deviceManager.availablePumpManagers, @@ -1610,8 +1552,8 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.addCGMManager(withIdentifier: $0.identifier) }) let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, - availableServices: { [weak self] in self?.deviceManager.servicesManager.availableServices ?? [] }, - activeServices: { [weak self] in self?.deviceManager.servicesManager.activeServices ?? [] }, + availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, + activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }, delegate: self) let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, @@ -1620,12 +1562,12 @@ final class StatusTableViewController: LoopChartsTableViewController { pumpManagerSettingsViewModel: pumpViewModel, cgmManagerSettingsViewModel: cgmViewModel, servicesViewModel: servicesViewModel, - criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: deviceManager.criticalEventLogExportManager), - therapySettings: { [weak self] in self?.deviceManager.loopManager.therapySettings ?? TherapySettings() }, + criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), + therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - initialDosingEnabled: deviceManager.loopManager.settings.dosingEnabled, + initialDosingEnabled: self.settingsManager.settings.dosingEnabled, isClosedLoopAllowed: automaticDosingStatus.$isAutomaticDosingAllowed, - automaticDosingStrategy: deviceManager.loopManager.settings.automaticDosingStrategy, + automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceManager, @@ -1639,10 +1581,11 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func onPumpTapped() { - guard var settingsViewController = deviceManager.pumpManager?.settingsViewController(bluetoothProvider: deviceManager.bluetoothProvider, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, allowedInsulinTypes: deviceManager.allowedInsulinTypes) else { - // assert? + guard let pumpManager = deviceManager.pumpManager as? PumpManagerUI else { return } + + var settingsViewController = pumpManager.settingsViewController(bluetoothProvider: deviceManager.bluetoothProvider, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, allowedInsulinTypes: deviceManager.allowedInsulinTypes) settingsViewController.pumpManagerOnboardingDelegate = deviceManager settingsViewController.completionDelegate = self show(settingsViewController, sender: self) @@ -1690,7 +1633,7 @@ final class StatusTableViewController: LoopChartsTableViewController { // when HUD view is initialized, update loop completion HUD (e.g., icon and last loop completed) hudView.loopCompletionHUD.stateColors = .loopStatus hudView.loopCompletionHUD.loopIconClosed = automaticDosingStatus.automaticDosingEnabled - hudView.loopCompletionHUD.lastLoopCompleted = deviceManager.loopManager.lastLoopCompleted + hudView.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted hudView.cgmStatusHUD.stateColors = .cgmStatus hudView.cgmStatusHUD.tintColor = .label @@ -1698,8 +1641,10 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView.pumpStatusHUD.tintColor = .insulinTintColor refreshContext.update(with: .status) - log.debug("[reloadData] after hudView loaded") - reloadData() + Task { @MainActor in + log.debug("[reloadData] after hudView loaded") + await reloadData() + } } } @@ -1761,7 +1706,9 @@ final class StatusTableViewController: LoopChartsTableViewController { if let error = error { let alertController = UIAlertController(with: error) let manualLoopAction = UIAlertAction(title: NSLocalizedString("Retry", comment: "The button text for attempting a manual loop"), style: .default, handler: { _ in - self.deviceManager.refreshDeviceData() + Task { + await self.deviceManager.refreshDeviceData() + } }) alertController.addAction(manualLoopAction) present(alertController, animated: true) @@ -1855,9 +1802,11 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { rotateTimer?.invalidate() rotateTimer = Timer.scheduledTimer(withTimeInterval: rotateTimerTimeout, repeats: false) { [weak self] _ in - self?.rotateCount = 0 - self?.rotateTimer?.invalidate() - self?.rotateTimer = nil + Task { @MainActor [weak self] in + self?.rotateCount = 0 + self?.rotateTimer?.invalidate() + self?.rotateTimer = nil + } } rotateCount += 1 } @@ -1893,14 +1842,14 @@ final class StatusTableViewController: LoopChartsTableViewController { }) } actionSheet.addAction(UIAlertAction(title: "Remove Exports Directory", style: .default) { _ in - if let error = self.deviceManager.removeExportsDirectory() { + if let error = self.criticalEventLogExportManager.removeExportsDirectory() { self.presentError(error) } }) if FeatureFlags.mockTherapySettingsEnabled { actionSheet.addAction(UIAlertAction(title: "Mock Therapy Settings", style: .default) { _ in let therapySettings = TherapySettings.mockTherapySettings - self.deviceManager.loopManager.mutateSettings { settings in + self.settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout @@ -1974,7 +1923,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } presentActivityIndicator(title: "Simulated Core Data", message: "Generating simulated historical...") { dismissActivityIndicator in - self.deviceManager.purgeHistoricalCoreData() { error in + self.simulatedData.purgeHistoricalCoreData() { error in DispatchQueue.main.async { if let error = error { dismissActivityIndicator() @@ -1982,7 +1931,7 @@ final class StatusTableViewController: LoopChartsTableViewController { return } - self.deviceManager.generateSimulatedHistoricalCoreData() { error in + self.simulatedData.generateSimulatedHistoricalCoreData() { error in DispatchQueue.main.async { dismissActivityIndicator() if let error = error { @@ -2001,7 +1950,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } presentActivityIndicator(title: "Simulated Core Data", message: "Purging historical...") { dismissActivityIndicator in - self.deviceManager.purgeHistoricalCoreData() { error in + self.simulatedData.purgeHistoricalCoreData() { error in DispatchQueue.main.async { dismissActivityIndicator() if let error = error { @@ -2067,21 +2016,22 @@ extension StatusTableViewController: CompletionDelegate { extension StatusTableViewController: PumpManagerStatusObserver { func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { - dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update status", String(describing: type(of: pumpManager))) + Task { @MainActor in - basalDeliveryState = status.basalDeliveryState - bolusState = status.bolusState + basalDeliveryState = status.basalDeliveryState + bolusState = status.bolusState - refreshContext.update(with: .status) - reloadData(animated: true) + refreshContext.update(with: .status) + await self.reloadData(animated: true) + } } } extension StatusTableViewController: CGMManagerStatusObserver { func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) { refreshContext.update(with: .status) - reloadData(animated: true) + Task { await reloadData(animated: true) } } } @@ -2095,7 +2045,7 @@ extension StatusTableViewController: DoseProgressObserver { self.bolusProgressReporter = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { self.bolusState = .noBolus - self.reloadData(animated: true) + Task { await self.reloadData(animated: true) } }) } } @@ -2103,15 +2053,13 @@ extension StatusTableViewController: DoseProgressObserver { extension StatusTableViewController: OverrideSelectionViewControllerDelegate { func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didUpdatePresets presets: [TemporaryScheduleOverridePreset]) { - deviceManager.loopManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.overridePresets = presets } } func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = override - } + temporaryPresetsManager.scheduleOverride = override } func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmPreset preset: TemporaryScheduleOverridePreset) { @@ -2126,29 +2074,21 @@ extension StatusTableViewController: OverrideSelectionViewControllerDelegate { os_log(.error, "Failed to donate intent: %{public}@", String(describing: error)) } } - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = preset.createOverride(enactTrigger: .local) - } + temporaryPresetsManager.scheduleOverride = preset.createOverride(enactTrigger: .local) } func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didCancelOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = nil - } + temporaryPresetsManager.scheduleOverride = nil } } extension StatusTableViewController: AddEditOverrideTableViewControllerDelegate { func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didSaveOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = override - } + temporaryPresetsManager.scheduleOverride = override } func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didCancelOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = nil - } + temporaryPresetsManager.scheduleOverride = nil } } @@ -2172,9 +2112,9 @@ extension StatusTableViewController { extension StatusTableViewController { fileprivate func addPumpManager(withIdentifier identifier: String) { - guard let maximumBasalRate = deviceManager.loopManager.settings.maximumBasalRatePerHour, - let maxBolus = deviceManager.loopManager.settings.maximumBolus, - let basalSchedule = deviceManager.loopManager.settings.basalRateSchedule else + guard let maximumBasalRate = settingsManager.settings.maximumBasalRatePerHour, + let maxBolus = settingsManager.settings.maximumBolus, + let basalSchedule = settingsManager.settings.basalRateSchedule else { log.error("Failure to setup pump manager: incomplete settings") return @@ -2202,7 +2142,7 @@ extension StatusTableViewController { extension StatusTableViewController: BluetoothObserver { func bluetoothDidUpdateState(_ state: BluetoothState) { refreshContext.update(with: .status) - reloadData(animated: true) + Task { await reloadData(animated: true) } } } @@ -2213,13 +2153,13 @@ extension StatusTableViewController: SettingsViewModelDelegate { } func dosingEnabledChanged(_ value: Bool) { - deviceManager.loopManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.dosingEnabled = value } } func dosingStrategyChanged(_ strategy: AutomaticDosingStrategy) { - self.deviceManager.loopManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.automaticDosingStrategy = strategy } } @@ -2228,7 +2168,7 @@ extension StatusTableViewController: SettingsViewModelDelegate { // TODO: this dismiss here is temporary, until we know exactly where // we want this screen to belong in the navigation flow dismiss(animated: true) { - let vc = CommandResponseViewController.generateDiagnosticReport(deviceManager: self.deviceManager) + let vc = CommandResponseViewController.generateDiagnosticReport(reportGenerator: self.diagnosticReportGenerator) vc.title = NSLocalizedString("Issue Report", comment: "The view controller title for the issue report screen") self.show(vc, sender: nil) } @@ -2239,13 +2179,13 @@ extension StatusTableViewController: SettingsViewModelDelegate { extension StatusTableViewController: ServicesViewModelDelegate { func addService(withIdentifier identifier: String) { - switch deviceManager.servicesManager.setupService(withIdentifier: identifier) { + switch servicesManager.setupService(withIdentifier: identifier) { case .failure(let error): log.default("Failure to setup service with identifier '%{public}@': %{public}@", identifier, String(describing: error)) case .success(let success): switch success { case .userInteractionRequired(var setupViewController): - setupViewController.serviceOnboardingDelegate = deviceManager.servicesManager + setupViewController.serviceOnboardingDelegate = servicesManager setupViewController.completionDelegate = self show(setupViewController, sender: self) case .createdAndOnboarded: @@ -2255,7 +2195,7 @@ extension StatusTableViewController: ServicesViewModelDelegate { } func gotoService(withIdentifier identifier: String) { - guard let serviceUI = deviceManager.servicesManager.activeServices.first(where: { $0.pluginIdentifier == identifier }) as? ServiceUI else { + guard let serviceUI = servicesManager.activeServices.first(where: { $0.pluginIdentifier == identifier }) as? ServiceUI else { return } showServiceSettings(serviceUI) @@ -2263,7 +2203,7 @@ extension StatusTableViewController: ServicesViewModelDelegate { fileprivate func showServiceSettings(_ serviceUI: ServiceUI) { var settingsViewController = serviceUI.settingsViewController(colorPalette: .default) - settingsViewController.serviceOnboardingDelegate = deviceManager.servicesManager + settingsViewController.serviceOnboardingDelegate = servicesManager settingsViewController.completionDelegate = self show(settingsViewController, sender: self) } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index ff0408e1a2..38532c6495 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -19,41 +19,34 @@ import SwiftUI import SwiftCharts protocol BolusEntryViewModelDelegate: AnyObject { - - func withLoopState(do block: @escaping (LoopState) -> Void) - - func saveGlucose(sample: NewGlucoseSample) async -> StoredGlucoseSample? - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? , - completion: @escaping (_ result: Result) -> Void) - - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) - - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void) - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) + var settings: StoredSettings { get } + var scheduleOverride: TemporaryScheduleOverride? { get } + var preMealOverride: TemporaryScheduleOverride? { get } + var pumpInsulinType: InsulinType? { get } + var mostRecentGlucoseDataDate: Date? { get } + var mostRecentPumpDataDate: Date? { get } - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) - + func fetchData(for baseTime: Date, disablingPreMeal: Bool) async throws -> LoopAlgorithmInput + func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? func insulinActivityDuration(for type: InsulinType?) -> TimeInterval - var mostRecentGlucoseDataDate: Date? { get } - - var mostRecentPumpDataDate: Date? { get } - - var isPumpConfigured: Bool { get } - - var pumpInsulinType: InsulinType? { get } - - var settings: LoopSettings { get } + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async + func enactBolus(units: Double, activationType: BolusActivationType) async throws - var displayGlucosePreference: DisplayGlucosePreference { get } + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample?, + potentialCarbEntry: NewCarbEntry?, + originalCarbEntry: StoredCarbEntry? + ) async throws -> ManualBolusRecommendation? - func roundBolusVolume(units: Double) -> Double - func updateRemoteRecommendation() + func generatePrediction(input: LoopAlgorithmInput) throws -> [PredictedGlucoseValue] + + var activeInsulin: InsulinValue? { get } + var activeCarbs: CarbValue? { get } } @MainActor @@ -151,6 +144,7 @@ final class BolusEntryViewModel: ObservableObject { // MARK: - Seams private weak var delegate: BolusEntryViewModelDelegate? + weak var deliveryDelegate: DeliveryDelegate? private let now: () -> Date private let screenWidth: CGFloat private let debounceIntervalMilliseconds: Int @@ -215,8 +209,8 @@ final class BolusEntryViewModel: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] note in Task { - if let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, - let context = LoopDataManager.LoopUpdateContext(rawValue: rawContext), + if let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopUpdateContext.RawValue, + let context = LoopUpdateContext(rawValue: rawContext), context == .preferences { self?.updateSettings() @@ -233,8 +227,8 @@ final class BolusEntryViewModel: ObservableObject { .removeDuplicates() .debounce(for: .milliseconds(debounceIntervalMilliseconds), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) + Task { + await self?.updatePredictedGlucoseValues() } } .store(in: &cancellables) @@ -248,13 +242,11 @@ final class BolusEntryViewModel: ObservableObject { // Clear out any entered bolus whenever the glucose entry changes self.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) - self.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state, completion: { - // Ensure the manual glucose entry appears on the chart at the same time as the updated prediction - self?.updateGlucoseChartValues() - }) - - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + Task { + await self.updatePredictedGlucoseValues() + // Ensure the manual glucose entry appears on the chart at the same time as the updated prediction + self.updateGlucoseChartValues() + await self.updateRecommendedBolusAndNotice(isUpdatingFromUserInput: true) } if let manualGlucoseQuantity = manualGlucoseQuantity { @@ -301,21 +293,7 @@ final class BolusEntryViewModel: ObservableObject { } func saveCarbEntry(_ entry: NewCarbEntry, replacingEntry: StoredCarbEntry?) async -> StoredCarbEntry? { - guard let delegate = delegate else { - return nil - } - - return await withCheckedContinuation { continuation in - delegate.addCarbEntry(entry, replacing: replacingEntry) { result in - switch result { - case .success(let storedCarbEntry): - continuation.resume(returning: storedCarbEntry) - case .failure(let error): - self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) - continuation.resume(returning: nil) - } - } - } + try? await delegate?.addCarbEntry(entry, replacing: replacingEntry) } // returns true if action succeeded @@ -331,16 +309,16 @@ final class BolusEntryViewModel: ObservableObject { // returns true if no errors func saveAndDeliver() async -> Bool { - guard delegate?.isPumpConfigured ?? false else { - presentAlert(.noPumpManagerConfigured) + guard let delegate, let deliveryDelegate else { + assertionFailure("Missing Delegate") return false } - guard let delegate = delegate else { - assertionFailure("Missing BolusEntryViewModelDelegate") + guard deliveryDelegate.isPumpConfigured else { + presentAlert(.noPumpManagerConfigured) return false } - + guard let maximumBolus = maximumBolus else { presentAlert(.noMaxBolusConfigured) return false @@ -351,7 +329,8 @@ final class BolusEntryViewModel: ObservableObject { return false } - let amountToDeliver = delegate.roundBolusVolume(units: enteredBolusAmount) + let amountToDeliver = deliveryDelegate.roundBolusVolume(units: enteredBolusAmount) + guard enteredBolusAmount == 0 || amountToDeliver > 0 else { presentAlert(.bolusTooSmall) return false @@ -378,14 +357,10 @@ final class BolusEntryViewModel: ObservableObject { } } - defer { - delegate.updateRemoteRecommendation() - } - - if let manualGlucoseSample = manualGlucoseSample { - if let glucoseValue = await delegate.saveGlucose(sample: manualGlucoseSample) { - dosingDecision.manualGlucoseSample = glucoseValue - } else { + if let manualGlucoseSample { + do { + dosingDecision.manualGlucoseSample = try await delegate.saveGlucose(sample: manualGlucoseSample) + } catch { presentAlert(.manualGlucoseEntryPersistenceFailure) return false } @@ -417,20 +392,21 @@ final class BolusEntryViewModel: ObservableObject { dosingDecision.manualBolusRequested = amountToDeliver let now = self.now() - delegate.storeManualBolusDosingDecision(dosingDecision, withDate: now) + await delegate.storeManualBolusDosingDecision(dosingDecision, withDate: now) if amountToDeliver > 0 { savedPreMealOverride = nil - delegate.enactBolus(units: amountToDeliver, activationType: activationType, completion: { _ in - self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) - }) + do { + try await delegate.enactBolus(units: amountToDeliver, activationType: activationType) + } catch { + log.error("Failed to store bolus: %{public}@", String(describing: error)) + } + self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) } return true } private func presentAlert(_ alert: Alert) { - dispatchPrecondition(condition: .onQueue(.main)) - // As of iOS 13.6 / Xcode 11.6, swapping out an alert while one is active crashes SwiftUI. guard activeAlert == nil else { return @@ -497,30 +473,23 @@ final class BolusEntryViewModel: ObservableObject { // MARK: - Data upkeep func update() async { - dispatchPrecondition(condition: .onQueue(.main)) - // Prevent any UI updates after a bolus has been initiated. guard !enacting else { return } + self.activeCarbs = delegate?.activeCarbs?.quantity + self.activeInsulin = delegate?.activeInsulin?.quantity + dosingDecision.insulinOnBoard = delegate?.activeInsulin + disableManualGlucoseEntryIfNecessary() updateChartDateInterval() - updateStoredGlucoseValues() - await updatePredictionAndRecommendation() - - if let iob = await getInsulinOnBoard() { - self.activeInsulin = HKQuantity(unit: .internationalUnit(), doubleValue: iob.value) - self.dosingDecision.insulinOnBoard = iob - } else { - self.activeInsulin = nil - self.dosingDecision.insulinOnBoard = nil - } + await updateRecommendedBolusAndNotice(isUpdatingFromUserInput: false) + await updatePredictedGlucoseValues() + updateGlucoseChartValues() } private func disableManualGlucoseEntryIfNecessary() { - dispatchPrecondition(condition: .onQueue(.main)) - if isManualGlucoseEntryEnabled, !isGlucoseDataStale { isManualGlucoseEntryEnabled = false manualGlucoseQuantity = nil @@ -529,28 +498,7 @@ final class BolusEntryViewModel: ObservableObject { } } - private func updateStoredGlucoseValues() { - let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now()) - let chartStartDate = chartDateInterval.start - delegate?.getGlucoseSamples(start: min(historicalGlucoseStartDate, chartStartDate), end: nil) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - self.storedGlucoseValues = [] - self.dosingDecision.historicalGlucose = [] - case .success(let samples): - self.storedGlucoseValues = samples.filter { $0.startDate >= chartStartDate } - self.dosingDecision.historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } - } - self.updateGlucoseChartValues() - } - } - } - private func updateGlucoseChartValues() { - dispatchPrecondition(condition: .onQueue(.main)) var chartGlucoseValues = storedGlucoseValues if let manualGlucoseSample = manualGlucoseSample { @@ -561,110 +509,59 @@ final class BolusEntryViewModel: ObservableObject { } /// - NOTE: `completion` is invoked on the main queue after predicted glucose values are updated - private func updatePredictedGlucoseValues(from state: LoopState, completion: @escaping () -> Void = {}) { - dispatchPrecondition(condition: .notOnQueue(.main)) - - let (manualGlucoseSample, enteredBolus, insulinType) = DispatchQueue.main.sync { (self.manualGlucoseSample, self.enteredBolus, delegate?.pumpInsulinType) } - - let enteredBolusDose = DoseEntry(type: .bolus, startDate: Date(), value: enteredBolus.doubleValue(for: .internationalUnit()), unit: .units, insulinType: insulinType) + private func updatePredictedGlucoseValues() async { + guard let delegate else { + return + } - let predictedGlucoseValues: [PredictedGlucoseValue] do { - if let manualGlucoseEntry = manualGlucoseSample { - predictedGlucoseValues = try state.predictGlucoseFromManualGlucose( - manualGlucoseEntry, - potentialBolus: enteredBolusDose, - potentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - includingPendingInsulin: true, - considerPositiveVelocityAndRC: true - ) - } else { - predictedGlucoseValues = try state.predictGlucose( - using: .all, - potentialBolus: enteredBolusDose, - potentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - includingPendingInsulin: true, - considerPositiveVelocityAndRC: true - ) - } - } catch { - predictedGlucoseValues = [] - } + let startDate = now() + var input = try await delegate.fetchData(for: startDate, disablingPreMeal: potentialCarbEntry != nil) + + let enteredBolusDose = DoseEntry( + type: .bolus, + startDate: startDate, + value: enteredBolus.doubleValue(for: .internationalUnit()), + unit: .units, + insulinType: deliveryDelegate?.pumpInsulinType, + manuallyEntered: true + ) - DispatchQueue.main.async { - self.predictedGlucoseValues = predictedGlucoseValues - self.dosingDecision.predictedGlucose = predictedGlucoseValues - completion() - } - } + storedGlucoseValues = input.glucoseHistory - private func getInsulinOnBoard() async -> InsulinValue? { - guard let delegate = delegate else { - return nil - } + // Add potential bolus, carbs, manual glucose + input = input + .addingDose(dose: enteredBolusDose) + .addingGlucoseSample(sample: manualGlucoseSample) + .removingCarbEntry(carbEntry: originalCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry) - return await withCheckedContinuation { continuation in - delegate.insulinOnBoard(at: Date()) { result in - switch result { - case .success(let iob): - continuation.resume(returning: iob) - case .failure: - continuation.resume(returning: nil) - } - } - } - } - - private func updatePredictionAndRecommendation() async { - guard let delegate = delegate else { - return - } - return await withCheckedContinuation { continuation in - delegate.withLoopState { [weak self] state in - self?.updateCarbsOnBoard(from: state) - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: false) - self?.updatePredictedGlucoseValues(from: state) - continuation.resume() - } + let prediction = try delegate.generatePrediction(input: input) + predictedGlucoseValues = prediction + dosingDecision.predictedGlucose = prediction + } catch { + predictedGlucoseValues = [] + dosingDecision.predictedGlucose = [] } - } - private func updateCarbsOnBoard(from state: LoopState) { - delegate?.carbsOnBoard(at: Date(), effectVelocities: state.insulinCounteractionEffects) { result in - DispatchQueue.main.async { - switch result { - case .success(let carbValue): - self.activeCarbs = carbValue.quantity - self.dosingDecision.carbsOnBoard = carbValue - case .failure: - self.activeCarbs = nil - self.dosingDecision.carbsOnBoard = nil - } - } - } } - private func updateRecommendedBolusAndNotice(from state: LoopState, isUpdatingFromUserInput: Bool) { - dispatchPrecondition(condition: .notOnQueue(.main)) + private func updateRecommendedBolusAndNotice(isUpdatingFromUserInput: Bool) async { guard let delegate = delegate else { assertionFailure("Missing BolusEntryViewModelDelegate") return } - let now = Date() var recommendation: ManualBolusRecommendation? let recommendedBolus: HKQuantity? let notice: Notice? do { - recommendation = try computeBolusRecommendation(from: state) + recommendation = try await computeBolusRecommendation() + + if let recommendation, let deliveryDelegate { + recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: deliveryDelegate.roundBolusVolume(units: recommendation.amount)) - if let recommendation = recommendation { - recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: recommendation.amount)) - //recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) - switch recommendation.notice { case .glucoseBelowSuspendThreshold: if let suspendThreshold = delegate.settings.suspendThreshold { @@ -698,53 +595,41 @@ final class BolusEntryViewModel: ObservableObject { } } - DispatchQueue.main.async { - let priorRecommendedBolus = self.recommendedBolus - self.recommendedBolus = recommendedBolus - self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } - self.activeNotice = notice + let priorRecommendedBolus = self.recommendedBolus + self.recommendedBolus = recommendedBolus + self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now()) } + self.activeNotice = notice - if priorRecommendedBolus != nil, - priorRecommendedBolus != recommendedBolus, - !self.enacting, - !isUpdatingFromUserInput - { - self.presentAlert(.recommendationChanged) - } + if priorRecommendedBolus != nil, + priorRecommendedBolus != recommendedBolus, + !self.enacting, + !isUpdatingFromUserInput + { + self.presentAlert(.recommendationChanged) } } - private func computeBolusRecommendation(from state: LoopState) throws -> ManualBolusRecommendation? { - dispatchPrecondition(condition: .notOnQueue(.main)) - - let manualGlucoseSample = DispatchQueue.main.sync { self.manualGlucoseSample } - if manualGlucoseSample != nil { - return try state.recommendBolusForManualGlucose( - manualGlucoseSample!, - consideringPotentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses - ) - } else { - return try state.recommendBolus( - consideringPotentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses - ) + private func computeBolusRecommendation() async throws -> ManualBolusRecommendation? { + guard let delegate else { + return nil } + + return try await delegate.recommendManualBolus( + manualGlucoseSample: manualGlucoseSample, + potentialCarbEntry: potentialCarbEntry, + originalCarbEntry: originalCarbEntry + ) } func updateSettings() { - dispatchPrecondition(condition: .onQueue(.main)) - guard let delegate = delegate else { return } targetGlucoseSchedule = delegate.settings.glucoseTargetRangeSchedule // Pre-meal override should be ignored if we have carbs (LOOP-1964) - preMealOverride = potentialCarbEntry == nil ? delegate.settings.preMealOverride : nil - scheduleOverride = delegate.settings.scheduleOverride + preMealOverride = potentialCarbEntry == nil ? delegate.preMealOverride : nil + scheduleOverride = delegate.scheduleOverride if preMealOverride?.hasFinished() == true { preMealOverride = nil @@ -761,15 +646,13 @@ final class BolusEntryViewModel: ObservableObject { dosingDecision.scheduleOverride = scheduleOverride if scheduleOverride != nil || preMealOverride != nil { - dosingDecision.glucoseTargetRangeSchedule = delegate.settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) + dosingDecision.glucoseTargetRangeSchedule = delegate.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) } else { dosingDecision.glucoseTargetRangeSchedule = targetGlucoseSchedule } } private func updateChartDateInterval() { - dispatchPrecondition(condition: .onQueue(.main)) - // How far back should we show data? Use the screen size as a guide. let viewMarginInset: CGFloat = 14 let availableWidth = screenWidth - chartManager.fixedHorizontalMargin - 2 * viewMarginInset diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index f271793d1c..ee0cbe12bc 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -12,8 +12,8 @@ import HealthKit import Combine protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate { - var analyticsServicesManager: AnalyticsServicesManager { get } var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes { get } + func scheduleOverrideEnabled(at date: Date) -> Bool } final class CarbEntryViewModel: ObservableObject { @@ -83,7 +83,9 @@ final class CarbEntryViewModel: ObservableObject { @Published var selectedFavoriteFoodIndex = -1 weak var delegate: CarbEntryViewModelDelegate? - + weak var analyticsServicesManager: AnalyticsServicesManager? + weak var deliveryDelegate: DeliveryDelegate? + private lazy var cancellables = Set() /// Initalizer for when`CarbEntryView` is presented from the home screen @@ -189,14 +191,12 @@ final class CarbEntryViewModel: ObservableObject { potentialCarbEntry: updatedCarbEntry, selectedCarbAbsorptionTimeEmoji: selectedDefaultAbsorptionTimeEmoji ) - Task { - await viewModel.generateRecommendationAndStartObserving() - } - viewModel.analyticsServicesManager = delegate?.analyticsServicesManager + viewModel.analyticsServicesManager = analyticsServicesManager + viewModel.deliveryDelegate = deliveryDelegate bolusViewModel = viewModel - delegate?.analyticsServicesManager.didDisplayBolusScreen() + analyticsServicesManager?.didDisplayBolusScreen() } func clearAlert() { @@ -290,13 +290,16 @@ final class CarbEntryViewModel: ObservableObject { } private func checkIfOverrideEnabled() { - if let managerSettings = delegate?.settings, - managerSettings.scheduleOverrideEnabled(at: Date()), - let overrideSettings = managerSettings.scheduleOverride?.settings, - overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 { - self.warnings.insert(.overrideInProgress) + guard let delegate else { + return } - else { + + if delegate.scheduleOverrideEnabled(at: Date()), + let overrideSettings = delegate.scheduleOverride?.settings, + overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 + { + self.warnings.insert(.overrideInProgress) + } else { self.warnings.remove(.overrideInProgress) } } diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift index 5fcd966c62..269cd3b735 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -17,37 +17,22 @@ import LoopKitUI import LoopUI import SwiftUI -protocol ManualDoseViewModelDelegate: AnyObject { - - func withLoopState(do block: @escaping (LoopState) -> Void) - - func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) - - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval +enum ManualEntryDoseViewModelError: Error { + case notAuthenticated +} - var mostRecentGlucoseDataDate: Date? { get } - - var mostRecentPumpDataDate: Date? { get } - - var isPumpConfigured: Bool { get } - - var preferredGlucoseUnit: HKUnit { get } - +protocol ManualDoseViewModelDelegate: AnyObject { + var algorithmDisplayState: AlgorithmDisplayState { get async } var pumpInsulinType: InsulinType? { get } + var settings: StoredSettings { get } + var scheduleOverride: TemporaryScheduleOverride? { get } - var settings: LoopSettings { get } + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) async + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval } +@MainActor final class ManualEntryDoseViewModel: ObservableObject { - - var authenticate: AuthenticationChallenge = LocalAuthentication.deviceOwnerCheck - // MARK: - State @Published var glucoseValues: [GlucoseValue] = [] // stored glucose values @@ -83,27 +68,40 @@ final class ManualEntryDoseViewModel: ObservableObject { @Published var selectedDoseDate: Date = Date() var insulinTypePickerOptions: [InsulinType] - + // MARK: - Seams private weak var delegate: ManualDoseViewModelDelegate? private let now: () -> Date private let screenWidth: CGFloat private let debounceIntervalMilliseconds: Int private let uuidProvider: () -> String - + + var authenticationHandler: (String) async -> Bool = { message in + return await withCheckedContinuation { continuation in + LocalAuthentication.deviceOwnerCheck(message) { result in + switch result { + case .success: + continuation.resume(returning: true) + case .failure: + continuation.resume(returning: false) + } + } + } + } + + // MARK: - Initialization init( delegate: ManualDoseViewModelDelegate, now: @escaping () -> Date = { Date() }, - screenWidth: CGFloat = UIScreen.main.bounds.width, debounceIntervalMilliseconds: Int = 400, uuidProvider: @escaping () -> String = { UUID().uuidString }, timeZone: TimeZone? = nil ) { self.delegate = delegate self.now = now - self.screenWidth = screenWidth + self.screenWidth = UIScreen.main.bounds.width self.debounceIntervalMilliseconds = debounceIntervalMilliseconds self.uuidProvider = uuidProvider @@ -138,9 +136,7 @@ final class ManualEntryDoseViewModel: ObservableObject { .removeDuplicates() .debounce(for: .milliseconds(debounceIntervalMilliseconds), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) - } + self?.updateTriggered() } .store(in: &cancellables) } @@ -150,9 +146,7 @@ final class ManualEntryDoseViewModel: ObservableObject { .removeDuplicates() .debounce(for: .milliseconds(400), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) - } + self?.updateTriggered() } .store(in: &cancellables) } @@ -162,41 +156,41 @@ final class ManualEntryDoseViewModel: ObservableObject { .removeDuplicates() .debounce(for: .milliseconds(400), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) - } + self?.updateTriggered() } .store(in: &cancellables) } + private func updateTriggered() { + Task { @MainActor in + await updateFromLoopState() + } + } + + // MARK: - View API - func saveManualDose(onSuccess completion: @escaping () -> Void) { + func saveManualDose() async throws { + guard enteredBolus.doubleValue(for: .internationalUnit()) > 0 else { + return + } + // Authenticate before saving anything - if enteredBolus.doubleValue(for: .internationalUnit()) > 0 { - let message = String(format: NSLocalizedString("Authenticate to log %@ Units", comment: "The message displayed during a device authentication prompt to log an insulin dose"), enteredBolusAmountString) - authenticate(message) { - switch $0 { - case .success: - self.continueSaving(onSuccess: completion) - case .failure: - break - } - } - } else { - completion() + let message = String(format: NSLocalizedString("Authenticate to log %@ Units", comment: "The message displayed during a device authentication prompt to log an insulin dose"), enteredBolusAmountString) + + if !(await authenticationHandler(message)) { + throw ManualEntryDoseViewModelError.notAuthenticated } + await self.continueSaving() } - private func continueSaving(onSuccess completion: @escaping () -> Void) { + private func continueSaving() async { let doseVolume = enteredBolus.doubleValue(for: .internationalUnit()) guard doseVolume > 0 else { - completion() return } - delegate?.addManuallyEnteredDose(startDate: selectedDoseDate, units: doseVolume, insulinType: selectedInsulinType) - completion() + await delegate?.addManuallyEnteredDose(startDate: selectedDoseDate, units: doseVolume, insulinType: selectedInsulinType) } private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) @@ -218,117 +212,53 @@ final class ManualEntryDoseViewModel: ObservableObject { // MARK: - Data upkeep private func update() { - dispatchPrecondition(condition: .onQueue(.main)) // Prevent any UI updates after a bolus has been initiated. guard !isInitiatingSaveOrBolus else { return } updateChartDateInterval() - updateStoredGlucoseValues() - updateFromLoopState() - updateActiveInsulin() - } - - private func updateStoredGlucoseValues() { - delegate?.getGlucoseSamples(start: chartDateInterval.start, end: nil) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - self.storedGlucoseValues = [] - case .success(let samples): - self.storedGlucoseValues = samples - } - self.updateGlucoseChartValues() - } + Task { + await updateFromLoopState() } } - private func updateGlucoseChartValues() { - dispatchPrecondition(condition: .onQueue(.main)) + private func updateFromLoopState() async { + guard let delegate = delegate else { + return + } - self.glucoseValues = storedGlucoseValues - } + let state = await delegate.algorithmDisplayState - /// - NOTE: `completion` is invoked on the main queue after predicted glucose values are updated - private func updatePredictedGlucoseValues(from state: LoopState, completion: @escaping () -> Void = {}) { - dispatchPrecondition(condition: .notOnQueue(.main)) + let enteredBolusDose = DoseEntry(type: .bolus, startDate: selectedDoseDate, value: enteredBolus.doubleValue(for: .internationalUnit()), unit: .units, insulinType: selectedInsulinType) - let (enteredBolus, doseDate, insulinType) = DispatchQueue.main.sync { (self.enteredBolus, self.selectedDoseDate, self.selectedInsulinType) } - - let enteredBolusDose = DoseEntry(type: .bolus, startDate: doseDate, value: enteredBolus.doubleValue(for: .internationalUnit()), unit: .units, insulinType: insulinType) - - let predictedGlucoseValues: [PredictedGlucoseValue] - do { - predictedGlucoseValues = try state.predictGlucose( - using: .all, - potentialBolus: enteredBolusDose, - potentialCarbEntry: nil, - replacingCarbEntry: nil, - includingPendingInsulin: true, - considerPositiveVelocityAndRC: true - ) - } catch { - predictedGlucoseValues = [] - } + self.activeInsulin = state.activeInsulin?.quantity + self.activeCarbs = state.activeCarbs?.quantity - DispatchQueue.main.async { - self.predictedGlucoseValues = predictedGlucoseValues - completion() - } - } - private func updateActiveInsulin() { - delegate?.insulinOnBoard(at: Date()) { [weak self] result in - guard let self = self else { return } + if let input = state.input { + self.storedGlucoseValues = input.glucoseHistory - DispatchQueue.main.async { - switch result { - case .success(let iob): - self.activeInsulin = HKQuantity(unit: .internationalUnit(), doubleValue: iob.value) - case .failure: - self.activeInsulin = nil - } - } - } - } - - private func updateFromLoopState() { - delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) - self?.updateCarbsOnBoard(from: state) - DispatchQueue.main.async { - self?.updateSettings() + do { + predictedGlucoseValues = try input + .addingDose(dose: enteredBolusDose) + .predictGlucose() + } catch { + predictedGlucoseValues = [] } + } else { + predictedGlucoseValues = [] } - } - private func updateCarbsOnBoard(from state: LoopState) { - delegate?.carbsOnBoard(at: Date(), effectVelocities: state.insulinCounteractionEffects) { result in - DispatchQueue.main.async { - switch result { - case .success(let carbValue): - self.activeCarbs = carbValue.quantity - case .failure: - self.activeCarbs = nil - } - } - } + updateSettings() } - private func updateSettings() { - dispatchPrecondition(condition: .onQueue(.main)) - - guard let delegate = delegate else { + guard let delegate else { return } - glucoseUnit = delegate.preferredGlucoseUnit - targetGlucoseSchedule = delegate.settings.glucoseTargetRangeSchedule - scheduleOverride = delegate.settings.scheduleOverride + scheduleOverride = delegate.scheduleOverride if preMealOverride?.hasFinished() == true { preMealOverride = nil diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index d4b48766b3..16f5a71f72 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -164,6 +164,7 @@ public class SettingsViewModel: ObservableObject { } // For previews only +@MainActor extension SettingsViewModel { fileprivate class FakeClosedLoopAllowedPublisher { @Published var mockIsClosedLoopAllowed: Bool = false diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index f803bfa595..ed13799b0f 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -18,30 +18,33 @@ import LocalAuthentication protocol SimpleBolusViewModelDelegate: AnyObject { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) - - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? , - completion: @escaping (_ result: Result) -> Void) + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async - func enactBolus(units: Double, activationType: BolusActivationType) + func enactBolus(units: Double, activationType: BolusActivationType) async throws - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) + func insulinOnBoard(at date: Date) async -> InsulinValue? func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? - var displayGlucosePreference: DisplayGlucosePreference { get } - - var maximumBolus: Double { get } + var maximumBolus: Double? { get } - var suspendThreshold: HKQuantity { get } + var suspendThreshold: HKQuantity? { get } } +@MainActor class SimpleBolusViewModel: ObservableObject { var authenticate: AuthenticationChallenge = LocalAuthentication.deviceOwnerCheck + // For testing + func setAuthenticationMethdod(_ authenticate: @escaping AuthenticationChallenge) { + self.authenticate = authenticate + } + enum Alert: Int { case carbEntryPersistenceFailure case manualGlucoseEntryPersistenceFailure @@ -93,7 +96,7 @@ class SimpleBolusViewModel: ObservableObject { _manualGlucoseString = "" return _manualGlucoseString } - self._manualGlucoseString = delegate.displayGlucosePreference.format(manualGlucoseQuantity, includeUnit: false) + self._manualGlucoseString = displayGlucosePreference.format(manualGlucoseQuantity, includeUnit: false) } return _manualGlucoseString @@ -104,7 +107,11 @@ class SimpleBolusViewModel: ObservableObject { } private func updateNotice() { - + + guard let maxBolus = delegate.maximumBolus, let suspendThreshold = delegate.suspendThreshold else { + return + } + if let carbs = self.carbQuantity { guard carbs <= LoopConstants.maxCarbEntryQuantity else { activeNotice = .carbohydrateEntryTooLarge @@ -113,7 +120,7 @@ class SimpleBolusViewModel: ObservableObject { } if let bolus = bolus { - guard bolus.doubleValue(for: .internationalUnit()) <= delegate.maximumBolus else { + guard bolus.doubleValue(for: .internationalUnit()) <= maxBolus else { activeNotice = .maxBolusExceeded return } @@ -141,7 +148,7 @@ class SimpleBolusViewModel: ObservableObject { case let g? where g < suspendThreshold: activeNotice = .glucoseBelowSuspendThreshold default: - if let recommendation = recommendation, recommendation > delegate.maximumBolus { + if let recommendation = recommendation, recommendation > maxBolus { activeNotice = .recommendationExceedsMaxBolus } else { activeNotice = nil @@ -152,7 +159,7 @@ class SimpleBolusViewModel: ObservableObject { @Published private var _manualGlucoseString: String = "" { didSet { - guard let manualGlucoseValue = delegate.displayGlucosePreference.formatter.numberFormatter.number(from: _manualGlucoseString)?.doubleValue + guard let manualGlucoseValue = displayGlucosePreference.formatter.numberFormatter.number(from: _manualGlucoseString)?.doubleValue else { manualGlucoseQuantity = nil return @@ -160,7 +167,7 @@ class SimpleBolusViewModel: ObservableObject { // if needed update manualGlucoseQuantity and related activeNotice if manualGlucoseQuantity == nil || - _manualGlucoseString != delegate.displayGlucosePreference.format(manualGlucoseQuantity!, includeUnit: false) + _manualGlucoseString != displayGlucosePreference.format(manualGlucoseQuantity!, includeUnit: false) { manualGlucoseQuantity = HKQuantity(unit: cachedDisplayGlucoseUnit, doubleValue: manualGlucoseValue) updateNotice() @@ -195,16 +202,18 @@ class SimpleBolusViewModel: ObservableObject { } return false } + + let displayGlucosePreference: DisplayGlucosePreference + + var displayGlucoseUnit: HKUnit { return displayGlucosePreference.unit } - var displayGlucoseUnit: HKUnit { return delegate.displayGlucosePreference.unit } - - var suspendThreshold: HKQuantity { return delegate.suspendThreshold } + var suspendThreshold: HKQuantity? { return delegate.suspendThreshold } private var recommendation: Double? = nil { didSet { - if let recommendation = recommendation { + if let recommendation = recommendation, let maxBolus = delegate.maximumBolus { recommendedBolus = Self.doseAmountFormatter.string(from: recommendation)! - enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, delegate.maximumBolus))! + enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, maxBolus))! } else { recommendedBolus = NSLocalizedString("–", comment: "String denoting lack of a recommended bolus amount in the simple bolus calculator") enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)! @@ -271,14 +280,18 @@ class SimpleBolusViewModel: ObservableObject { private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) var maximumBolusAmountString: String { - let maxBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.maximumBolus) + guard let maxBolus = delegate.maximumBolus else { + return "" + } + let maxBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) return bolusVolumeFormatter.string(from: maxBolusQuantity)! } - init(delegate: SimpleBolusViewModelDelegate, displayMealEntry: Bool) { + init(delegate: SimpleBolusViewModelDelegate, displayMealEntry: Bool, displayGlucosePreference: DisplayGlucosePreference) { self.delegate = delegate self.displayMealEntry = displayMealEntry - cachedDisplayGlucoseUnit = delegate.displayGlucosePreference.unit + self.displayGlucosePreference = displayGlucosePreference + cachedDisplayGlucoseUnit = displayGlucosePreference.unit enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)! updateRecommendation() dosingDecision = BolusDosingDecision(for: .simpleBolus) @@ -323,121 +336,79 @@ class SimpleBolusViewModel: ObservableObject { } } - func saveAndDeliver(completion: @escaping (Bool) -> Void) { - + func saveAndDeliver() async -> Bool { + let saveDate = Date() - // Authenticate the bolus before saving anything - func authenticateIfNeeded(_ completion: @escaping (Bool) -> Void) { - if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { - let message = String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), enteredBolusString) + // Authenticate if needed + if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { + let message = String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), enteredBolusString) + let authenticated = await withCheckedContinuation { continuation in authenticate(message) { switch $0 { case .success: - completion(true) + continuation.resume(returning: true) case .failure: - completion(false) + continuation.resume(returning: false) } } - } else { - completion(true) } - } - - func saveManualGlucose(_ completion: @escaping (Bool) -> Void) { - if let manualGlucoseQuantity = manualGlucoseQuantity { - let manualGlucoseSample = NewGlucoseSample(date: saveDate, - quantity: manualGlucoseQuantity, - condition: nil, // All manual glucose entries are assumed to have no condition. - trend: nil, // All manual glucose entries are assumed to have no trend. - trendRate: nil, // All manual glucose entries are assumed to have no trend rate. - isDisplayOnly: false, - wasUserEntered: true, - syncIdentifier: UUID().uuidString) - delegate.addGlucose([manualGlucoseSample]) { result in - DispatchQueue.main.async { - switch result { - case .failure(let error): - self.presentAlert(.manualGlucoseEntryPersistenceFailure) - self.log.error("Failed to add manual glucose entry: %{public}@", String(describing: error)) - completion(false) - case .success(let storedSamples): - self.dosingDecision?.manualGlucoseSample = storedSamples.first - completion(true) - } - } - } - } else { - completion(true) + if !authenticated { + return false } } - - func saveCarbs(_ completion: @escaping (Bool) -> Void) { - if let carbs = carbQuantity { - - let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) - interaction.donate { [weak self] (error) in - if let error = error { - self?.log.error("Failed to donate intent: %{public}@", String(describing: error)) - } - } - - let carbEntry = NewCarbEntry(date: saveDate, quantity: carbs, startDate: saveDate, foodType: nil, absorptionTime: nil) - - delegate.addCarbEntry(carbEntry, replacing: nil) { result in - DispatchQueue.main.async { - switch result { - case .failure(let error): - self.presentAlert(.carbEntryPersistenceFailure) - self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) - completion(false) - case .success(let storedEntry): - self.dosingDecision?.carbEntry = storedEntry - completion(true) - } - } - } - } else { - completion(true) + + if let manualGlucoseQuantity = manualGlucoseQuantity { + let manualGlucoseSample = NewGlucoseSample(date: saveDate, + quantity: manualGlucoseQuantity, + condition: nil, // All manual glucose entries are assumed to have no condition. + trend: nil, // All manual glucose entries are assumed to have no trend. + trendRate: nil, // All manual glucose entries are assumed to have no trend rate. + isDisplayOnly: false, + wasUserEntered: true, + syncIdentifier: UUID().uuidString) + do { + self.dosingDecision?.manualGlucoseSample = try await delegate.saveGlucose(sample: manualGlucoseSample) + } catch { + self.presentAlert(.manualGlucoseEntryPersistenceFailure) + self.log.error("Failed to add manual glucose entry: %{public}@", String(describing: error)) + return false } } - func enactBolus() { - if let bolusVolume = bolus?.doubleValue(for: .internationalUnit()), bolusVolume > 0 { - delegate.enactBolus(units: bolusVolume, activationType: .activationTypeFor(recommendedAmount: recommendation, bolusAmount: bolusVolume)) - dosingDecision?.manualBolusRequested = bolusVolume + if let carbs = carbQuantity { + let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) + do { + try await interaction.donate() + } catch { + log.error("Failed to donate intent: %{public}@", String(describing: error)) } - } - - func saveBolusDecision() { - if let decision = dosingDecision, let recommendationDate = recommendationDate { - delegate.storeManualBolusDosingDecision(decision, withDate: recommendationDate) + + let carbEntry = NewCarbEntry(date: saveDate, quantity: carbs, startDate: saveDate, foodType: nil, absorptionTime: nil) + + do { + self.dosingDecision?.carbEntry = try await delegate.addCarbEntry(carbEntry, replacing: nil) + } catch { + self.presentAlert(.carbEntryPersistenceFailure) + self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) + return false } } - - func finishWithResult(_ success: Bool) { - saveBolusDecision() - completion(success) - } - - authenticateIfNeeded { (success) in - if success { - saveManualGlucose { (success) in - if success { - saveCarbs { (success) in - if success { - enactBolus() - } - finishWithResult(success) - } - } else { - finishWithResult(false) - } - } - } else { - finishWithResult(false) + + if let bolusVolume = bolus?.doubleValue(for: .internationalUnit()), bolusVolume > 0 { + do { + try await delegate.enactBolus(units: bolusVolume, activationType: .activationTypeFor(recommendedAmount: recommendation, bolusAmount: bolusVolume)) + dosingDecision?.manualBolusRequested = bolusVolume + } catch { + log.error("Unable to enact bolus: %{public}@", String(describing: error)) + return false } } + + if let decision = dosingDecision, let recommendationDate = recommendationDate { + await delegate.storeManualBolusDosingDecision(decision, withDate: recommendationDate) + } + return true } private func presentAlert(_ alert: Alert) { diff --git a/Loop/View Models/VersionUpdateViewModel.swift b/Loop/View Models/VersionUpdateViewModel.swift index fa2b87e6c5..72267c6651 100644 --- a/Loop/View Models/VersionUpdateViewModel.swift +++ b/Loop/View Models/VersionUpdateViewModel.swift @@ -12,6 +12,7 @@ import LoopKit import SwiftUI import LoopKitUI +@MainActor public class VersionUpdateViewModel: ObservableObject { @Published var versionUpdate: VersionUpdate? diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 4dd0c11a52..1d4d1e2c2a 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -73,6 +73,9 @@ struct BolusEntryView: View { enteredBolusStringBinding.wrappedValue = newEnteredBolusString } } + .task { + await self.viewModel.generateRecommendationAndStartObserving() + } } } diff --git a/Loop/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift index e81dccdabb..d361b48ad3 100644 --- a/Loop/Views/ManualEntryDoseView.swift +++ b/Loop/Views/ManualEntryDoseView.swift @@ -239,7 +239,13 @@ struct ManualEntryDoseView: View { private var actionButton: some View { Button( action: { - self.viewModel.saveManualDose(onSuccess: self.dismiss) + Task { + do { + try await self.viewModel.saveManualDose() + self.dismiss() + } catch { + } + } }, label: { return Text("Log Dose", comment: "Button text to log a dose") diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index aa7546c6f9..e0b413df53 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -252,12 +252,11 @@ struct SimpleBolusView: View { if self.viewModel.actionButtonAction == .enterBolus { self.shouldBolusEntryBecomeFirstResponder = true } else { - self.viewModel.saveAndDeliver { (success) in - if success { + Task { + if await viewModel.saveAndDeliver() { self.dismiss() } } - } }, label: { @@ -306,7 +305,7 @@ struct SimpleBolusView: View { } else { title = Text("No Bolus Recommended", comment: "Title for bolus screen warning when glucose is below suspend threshold, and a bolus is not recommended") } - let suspendThresholdString = formatGlucose(viewModel.suspendThreshold) + let suspendThresholdString = formatGlucose(viewModel.suspendThreshold!) return WarningView( title: title, caption: Text(String(format: NSLocalizedString("Your glucose is below your glucose safety limit, %1$@.", comment: "Format string for bolus screen warning when no bolus is recommended due input value below glucose safety limit. (1: suspendThreshold)"), suspendThresholdString)) @@ -362,13 +361,12 @@ struct SimpleBolusView: View { struct SimpleBolusCalculatorView_Previews: PreviewProvider { class MockSimpleBolusViewDelegate: SimpleBolusViewModelDelegate { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - completion(.success([])) + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample { + return StoredGlucoseSample(startDate: sample.date, quantity: sample.quantity) } - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { - - let storedCarbEntry = StoredCarbEntry( + func addCarbEntry(_ carbEntry: LoopKit.NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { + StoredCarbEntry( startDate: carbEntry.startDate, quantity: carbEntry.quantity, uuid: UUID(), @@ -380,9 +378,12 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { createdByCurrentApp: true, userCreatedDate: Date(), userUpdatedDate: nil) - completion(.success(storedCarbEntry)) } + func insulinOnBoard(at date: Date) async -> LoopKit.InsulinValue? { + return nil + } + func enactBolus(units: Double, activationType: BolusActivationType) { } @@ -404,20 +405,24 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { return DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) } - var maximumBolus: Double { + var maximumBolus: Double? { return 6 } - var suspendThreshold: HKQuantity { + var suspendThreshold: HKQuantity? { return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 75) } } - static var viewModel: SimpleBolusViewModel = SimpleBolusViewModel(delegate: MockSimpleBolusViewDelegate(), displayMealEntry: true) - + static var previewViewModel: SimpleBolusViewModel = SimpleBolusViewModel( + delegate: MockSimpleBolusViewDelegate(), + displayMealEntry: true, + displayGlucosePreference: DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + ) + static var previews: some View { NavigationView { - SimpleBolusView(viewModel: viewModel) + SimpleBolusView(viewModel: previewViewModel) } .previewDevice("iPod touch (7th generation)") .environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter)) diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index 82ad76b6cc..1140f60c99 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -20,11 +20,6 @@ public extension AutomaticDosingStrategy { } public struct LoopSettings: Equatable { - public var isScheduleOverrideInfiniteWorkout: Bool { - guard let scheduleOverride = scheduleOverride else { return false } - return scheduleOverride.context == .legacyWorkout && scheduleOverride.duration.isInfinite - } - public var dosingEnabled = false public var glucoseTargetRangeSchedule: GlucoseRangeSchedule? @@ -41,30 +36,6 @@ public struct LoopSettings: Equatable { public var overridePresets: [TemporaryScheduleOverridePreset] = [] - public var scheduleOverride: TemporaryScheduleOverride? { - didSet { - if let newValue = scheduleOverride, newValue.context == .preMeal { - preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") - } - - if scheduleOverride?.context == .legacyWorkout { - preMealOverride = nil - } - } - } - - public var preMealOverride: TemporaryScheduleOverride? { - didSet { - if let newValue = preMealOverride, newValue.context != .preMeal || newValue.settings.insulinNeedsScaleFactor != nil { - preconditionFailure("The `preMealOverride` field should be used only for a pre-meal target range override") - } - - if preMealOverride != nil, scheduleOverride?.context == .legacyWorkout { - scheduleOverride = nil - } - } - } - public var maximumBasalRatePerHour: Double? public var maximumBolus: Double? @@ -88,8 +59,6 @@ public struct LoopSettings: Equatable { preMealTargetRange: ClosedRange? = nil, legacyWorkoutTargetRange: ClosedRange? = nil, overridePresets: [TemporaryScheduleOverridePreset]? = nil, - scheduleOverride: TemporaryScheduleOverride? = nil, - preMealOverride: TemporaryScheduleOverride? = nil, maximumBasalRatePerHour: Double? = nil, maximumBolus: Double? = nil, suspendThreshold: GlucoseThreshold? = nil, @@ -104,8 +73,6 @@ public struct LoopSettings: Equatable { self.preMealTargetRange = preMealTargetRange self.legacyWorkoutTargetRange = legacyWorkoutTargetRange self.overridePresets = overridePresets ?? [] - self.scheduleOverride = scheduleOverride - self.preMealOverride = preMealOverride self.maximumBasalRatePerHour = maximumBasalRatePerHour self.maximumBolus = maximumBolus self.suspendThreshold = suspendThreshold @@ -114,105 +81,6 @@ public struct LoopSettings: Equatable { } } -extension LoopSettings { - public func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { - - let preMealOverride = presumingMealEntry ? nil : self.preMealOverride - - let currentEffectiveOverride: TemporaryScheduleOverride? - switch (preMealOverride, scheduleOverride) { - case (let preMealOverride?, nil): - currentEffectiveOverride = preMealOverride - case (nil, let scheduleOverride?): - currentEffectiveOverride = scheduleOverride - case (let preMealOverride?, let scheduleOverride?): - currentEffectiveOverride = preMealOverride.scheduledEndDate > Date() - ? preMealOverride - : scheduleOverride - case (nil, nil): - currentEffectiveOverride = nil - } - - if let effectiveOverride = currentEffectiveOverride { - return glucoseTargetRangeSchedule?.applyingOverride(effectiveOverride) - } else { - return glucoseTargetRangeSchedule - } - } - - public func scheduleOverrideEnabled(at date: Date = Date()) -> Bool { - return scheduleOverride?.isActive(at: date) == true - } - - public func nonPreMealOverrideEnabled(at date: Date = Date()) -> Bool { - return scheduleOverride?.isActive(at: date) == true - } - - public func preMealTargetEnabled(at date: Date = Date()) -> Bool { - return preMealOverride?.isActive(at: date) == true - } - - public func futureOverrideEnabled(relativeTo date: Date = Date()) -> Bool { - guard let scheduleOverride = scheduleOverride else { return false } - return scheduleOverride.startDate > date - } - - public mutating func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { - preMealOverride = makePreMealOverride(beginningAt: date, for: duration) - } - - private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { - guard let preMealTargetRange = preMealTargetRange else { - return nil - } - return TemporaryScheduleOverride( - context: .preMeal, - settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), - startDate: date, - duration: .finite(duration), - enactTrigger: .local, - syncIdentifier: UUID() - ) - } - - public mutating func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TimeInterval) { - scheduleOverride = legacyWorkoutOverride(beginningAt: date, for: duration) - preMealOverride = nil - } - - public mutating func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { - guard let legacyWorkoutTargetRange = legacyWorkoutTargetRange else { - return nil - } - - return TemporaryScheduleOverride( - context: .legacyWorkout, - settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), - startDate: date, - duration: duration.isInfinite ? .indefinite : .finite(duration), - enactTrigger: .local, - syncIdentifier: UUID() - ) - } - - public mutating func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { - if context == .preMeal { - preMealOverride = nil - return - } - - guard let scheduleOverride = scheduleOverride else { return } - - if let context = context { - if scheduleOverride.context == context { - self.scheduleOverride = nil - } - } else { - self.scheduleOverride = nil - } - } -} - extension LoopSettings: RawRepresentable { public typealias RawValue = [String: Any] private static let version = 1 @@ -256,14 +124,6 @@ extension LoopSettings: RawRepresentable { self.overridePresets = rawPresets.compactMap(TemporaryScheduleOverridePreset.init(rawValue:)) } - if let rawPreMealOverride = rawValue["preMealOverride"] as? TemporaryScheduleOverride.RawValue { - self.preMealOverride = TemporaryScheduleOverride(rawValue: rawPreMealOverride) - } - - if let rawOverride = rawValue["scheduleOverride"] as? TemporaryScheduleOverride.RawValue { - self.scheduleOverride = TemporaryScheduleOverride(rawValue: rawOverride) - } - self.maximumBasalRatePerHour = rawValue["maximumBasalRatePerHour"] as? Double self.maximumBolus = rawValue["maximumBolus"] as? Double @@ -289,8 +149,6 @@ extension LoopSettings: RawRepresentable { raw["glucoseTargetRangeSchedule"] = glucoseTargetRangeSchedule?.rawValue raw["preMealTargetRange"] = preMealTargetRange?.doubleRange(for: LoopSettings.codingGlucoseUnit).rawValue raw["legacyWorkoutTargetRange"] = legacyWorkoutTargetRange?.doubleRange(for: LoopSettings.codingGlucoseUnit).rawValue - raw["preMealOverride"] = preMealOverride?.rawValue - raw["scheduleOverride"] = scheduleOverride?.rawValue raw["maximumBasalRatePerHour"] = maximumBasalRatePerHour raw["maximumBolus"] = maximumBolus raw["minimumBGGuard"] = suspendThreshold?.rawValue diff --git a/LoopCore/Result.swift b/LoopCore/Result.swift deleted file mode 100644 index 580595159d..0000000000 --- a/LoopCore/Result.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Result.swift -// Loop -// -// Copyright © 2017 LoopKit Authors. All rights reserved. -// - - -public enum Result { - case success(T) - case failure(Error) -} diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_carb_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_carb_effect.json deleted file mode 100644 index 0637a088a0..0000000000 --- a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_carb_effect.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_counteraction_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_counteraction_effect.json deleted file mode 100644 index 28e66e4932..0000000000 --- a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_counteraction_effect.json +++ /dev/null @@ -1,230 +0,0 @@ -[ - { - "startDate": "2020-08-11T17:25:13", - "endDate": "2020-08-11T17:30:13", - "unit": "mg\/min·dL", - "value": -0.17427698848393616 - }, - { - "startDate": "2020-08-11T17:30:13", - "endDate": "2020-08-11T17:35:13", - "unit": "mg\/min·dL", - "value": -0.172884893111717 - }, - { - "startDate": "2020-08-11T17:35:13", - "endDate": "2020-08-11T17:40:13", - "unit": "mg\/min·dL", - "value": -0.16620062119698026 - }, - { - "startDate": "2020-08-11T17:40:13", - "endDate": "2020-08-11T17:45:13", - "unit": "mg\/min·dL", - "value": -0.15239126546960888 - }, - { - "startDate": "2020-08-11T17:45:13", - "endDate": "2020-08-11T17:50:13", - "unit": "mg\/min·dL", - "value": -0.13844620387243192 - }, - { - "startDate": "2020-08-11T17:50:13", - "endDate": "2020-08-11T17:55:13", - "unit": "mg\/min·dL", - "value": -0.12440053903505803 - }, - { - "startDate": "2020-08-11T17:55:13", - "endDate": "2020-08-11T18:00:13", - "unit": "mg\/min·dL", - "value": -0.1102033787233404 - }, - { - "startDate": "2020-08-11T18:00:13", - "endDate": "2020-08-11T18:05:13", - "unit": "mg\/min·dL", - "value": -0.09582040633985235 - }, - { - "startDate": "2020-08-11T18:05:13", - "endDate": "2020-08-11T18:10:13", - "unit": "mg\/min·dL", - "value": -0.08123290693932182 - }, - { - "startDate": "2020-08-11T18:10:13", - "endDate": "2020-08-11T18:15:13", - "unit": "mg\/min·dL", - "value": -0.06643676319414542 - }, - { - "startDate": "2020-08-11T18:15:13", - "endDate": "2020-08-11T18:20:13", - "unit": "mg\/min·dL", - "value": -0.051441423013083416 - }, - { - "startDate": "2020-08-11T18:20:13", - "endDate": "2020-08-11T18:25:13", - "unit": "mg\/min·dL", - "value": -0.0362688411105418 - }, - { - "startDate": "2020-08-11T18:25:13", - "endDate": "2020-08-11T18:30:13", - "unit": "mg\/min·dL", - "value": -0.020952397377567107 - }, - { - "startDate": "2020-08-11T18:30:13", - "endDate": "2020-08-11T18:35:13", - "unit": "mg\/min·dL", - "value": -0.005535795415598254 - }, - { - "startDate": "2020-08-11T18:35:13", - "endDate": "2020-08-11T18:40:13", - "unit": "mg\/min·dL", - "value": 0.009928054942067454 - }, - { - "startDate": "2020-08-11T18:40:13", - "endDate": "2020-08-11T18:45:13", - "unit": "mg\/min·dL", - "value": 0.02537816688081129 - }, - { - "startDate": "2020-08-11T18:45:13", - "endDate": "2020-08-11T18:50:13", - "unit": "mg\/min·dL", - "value": 0.040746613021907935 - }, - { - "startDate": "2020-08-11T18:50:13", - "endDate": "2020-08-11T18:55:13", - "unit": "mg\/min·dL", - "value": 0.05595966408835151 - }, - { - "startDate": "2020-08-11T18:55:13", - "endDate": "2020-08-11T19:00:13", - "unit": "mg\/min·dL", - "value": 0.07093892464198123 - }, - { - "startDate": "2020-08-11T19:00:13", - "endDate": "2020-08-11T19:05:13", - "unit": "mg\/min·dL", - "value": 0.08560246050964196 - }, - { - "startDate": "2020-08-11T19:05:13", - "endDate": "2020-08-11T19:10:13", - "unit": "mg\/min·dL", - "value": 0.09986591236653002 - }, - { - "startDate": "2020-08-11T19:10:13", - "endDate": "2020-08-11T19:15:13", - "unit": "mg\/min·dL", - "value": 0.11364358985065513 - }, - { - "startDate": "2020-08-11T19:15:13", - "endDate": "2020-08-11T19:20:13", - "unit": "mg\/min·dL", - "value": 0.12684954054338973 - }, - { - "startDate": "2020-08-11T19:20:13", - "endDate": "2020-08-11T19:25:13", - "unit": "mg\/min·dL", - "value": 0.13939858816698666 - }, - { - "startDate": "2020-08-11T19:25:13", - "endDate": "2020-08-11T19:30:13", - "unit": "mg\/min·dL", - "value": 0.15120733442007542 - }, - { - "startDate": "2020-08-11T19:30:13", - "endDate": "2020-08-11T19:35:13", - "unit": "mg\/min·dL", - "value": 0.16219511899486355 - }, - { - "startDate": "2020-08-11T19:35:13", - "endDate": "2020-08-11T19:40:13", - "unit": "mg\/min·dL", - "value": 0.17228493249382398 - }, - { - "startDate": "2020-08-11T19:40:13", - "endDate": "2020-08-11T19:45:13", - "unit": "mg\/min·dL", - "value": 0.1814042771871964 - }, - { - "startDate": "2020-08-11T19:45:13", - "endDate": "2020-08-11T19:50:13", - "unit": "mg\/min·dL", - "value": 0.18948597082306992 - }, - { - "startDate": "2020-08-11T19:50:13", - "endDate": "2020-08-11T19:55:13", - "unit": "mg\/min·dL", - "value": 0.196468889016708 - }, - { - "startDate": "2020-08-11T19:55:13", - "endDate": "2020-08-11T20:00:13", - "unit": "mg\/min·dL", - "value": 0.20229864210263385 - }, - { - "startDate": "2020-08-11T20:00:13", - "endDate": "2020-08-11T20:05:13", - "unit": "mg\/min·dL", - "value": 0.2069281827278072 - }, - { - "startDate": "2020-08-11T20:05:13", - "endDate": "2020-08-11T20:10:13", - "unit": "mg\/min·dL", - "value": 0.21031834089428644 - }, - { - "startDate": "2020-08-11T20:10:13", - "endDate": "2020-08-11T20:15:13", - "unit": "mg\/min·dL", - "value": 0.21243828362120673 - }, - { - "startDate": "2020-08-11T20:15:13", - "endDate": "2020-08-11T20:20:13", - "unit": "mg\/min·dL", - "value": 0.213265896884441 - }, - { - "startDate": "2020-08-11T20:20:13", - "endDate": "2020-08-11T20:25:13", - "unit": "mg\/min·dL", - "value": 0.212788088004482 - }, - { - "startDate": "2020-08-11T20:25:13", - "endDate": "2020-08-11T20:32:50", - "unit": "mg\/min·dL", - "value": 0.17396858033976298 - }, - { - "startDate": "2020-08-11T20:32:50", - "endDate": "2020-08-11T20:45:02", - "unit": "mg\/min·dL", - "value": 0.18555611348135584 - } -] diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_insulin_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_insulin_effect.json deleted file mode 100644 index e83d91e34b..0000000000 --- a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_insulin_effect.json +++ /dev/null @@ -1,377 +0,0 @@ -[ - { - "date": "2020-08-11T20:50:00", - "amount": -0.21997829342610006, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T20:55:00", - "amount": -0.4261395410590354, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:00:00", - "amount": -0.7096583179105603, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:05:00", - "amount": -1.0621881093826662, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:10:00", - "amount": -1.4740341427597377, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:15:00", - "amount": -1.9363888584472242, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:20:00", - "amount": -2.441263560467393, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:25:00", - "amount": -2.9814248393095815, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:30:00", - "amount": -3.5503354629325354, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:35:00", - "amount": -4.142099441439137, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:40:00", - "amount": -4.751410989493849, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:45:00", - "amount": -5.373507127973413, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:50:00", - "amount": -6.004123682698768, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:55:00", - "amount": -6.639454453454031, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:00:00", - "amount": -7.276113340916081, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:05:00", - "amount": -7.911099232651796, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:10:00", - "amount": -8.541763462042216, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:15:00", - "amount": -9.165779665913185, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:20:00", - "amount": -9.7811158778376, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:25:00", - "amount": -10.386008704568662, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:30:00", - "amount": -10.97893944290868, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:35:00", - "amount": -11.558612003552255, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:40:00", - "amount": -12.12393251710345, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:45:00", - "amount": -12.673990505588074, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:50:00", - "amount": -13.20804151039699, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:55:00", - "amount": -13.725491074735217, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:00:00", - "amount": -14.225879985343203, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:05:00", - "amount": -14.708870684528089, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:10:00", - "amount": -15.174234769419765, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:15:00", - "amount": -15.62184150087279, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:20:00", - "amount": -16.05164724959357, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:25:00", - "amount": -16.463685811903716, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:30:00", - "amount": -16.858059532075337, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:35:00", - "amount": -17.234931172410647, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:40:00", - "amount": -17.594516476204813, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:45:00", - "amount": -17.93707737244358, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:50:00", - "amount": -18.26291577456192, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:55:00", - "amount": -18.572367928841064, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:00:00", - "amount": -18.86579927106296, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:05:00", - "amount": -19.14359975288623, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:10:00", - "amount": -19.406179602068192, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:15:00", - "amount": -19.65396548314523, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:20:00", - "amount": -19.887397027509305, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:25:00", - "amount": -20.106923703991654, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:30:00", - "amount": -20.313002003095814, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:35:00", - "amount": -20.506092909919293, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:40:00", - "amount": -20.686659642575187, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:45:00", - "amount": -20.855165634580125, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:50:00", - "amount": -21.012072741219335, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:55:00", - "amount": -21.157839651341906, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:00:00", - "amount": -21.292920487384542, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:05:00", - "amount": -21.41776357767731, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:10:00", - "amount": -21.532810386255537, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:15:00", - "amount": -21.638494586493437, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:20:00", - "amount": -21.735241265892345, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:25:00", - "amount": -21.823466250304694, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:30:00", - "amount": -21.903575536757817, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:35:00", - "amount": -21.975964824864416, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:40:00", - "amount": -22.041019137572135, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:45:00", - "amount": -22.099112522717494, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:50:00", - "amount": -22.150607827512605, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:55:00", - "amount": -22.19585653870966, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:00:00", - "amount": -22.235198681761787, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:05:00", - "amount": -22.268962772831713, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:10:00", - "amount": -22.297465817994798, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:15:00", - "amount": -22.32101335444279, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:20:00", - "amount": -22.33989952892162, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:25:00", - "amount": -22.354407209032342, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:30:00", - "amount": -22.364808123391935, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:35:00", - "amount": -22.371363026991066, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:40:00", - "amount": -22.374909853783546, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:45:00", - "amount": -22.37661999205696, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:50:00", - "amount": -22.377128476655095, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:55:00", - "amount": -22.377194743725912, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:00:00", - "amount": -22.37719474401739, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_momentum_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_momentum_effect.json deleted file mode 100644 index 0637a088a0..0000000000 --- a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_momentum_effect.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_predicted_glucose.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_predicted_glucose.json deleted file mode 100644 index a969a34495..0000000000 --- a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_predicted_glucose.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-11T20:45:02", - "unit": "mg/dL", - "amount": 123.42849966275706 - }, - { - "date": "2020-08-11T20:50:00", - "unit": "mg/dL", - "amount": 124.26018046469977 - }, - { - "date": "2020-08-11T20:55:00", - "unit": "mg/dL", - "amount": 124.81009267337839 - }, - { - "date": "2020-08-11T21:00:00", - "unit": "mg/dL", - "amount": 125.20704000720727 - }, - { - "date": "2020-08-11T21:05:00", - "unit": "mg/dL", - "amount": 125.4593689807844 - }, - { - "date": "2020-08-11T21:10:00", - "unit": "mg/dL", - "amount": 125.57677436682542 - }, - { - "date": "2020-08-11T21:15:00", - "unit": "mg/dL", - "amount": 125.56806372492487 - }, - { - "date": "2020-08-11T21:20:00", - "unit": "mg/dL", - "amount": 125.44122575106047 - }, - { - "date": "2020-08-11T21:25:00", - "unit": "mg/dL", - "amount": 125.2034938547429 - }, - { - "date": "2020-08-11T21:30:00", - "unit": "mg/dL", - "amount": 124.86140526801341 - }, - { - "date": "2020-08-11T21:35:00", - "unit": "mg/dL", - "amount": 124.42085598076912 - }, - { - "date": "2020-08-11T21:40:00", - "unit": "mg/dL", - "amount": 123.88715177834555 - }, - { - "date": "2020-08-11T21:45:00", - "unit": "mg/dL", - "amount": 123.26505563986599 - }, - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 122.63443908514064 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 121.99910831438538 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 121.36244942692333 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 120.72746353518762 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 120.0967993057972 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 119.47278310192624 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 118.85744689000182 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 118.25255406327076 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 117.65962332493075 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 117.07995076428718 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 116.51463025073598 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 115.96457226225135 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 115.43052125744244 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 114.91307169310421 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 114.41268278249623 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 113.92969208331135 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 113.46432799841968 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 113.01672126696666 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 112.58691551824587 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 112.17487695593573 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 111.78050323576412 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 111.4036315954288 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 111.04404629163464 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 110.70148539539588 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 110.37564699327754 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 110.0661948389984 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 109.7727634967765 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 109.49496301495324 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 109.23238316577128 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 108.98459728469425 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 108.75116574033018 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 108.53163906384783 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 108.32556076474367 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 108.1324698579202 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 107.95190312526431 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 107.78339713325937 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 107.62649002662016 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 107.48072311649759 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 107.34564228045495 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 107.22079919016218 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 107.10575238158395 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 107.00006818134605 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 106.90332150194715 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 106.8150965175348 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 106.73498723108167 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 106.66259794297508 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 106.59754363026737 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 106.53945024512201 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 106.4879549403269 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 106.44270622912984 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 106.40336408607772 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 106.36959999500779 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 106.34109694984471 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 106.31754941339672 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 106.2986632389179 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 106.28415555880717 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 106.27375464444758 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 106.26719974084844 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 106.26365291405597 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 106.26194277578256 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 106.26143429118443 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 106.26136802411361 - }, - { - "date": "2020-08-12T03:00:00", - "unit": "mg/dL", - "amount": 106.26136802382213 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_carb_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_carb_effect.json deleted file mode 100644 index 0637a088a0..0000000000 --- a/LoopTests/Fixtures/high_and_falling/high_and_falling_carb_effect.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_counteraction_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_counteraction_effect.json deleted file mode 100644 index 3cd84a4d76..0000000000 --- a/LoopTests/Fixtures/high_and_falling/high_and_falling_counteraction_effect.json +++ /dev/null @@ -1,236 +0,0 @@ -[ - { - "startDate": "2020-08-11T19:44:58", - "endDate": "2020-08-11T19:49:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T19:49:58", - "endDate": "2020-08-11T19:54:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T19:54:58", - "endDate": "2020-08-11T19:59:58", - "unit": "mg\/min·dL", - "value": 0.06065363877984119 - }, - { - "startDate": "2020-08-11T19:59:58", - "endDate": "2020-08-11T20:04:58", - "unit": "mg\/min·dL", - "value": 0.1829111566180655 - }, - { - "startDate": "2020-08-11T20:04:58", - "endDate": "2020-08-11T20:09:58", - "unit": "mg\/min·dL", - "value": 0.29002744966453 - }, - { - "startDate": "2020-08-11T20:09:58", - "endDate": "2020-08-11T20:14:58", - "unit": "mg\/min·dL", - "value": 0.38321365736330676 - }, - { - "startDate": "2020-08-11T20:14:58", - "endDate": "2020-08-11T20:19:58", - "unit": "mg\/min·dL", - "value": 0.4637144729903035 - }, - { - "startDate": "2020-08-11T20:19:58", - "endDate": "2020-08-11T20:24:58", - "unit": "mg\/min·dL", - "value": 0.5326798223434369 - }, - { - "startDate": "2020-08-11T20:24:58", - "endDate": "2020-08-11T20:29:58", - "unit": "mg\/min·dL", - "value": 0.5911714460685378 - }, - { - "startDate": "2020-08-11T20:29:58", - "endDate": "2020-08-11T20:34:58", - "unit": "mg\/min·dL", - "value": 0.6401690515783915 - }, - { - "startDate": "2020-08-11T20:34:58", - "endDate": "2020-08-11T20:39:58", - "unit": "mg\/min·dL", - "value": 0.6805760615235243 - }, - { - "startDate": "2020-08-11T20:39:58", - "endDate": "2020-08-11T20:44:58", - "unit": "mg\/min·dL", - "value": 0.7132249841389473 - }, - { - "startDate": "2020-08-11T20:44:58", - "endDate": "2020-08-11T20:49:58", - "unit": "mg\/min·dL", - "value": 0.7388824292522805 - }, - { - "startDate": "2020-08-11T20:49:58", - "endDate": "2020-08-11T20:54:58", - "unit": "mg\/min·dL", - "value": 0.758253792292099 - }, - { - "startDate": "2020-08-11T20:54:58", - "endDate": "2020-08-11T20:59:58", - "unit": "mg\/min·dL", - "value": 0.7719876272734658 - }, - { - "startDate": "2020-08-11T20:59:58", - "endDate": "2020-08-11T21:04:58", - "unit": "mg\/min·dL", - "value": 0.7806797284574882 - }, - { - "startDate": "2020-08-11T21:04:58", - "endDate": "2020-08-11T21:09:58", - "unit": "mg\/min·dL", - "value": 0.7848769391771567 - }, - { - "startDate": "2020-08-11T21:09:58", - "endDate": "2020-08-11T21:14:58", - "unit": "mg\/min·dL", - "value": 0.7850807051888878 - }, - { - "startDate": "2020-08-11T21:14:58", - "endDate": "2020-08-11T21:19:58", - "unit": "mg\/min·dL", - "value": 0.7817503888440966 - }, - { - "startDate": "2020-08-11T21:19:58", - "endDate": "2020-08-11T21:24:58", - "unit": "mg\/min·dL", - "value": 0.7753063593735205 - }, - { - "startDate": "2020-08-11T21:24:58", - "endDate": "2020-08-11T21:29:58", - "unit": "mg\/min·dL", - "value": 0.7661328736349247 - }, - { - "startDate": "2020-08-11T21:29:58", - "endDate": "2020-08-11T21:34:58", - "unit": "mg\/min·dL", - "value": 0.7545807607898111 - }, - { - "startDate": "2020-08-11T21:34:58", - "endDate": "2020-08-11T21:39:58", - "unit": "mg\/min·dL", - "value": 0.7409699235419351 - }, - { - "startDate": "2020-08-11T21:39:58", - "endDate": "2020-08-11T21:44:58", - "unit": "mg\/min·dL", - "value": 0.7255916677884272 - }, - { - "startDate": "2020-08-11T21:44:58", - "endDate": "2020-08-11T21:49:58", - "unit": "mg\/min·dL", - "value": 0.7087108717986296 - }, - { - "startDate": "2020-08-11T21:49:58", - "endDate": "2020-08-11T21:54:58", - "unit": "mg\/min·dL", - "value": 0.6905680053447725 - }, - { - "startDate": "2020-08-11T21:54:58", - "endDate": "2020-08-11T21:59:58", - "unit": "mg\/min·dL", - "value": 0.6713810085591916 - }, - { - "startDate": "2020-08-11T21:59:58", - "endDate": "2020-08-11T22:04:58", - "unit": "mg\/min·dL", - "value": 0.6513470396824913 - }, - { - "startDate": "2020-08-11T22:04:58", - "endDate": "2020-08-11T22:09:58", - "unit": "mg\/min·dL", - "value": 0.6306441002936196 - }, - { - "startDate": "2020-08-11T22:09:58", - "endDate": "2020-08-11T22:14:58", - "unit": "mg\/min·dL", - "value": 0.6094325460745351 - }, - { - "startDate": "2020-08-11T22:14:58", - "endDate": "2020-08-11T22:19:58", - "unit": "mg\/min·dL", - "value": 0.5878564906558068 - }, - { - "startDate": "2020-08-11T22:19:58", - "endDate": "2020-08-11T22:24:58", - "unit": "mg\/min·dL", - "value": 0.566045109614535 - }, - { - "startDate": "2020-08-11T22:24:58", - "endDate": "2020-08-11T22:29:58", - "unit": "mg\/min·dL", - "value": 0.5441138512497218 - }, - { - "startDate": "2020-08-11T22:29:58", - "endDate": "2020-08-11T22:34:58", - "unit": "mg\/min·dL", - "value": 0.5221655603410653 - }, - { - "startDate": "2020-08-11T22:34:58", - "endDate": "2020-08-11T22:39:58", - "unit": "mg\/min·dL", - "value": 0.5002915207035925 - }, - { - "startDate": "2020-08-11T22:39:58", - "endDate": "2020-08-11T22:44:58", - "unit": "mg\/min·dL", - "value": 0.47857242198147665 - }, - { - "startDate": "2020-08-11T22:44:58", - "endDate": "2020-08-11T22:49:44", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:49:44", - "endDate": "2020-08-11T22:54:44", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:54:44", - "endDate": "2020-08-11T22:59:45", - "unit": "mg\/min·dL", - "value": 0.060537504513367056 - } -] diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_insulin_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_insulin_effect.json deleted file mode 100644 index bea7fb07a4..0000000000 --- a/LoopTests/Fixtures/high_and_falling/high_and_falling_insulin_effect.json +++ /dev/null @@ -1,377 +0,0 @@ -[ - { - "date": "2020-08-11T23:00:00", - "amount": -0.30324421735766016, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:05:00", - "amount": -1.2074805603814895, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:10:00", - "amount": -2.6198776769809875, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:15:00", - "amount": -4.465672057725821, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:20:00", - "amount": -6.685266802723275, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:25:00", - "amount": -9.224809473113943, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:30:00", - "amount": -12.03541189572141, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:35:00", - "amount": -15.072766324251951, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:40:00", - "amount": -18.296788509858903, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:45:00", - "amount": -21.671285910499947, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:50:00", - "amount": -25.16364937991473, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:55:00", - "amount": -28.744566781673353, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:00:00", - "amount": -32.38775707198973, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:05:00", - "amount": -36.069723487241404, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:10:00", - "amount": -39.7695245587422, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:15:00", - "amount": -43.46856175861266, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:20:00", - "amount": -47.150382656903005, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:25:00", - "amount": -50.8004985417413, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:30:00", - "amount": -54.40621552148487, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:35:00", - "amount": -57.956478190913245, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:40:00", - "amount": -61.44172500265972, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:45:00", - "amount": -64.85375454057544, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:50:00", - "amount": -68.1856019437701, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:55:00", - "amount": -71.43142477888769, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:00:00", - "amount": -74.58639770394838, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:05:00", - "amount": -77.64661531000066, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:10:00", - "amount": -80.60900256705762, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:15:00", - "amount": -83.47123233849673, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:20:00", - "amount": -86.23164946343942, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:25:00", - "amount": -88.88920093973691, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:30:00", - "amount": -91.44337177121069, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:35:00", - "amount": -93.89412607185396, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:40:00", - "amount": -96.24185304691466, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:45:00", - "amount": -98.4873174962681, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:50:00", - "amount": -100.63161450934751, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:55:00", - "amount": -102.67612804323775, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:00:00", - "amount": -104.62249309644574, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:05:00", - "amount": -106.47256121042342, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:10:00", - "amount": -108.22836904922634, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:15:00", - "amount": -109.89210982481272, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:20:00", - "amount": -111.46610735150391, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:25:00", - "amount": -112.95279252810269, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:30:00", - "amount": -114.35468206016674, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:35:00", - "amount": -115.67435924802191, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:40:00", - "amount": -116.91445667832986, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:45:00", - "amount": -118.07764066845148, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:50:00", - "amount": -119.16659732352176, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:55:00", - "amount": -120.18402007612107, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:00:00", - "amount": -121.13259858773439, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:05:00", - "amount": -122.0150088998796, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:10:00", - "amount": -122.83390473089393, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:15:00", - "amount": -123.59190982193347, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:20:00", - "amount": -124.29161124279706, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:25:00", - "amount": -124.93555357476642, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:30:00", - "amount": -125.52623389378984, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:35:00", - "amount": -126.06609748305398, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:40:00", - "amount": -126.55753420931575, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:45:00", - "amount": -127.00287550232932, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:50:00", - "amount": -127.4043918813229, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:55:00", - "amount": -127.76429097678248, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:00:00", - "amount": -128.08471599980103, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:05:00", - "amount": -128.36774461497714, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:10:00", - "amount": -128.61538817630728, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:15:00", - "amount": -128.8295912887364, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:20:00", - "amount": -129.0122316610227, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:25:00", - "amount": -129.16512021834833, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:30:00", - "amount": -129.29000144569122, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:35:00", - "amount": -129.38855393536335, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:40:00", - "amount": -129.46239111434534, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:45:00", - "amount": -129.51306212910382, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:50:00", - "amount": -129.54205286749004, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:55:00", - "amount": -129.5507870990832, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T05:00:00", - "amount": -129.54961066748092, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T05:05:00", - "amount": -129.54931273055175, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T05:10:00", - "amount": -129.54930222233963, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_momentum_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_momentum_effect.json deleted file mode 100644 index 1166b913bb..0000000000 --- a/LoopTests/Fixtures/high_and_falling/high_and_falling_momentum_effect.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 0.0 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_predicted_glucose.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_predicted_glucose.json deleted file mode 100644 index 61f60a5e6a..0000000000 --- a/LoopTests/Fixtures/high_and_falling/high_and_falling_predicted_glucose.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-11T22:59:45", - "unit": "mg/dL", - "amount": 200.0 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 200.0 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 200.0111032633726 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 200.01924237216699 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 199.63033966967689 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 198.52739386494645 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 196.9449788576418 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 194.9319828209393 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 192.53271350113278 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 189.78725514706883 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 186.73180030078979 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 183.398958108556 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 179.81804070679738 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 176.174850416481 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 172.49288400122933 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 168.79308292972854 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 165.09404572985807 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 161.41222483156773 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 157.76210894672943 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 154.15639196698586 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 150.6061292975575 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 147.12088248581102 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 143.7088529478953 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 140.37700554470064 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 137.13118270958304 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 133.97620978452235 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 130.91599217847008 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 127.95360492141312 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 125.091375149974 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 122.33095802503131 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 119.67340654873382 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 117.11923571726004 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 114.66848141661677 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 112.32075444155608 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 110.07528999220263 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 107.93099297912322 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 105.88647944523298 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 103.940114392025 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 102.09004627804731 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 100.33423843924439 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 98.67049766365801 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 97.09650013696682 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 95.60981496036804 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 94.207925428304 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 92.88824824044882 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 91.64815081014088 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 90.48496682001925 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 89.39601016494898 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 88.37858741234966 - }, - { - "date": "2020-08-12T03:00:00", - "unit": "mg/dL", - "amount": 87.43000890073634 - }, - { - "date": "2020-08-12T03:05:00", - "unit": "mg/dL", - "amount": 86.54759858859113 - }, - { - "date": "2020-08-12T03:10:00", - "unit": "mg/dL", - "amount": 85.7287027575768 - }, - { - "date": "2020-08-12T03:15:00", - "unit": "mg/dL", - "amount": 84.97069766653726 - }, - { - "date": "2020-08-12T03:20:00", - "unit": "mg/dL", - "amount": 84.27099624567367 - }, - { - "date": "2020-08-12T03:25:00", - "unit": "mg/dL", - "amount": 83.62705391370432 - }, - { - "date": "2020-08-12T03:30:00", - "unit": "mg/dL", - "amount": 83.0363735946809 - }, - { - "date": "2020-08-12T03:35:00", - "unit": "mg/dL", - "amount": 82.49651000541675 - }, - { - "date": "2020-08-12T03:40:00", - "unit": "mg/dL", - "amount": 82.00507327915498 - }, - { - "date": "2020-08-12T03:45:00", - "unit": "mg/dL", - "amount": 81.55973198614141 - }, - { - "date": "2020-08-12T03:50:00", - "unit": "mg/dL", - "amount": 81.15821560714784 - }, - { - "date": "2020-08-12T03:55:00", - "unit": "mg/dL", - "amount": 80.79831651168826 - }, - { - "date": "2020-08-12T04:00:00", - "unit": "mg/dL", - "amount": 80.4778914886697 - }, - { - "date": "2020-08-12T04:05:00", - "unit": "mg/dL", - "amount": 80.19486287349359 - }, - { - "date": "2020-08-12T04:10:00", - "unit": "mg/dL", - "amount": 79.94721931216345 - }, - { - "date": "2020-08-12T04:15:00", - "unit": "mg/dL", - "amount": 79.73301619973434 - }, - { - "date": "2020-08-12T04:20:00", - "unit": "mg/dL", - "amount": 79.55037582744804 - }, - { - "date": "2020-08-12T04:25:00", - "unit": "mg/dL", - "amount": 79.3974872701224 - }, - { - "date": "2020-08-12T04:30:00", - "unit": "mg/dL", - "amount": 79.27260604277951 - }, - { - "date": "2020-08-12T04:35:00", - "unit": "mg/dL", - "amount": 79.17405355310738 - }, - { - "date": "2020-08-12T04:40:00", - "unit": "mg/dL", - "amount": 79.1002163741254 - }, - { - "date": "2020-08-12T04:45:00", - "unit": "mg/dL", - "amount": 79.04954535936692 - }, - { - "date": "2020-08-12T04:50:00", - "unit": "mg/dL", - "amount": 79.02055462098069 - }, - { - "date": "2020-08-12T04:55:00", - "unit": "mg/dL", - "amount": 79.01182038938752 - }, - { - "date": "2020-08-12T05:00:00", - "unit": "mg/dL", - "amount": 79.01299682098981 - }, - { - "date": "2020-08-12T05:05:00", - "unit": "mg/dL", - "amount": 79.01329475791898 - }, - { - "date": "2020-08-12T05:10:00", - "unit": "mg/dL", - "amount": 79.0133052661311 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_carb_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_carb_effect.json deleted file mode 100644 index 47d656b872..0000000000 --- a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_carb_effect.json +++ /dev/null @@ -1,322 +0,0 @@ -[ - { - "date": "2020-08-11T21:15:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:20:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:25:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:30:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:35:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:40:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:45:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 0.5054689190453953 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 2.033246696823173 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 3.5610244746009507 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 5.088802252378729 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 6.616580030156507 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 8.144357807934284 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 9.672135585712061 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 11.199913363489841 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 12.727691141267618 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 14.255468919045395 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 15.783246696823173 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 17.311024474600952 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 18.83880225237873 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 20.366580030156506 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 21.89435780793428 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 23.422135585712063 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 24.949913363489838 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 26.477691141267616 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 28.00546891904539 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 29.533246696823177 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 31.061024474600952 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 32.58880225237873 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 34.116580030156506 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 35.644357807934284 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 37.17213558571207 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 38.69991336348984 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 40.22769114126762 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 41.7554689190454 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 43.28324669682318 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 44.81102447460095 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 46.33880225237873 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 47.86658003015651 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 49.394357807934284 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 50.922135585712056 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 52.44991336348984 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 53.97769114126762 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 55.50546891904539 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 57.03324669682318 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 58.56102447460095 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 60.08880225237873 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 61.6165800301565 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 63.144357807934284 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 64.67213558571206 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 66.19991336348984 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 67.72769114126763 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 69.2554689190454 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 70.78324669682317 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 72.31102447460096 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 73.83880225237873 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 75.3665800301565 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 76.89435780793428 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 78.42213558571207 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 79.94991336348984 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 81.47769114126761 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 82.5 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_counteraction_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_counteraction_effect.json deleted file mode 100644 index 7032287fe7..0000000000 --- a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_counteraction_effect.json +++ /dev/null @@ -1,266 +0,0 @@ -[ - { - "startDate": "2020-08-11T17:25:13", - "endDate": "2020-08-11T17:30:13", - "unit": "mg\/min·dL", - "value": -0.17427698848393616 - }, - { - "startDate": "2020-08-11T17:30:13", - "endDate": "2020-08-11T17:35:13", - "unit": "mg\/min·dL", - "value": -0.172884893111717 - }, - { - "startDate": "2020-08-11T17:35:13", - "endDate": "2020-08-11T17:40:13", - "unit": "mg\/min·dL", - "value": -0.16620062119698026 - }, - { - "startDate": "2020-08-11T17:40:13", - "endDate": "2020-08-11T17:45:13", - "unit": "mg\/min·dL", - "value": -0.15239126546960888 - }, - { - "startDate": "2020-08-11T17:45:13", - "endDate": "2020-08-11T17:50:13", - "unit": "mg\/min·dL", - "value": -0.13844620387243192 - }, - { - "startDate": "2020-08-11T17:50:13", - "endDate": "2020-08-11T17:55:13", - "unit": "mg\/min·dL", - "value": -0.12440053903505803 - }, - { - "startDate": "2020-08-11T17:55:13", - "endDate": "2020-08-11T18:00:13", - "unit": "mg\/min·dL", - "value": -0.1102033787233404 - }, - { - "startDate": "2020-08-11T18:00:13", - "endDate": "2020-08-11T18:05:13", - "unit": "mg\/min·dL", - "value": -0.09582040633985235 - }, - { - "startDate": "2020-08-11T18:05:13", - "endDate": "2020-08-11T18:10:13", - "unit": "mg\/min·dL", - "value": -0.08123290693932182 - }, - { - "startDate": "2020-08-11T18:10:13", - "endDate": "2020-08-11T18:15:13", - "unit": "mg\/min·dL", - "value": -0.06643676319414542 - }, - { - "startDate": "2020-08-11T18:15:13", - "endDate": "2020-08-11T18:20:13", - "unit": "mg\/min·dL", - "value": -0.051441423013083416 - }, - { - "startDate": "2020-08-11T18:20:13", - "endDate": "2020-08-11T18:25:13", - "unit": "mg\/min·dL", - "value": -0.0362688411105418 - }, - { - "startDate": "2020-08-11T18:25:13", - "endDate": "2020-08-11T18:30:13", - "unit": "mg\/min·dL", - "value": -0.020952397377567107 - }, - { - "startDate": "2020-08-11T18:30:13", - "endDate": "2020-08-11T18:35:13", - "unit": "mg\/min·dL", - "value": -0.005535795415598254 - }, - { - "startDate": "2020-08-11T18:35:13", - "endDate": "2020-08-11T18:40:13", - "unit": "mg\/min·dL", - "value": 0.009928054942067454 - }, - { - "startDate": "2020-08-11T18:40:13", - "endDate": "2020-08-11T18:45:13", - "unit": "mg\/min·dL", - "value": 0.02537816688081129 - }, - { - "startDate": "2020-08-11T18:45:13", - "endDate": "2020-08-11T18:50:13", - "unit": "mg\/min·dL", - "value": 0.040746613021907935 - }, - { - "startDate": "2020-08-11T18:50:13", - "endDate": "2020-08-11T18:55:13", - "unit": "mg\/min·dL", - "value": 0.05595966408835151 - }, - { - "startDate": "2020-08-11T18:55:13", - "endDate": "2020-08-11T19:00:13", - "unit": "mg\/min·dL", - "value": 0.07093892464198123 - }, - { - "startDate": "2020-08-11T19:00:13", - "endDate": "2020-08-11T19:05:13", - "unit": "mg\/min·dL", - "value": 0.08560246050964196 - }, - { - "startDate": "2020-08-11T19:05:13", - "endDate": "2020-08-11T19:10:13", - "unit": "mg\/min·dL", - "value": 0.09986591236653002 - }, - { - "startDate": "2020-08-11T19:10:13", - "endDate": "2020-08-11T19:15:13", - "unit": "mg\/min·dL", - "value": 0.11364358985065513 - }, - { - "startDate": "2020-08-11T19:15:13", - "endDate": "2020-08-11T19:20:13", - "unit": "mg\/min·dL", - "value": 0.12684954054338973 - }, - { - "startDate": "2020-08-11T19:20:13", - "endDate": "2020-08-11T19:25:13", - "unit": "mg\/min·dL", - "value": 0.13939858816698666 - }, - { - "startDate": "2020-08-11T19:25:13", - "endDate": "2020-08-11T19:30:13", - "unit": "mg\/min·dL", - "value": 0.15120733442007542 - }, - { - "startDate": "2020-08-11T19:30:13", - "endDate": "2020-08-11T19:35:13", - "unit": "mg\/min·dL", - "value": 0.16219511899486355 - }, - { - "startDate": "2020-08-11T19:35:13", - "endDate": "2020-08-11T19:40:13", - "unit": "mg\/min·dL", - "value": 0.17228493249382398 - }, - { - "startDate": "2020-08-11T19:40:13", - "endDate": "2020-08-11T19:45:13", - "unit": "mg\/min·dL", - "value": 0.1814042771871964 - }, - { - "startDate": "2020-08-11T19:45:13", - "endDate": "2020-08-11T19:50:13", - "unit": "mg\/min·dL", - "value": 0.18948597082306992 - }, - { - "startDate": "2020-08-11T19:50:13", - "endDate": "2020-08-11T19:55:13", - "unit": "mg\/min·dL", - "value": 0.196468889016708 - }, - { - "startDate": "2020-08-11T19:55:13", - "endDate": "2020-08-11T20:00:13", - "unit": "mg\/min·dL", - "value": 0.20229864210263385 - }, - { - "startDate": "2020-08-11T20:00:13", - "endDate": "2020-08-11T20:05:13", - "unit": "mg\/min·dL", - "value": 0.2069281827278072 - }, - { - "startDate": "2020-08-11T20:05:13", - "endDate": "2020-08-11T20:10:13", - "unit": "mg\/min·dL", - "value": 0.21031834089428644 - }, - { - "startDate": "2020-08-11T20:10:13", - "endDate": "2020-08-11T20:15:13", - "unit": "mg\/min·dL", - "value": 0.21243828362120673 - }, - { - "startDate": "2020-08-11T20:15:13", - "endDate": "2020-08-11T20:20:13", - "unit": "mg\/min·dL", - "value": 0.213265896884441 - }, - { - "startDate": "2020-08-11T20:20:13", - "endDate": "2020-08-11T20:25:13", - "unit": "mg\/min·dL", - "value": 0.212788088004482 - }, - { - "startDate": "2020-08-11T20:25:13", - "endDate": "2020-08-11T20:32:50", - "unit": "mg\/min·dL", - "value": 0.17396858033976298 - }, - { - "startDate": "2020-08-11T20:32:50", - "endDate": "2020-08-11T20:45:02", - "unit": "mg\/min·dL", - "value": 0.18555611348135584 - }, - { - "startDate": "2020-08-11T20:45:02", - "endDate": "2020-08-11T21:09:23", - "unit": "mg\/min·dL", - "value": 0.2025162808274117 - }, - { - "startDate": "2020-08-11T21:09:23", - "endDate": "2020-08-11T21:21:34", - "unit": "mg\/min·dL", - "value": 0.2789312761868744 - }, - { - "startDate": "2020-08-11T21:21:34", - "endDate": "2020-08-11T21:33:17", - "unit": "mg\/min·dL", - "value": 0.17878610561707597 - }, - { - "startDate": "2020-08-11T21:33:17", - "endDate": "2020-08-11T21:38:17", - "unit": "mg\/min·dL", - "value": 0.29216469125794187 - }, - { - "startDate": "2020-08-11T21:38:17", - "endDate": "2020-08-11T21:43:17", - "unit": "mg\/min·dL", - "value": 0.2807908049199831 - }, - { - "startDate": "2020-08-11T21:43:17", - "endDate": "2020-08-11T21:48:04", - "unit": "mg\/min·dL", - "value": 0.27828132940268346 - } -] diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_insulin_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_insulin_effect.json deleted file mode 100644 index cd281f68d0..0000000000 --- a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_insulin_effect.json +++ /dev/null @@ -1,387 +0,0 @@ -[ - { - "date": "2020-08-11T21:50:00", - "amount": -8.639981829288883, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T21:55:00", - "amount": -9.789850828431643, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:00:00", - "amount": -10.963763653811602, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:05:00", - "amount": -12.153219270860628, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:10:00", - "amount": -13.350959307658405, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:15:00", - "amount": -14.550659188660132, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:20:00", - "amount": -15.7467157330705, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:25:00", - "amount": -16.934186099027563, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:30:00", - "amount": -18.108731231758313, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:35:00", - "amount": -19.266563509430355, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:40:00", - "amount": -20.404398300145722, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:45:00", - "amount": -21.51940916202376, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:50:00", - "amount": -22.60918643567319, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:55:00", - "amount": -23.67169899462816, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:00:00", - "amount": -24.705258934584283, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:05:00", - "amount": -25.708488996579725, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:10:00", - "amount": -26.680292532680262, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:15:00", - "amount": -27.619825835301093, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:20:00", - "amount": -28.526472663081833, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:25:00", - "amount": -29.3998208072736, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:30:00", - "amount": -30.239640552942898, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:35:00", - "amount": -31.045864898988505, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:40:00", - "amount": -31.818571410045358, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:45:00", - "amount": -32.557965581850254, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:50:00", - "amount": -33.26436560960395, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:55:00", - "amount": -33.93818845631585, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:00:00", - "amount": -34.57993712509237, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:05:00", - "amount": -35.190189045857444, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:10:00", - "amount": -35.76958549310118, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:15:00", - "amount": -36.31882195696637, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:20:00", - "amount": -36.838639395326744, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:25:00", - "amount": -37.32981629950877, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:30:00", - "amount": -37.7931615109809, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:35:00", - "amount": -38.229507730702785, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:40:00", - "amount": -38.639705666908725, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:45:00", - "amount": -39.024618770914344, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:50:00", - "amount": -39.38511851409847, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:55:00", - "amount": -39.72208016254089, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:00:00", - "amount": -40.03637900890356, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:05:00", - "amount": -40.32888702404502, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:10:00", - "amount": -40.600469893564416, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:15:00", - "amount": -40.85198440699897, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:20:00", - "amount": -41.08427616975518, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:25:00", - "amount": -41.29817761005208, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:30:00", - "amount": -41.494506255204584, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:35:00", - "amount": -41.674063253484796, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:40:00", - "amount": -41.837632119579226, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:45:00", - "amount": -41.98597768331826, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:50:00", - "amount": -42.11984522289805, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:55:00", - "amount": -42.23995976525269, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:00:00", - "amount": -42.347025537572655, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:05:00", - "amount": -42.441725555209814, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:10:00", - "amount": -42.524721332367534, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:15:00", - "amount": -42.59665270305005, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:20:00", - "amount": -42.65813774074594, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:25:00", - "amount": -42.70977276624976, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:30:00", - "amount": -42.752132433888306, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:35:00", - "amount": -42.785769887219686, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:40:00", - "amount": -42.81180494139787, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:45:00", - "amount": -42.831680423307795, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:50:00", - "amount": -42.84629245946508, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:55:00", - "amount": -42.856651353619604, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:00:00", - "amount": -42.86358529644523, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:05:00", - "amount": -42.867860863240104, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:10:00", - "amount": -42.87013681511805, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:15:00", - "amount": -42.871030675309036, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:20:00", - "amount": -42.871120411104464, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:25:00", - "amount": -42.87094608563874, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:30:00", - "amount": -42.870799980845575, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:35:00", - "amount": -42.87068168789406, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:40:00", - "amount": -42.870589529145974, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:45:00", - "amount": -42.870521892591526, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:50:00", - "amount": -42.87047723091304, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:55:00", - "amount": -42.870454060415405, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:00:00", - "amount": -42.87044984787703, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:05:00", - "amount": -42.87044984787703, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:10:00", - "amount": -42.87044984787703, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_momentum_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_momentum_effect.json deleted file mode 100644 index a8472461b2..0000000000 --- a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_momentum_effect.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2020-08-11T21:45:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 0.0596641 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 0.233866 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 0.408067 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 0.582269 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_predicted_glucose.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_predicted_glucose.json deleted file mode 100644 index 7dbe1a743c..0000000000 --- a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_predicted_glucose.json +++ /dev/null @@ -1,392 +0,0 @@ -[ - { - "date": "2020-08-11T21:48:17", - "unit": "mg/dL", - "amount": 129.93174411197853 - }, - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 129.99140823711906 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 130.12765634266816 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 130.32415384711314 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 131.24594584675708 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 132.27012597044103 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 133.19318305239187 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 134.02072027340495 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 134.75768047534217 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 135.4084027129766 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 135.9766746081406 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 136.46578079273215 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 136.87854770863188 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 137.31654821276024 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 137.78181343158306 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 138.2760312694047 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 138.80057898518703 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 139.35655322686426 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 139.94479770202122 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 140.56592865201824 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 141.22035828560425 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 141.90831631771272 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 142.6298697494449 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 143.38494101616584 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 144.17332462213872 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 144.9947023721628 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 145.8486573032287 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 146.73468641222996 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 147.65221226924265 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 148.6005935997767 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 149.57913491368927 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 150.58709525310667 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 151.6236961267024 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 152.68812869300805 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 153.77956025106397 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 154.8971400926358 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 156.04000476640795 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 157.2072828010016 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 158.39809893033697 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 159.61157786175207 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 160.8468476243884 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 162.10304253264678 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 163.37930579699 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 164.67479181201156 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 165.98866814949244 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 167.3201172821177 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 168.6683380616153 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 170.03254697329865 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 171.41197918733738 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 172.80588942553536 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 174.2135526609585 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 175.6342646664163 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 177.0673424265569 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 178.51212442717696 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 179.96797083427222 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 181.4342635743541 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 182.91040632662805 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 183.8903555177219 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 183.85671806439052 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 183.83068301021234 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 183.8108075283024 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 183.7961954921451 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 183.78583659799057 - }, - { - "date": "2020-08-12T03:00:00", - "unit": "mg/dL", - "amount": 183.77890265516493 - }, - { - "date": "2020-08-12T03:05:00", - "unit": "mg/dL", - "amount": 183.77462708837004 - }, - { - "date": "2020-08-12T03:10:00", - "unit": "mg/dL", - "amount": 183.7723511364921 - }, - { - "date": "2020-08-12T03:15:00", - "unit": "mg/dL", - "amount": 183.7714572763011 - }, - { - "date": "2020-08-12T03:20:00", - "unit": "mg/dL", - "amount": 183.77136754050568 - }, - { - "date": "2020-08-12T03:25:00", - "unit": "mg/dL", - "amount": 183.77154186597141 - }, - { - "date": "2020-08-12T03:30:00", - "unit": "mg/dL", - "amount": 183.7716879707646 - }, - { - "date": "2020-08-12T03:35:00", - "unit": "mg/dL", - "amount": 183.7718062637161 - }, - { - "date": "2020-08-12T03:40:00", - "unit": "mg/dL", - "amount": 183.77189842246418 - }, - { - "date": "2020-08-12T03:45:00", - "unit": "mg/dL", - "amount": 183.7719660590186 - }, - { - "date": "2020-08-12T03:50:00", - "unit": "mg/dL", - "amount": 183.7720107206971 - }, - { - "date": "2020-08-12T03:55:00", - "unit": "mg/dL", - "amount": 183.77203389119472 - }, - { - "date": "2020-08-12T04:00:00", - "unit": "mg/dL", - "amount": 183.7720381037331 - }, - { - "date": "2020-08-12T04:05:00", - "unit": "mg/dL", - "amount": 183.7720381037331 - }, - { - "date": "2020-08-12T04:10:00", - "unit": "mg/dL", - "amount": 183.7720381037331 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json deleted file mode 100644 index 64848ef5a2..0000000000 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json +++ /dev/null @@ -1,322 +0,0 @@ -[ - { - "date": "2020-08-12T12:05:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:10:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:15:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:20:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:25:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:30:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:35:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:40:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:45:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:50:00", - "unit": "mg/dL", - "amount": 0.03198444727394316 - }, - { - "date": "2020-08-12T12:55:00", - "unit": "mg/dL", - "amount": 0.4486511139406098 - }, - { - "date": "2020-08-12T13:00:00", - "unit": "mg/dL", - "amount": 0.8653177806072766 - }, - { - "date": "2020-08-12T13:05:00", - "unit": "mg/dL", - "amount": 1.281984447273943 - }, - { - "date": "2020-08-12T13:10:00", - "unit": "mg/dL", - "amount": 1.6986511139406095 - }, - { - "date": "2020-08-12T13:15:00", - "unit": "mg/dL", - "amount": 2.1153177806072767 - }, - { - "date": "2020-08-12T13:20:00", - "unit": "mg/dL", - "amount": 2.5319844472739432 - }, - { - "date": "2020-08-12T13:25:00", - "unit": "mg/dL", - "amount": 2.9486511139406097 - }, - { - "date": "2020-08-12T13:30:00", - "unit": "mg/dL", - "amount": 3.3653177806072763 - }, - { - "date": "2020-08-12T13:35:00", - "unit": "mg/dL", - "amount": 3.7819844472739437 - }, - { - "date": "2020-08-12T13:40:00", - "unit": "mg/dL", - "amount": 4.19865111394061 - }, - { - "date": "2020-08-12T13:45:00", - "unit": "mg/dL", - "amount": 4.615317780607277 - }, - { - "date": "2020-08-12T13:50:00", - "unit": "mg/dL", - "amount": 5.031984447273943 - }, - { - "date": "2020-08-12T13:55:00", - "unit": "mg/dL", - "amount": 5.44865111394061 - }, - { - "date": "2020-08-12T14:00:00", - "unit": "mg/dL", - "amount": 5.865317780607277 - }, - { - "date": "2020-08-12T14:05:00", - "unit": "mg/dL", - "amount": 6.281984447273943 - }, - { - "date": "2020-08-12T14:10:00", - "unit": "mg/dL", - "amount": 6.69865111394061 - }, - { - "date": "2020-08-12T14:15:00", - "unit": "mg/dL", - "amount": 7.115317780607277 - }, - { - "date": "2020-08-12T14:20:00", - "unit": "mg/dL", - "amount": 7.531984447273944 - }, - { - "date": "2020-08-12T14:25:00", - "unit": "mg/dL", - "amount": 7.94865111394061 - }, - { - "date": "2020-08-12T14:30:00", - "unit": "mg/dL", - "amount": 8.365317780607278 - }, - { - "date": "2020-08-12T14:35:00", - "unit": "mg/dL", - "amount": 8.781984447273942 - }, - { - "date": "2020-08-12T14:40:00", - "unit": "mg/dL", - "amount": 9.19865111394061 - }, - { - "date": "2020-08-12T14:45:00", - "unit": "mg/dL", - "amount": 9.615317780607278 - }, - { - "date": "2020-08-12T14:50:00", - "unit": "mg/dL", - "amount": 10.031984447273944 - }, - { - "date": "2020-08-12T14:55:00", - "unit": "mg/dL", - "amount": 10.44865111394061 - }, - { - "date": "2020-08-12T15:00:00", - "unit": "mg/dL", - "amount": 10.865317780607276 - }, - { - "date": "2020-08-12T15:05:00", - "unit": "mg/dL", - "amount": 11.281984447273942 - }, - { - "date": "2020-08-12T15:10:00", - "unit": "mg/dL", - "amount": 11.69865111394061 - }, - { - "date": "2020-08-12T15:15:00", - "unit": "mg/dL", - "amount": 12.115317780607278 - }, - { - "date": "2020-08-12T15:20:00", - "unit": "mg/dL", - "amount": 12.531984447273942 - }, - { - "date": "2020-08-12T15:25:00", - "unit": "mg/dL", - "amount": 12.94865111394061 - }, - { - "date": "2020-08-12T15:30:00", - "unit": "mg/dL", - "amount": 13.365317780607276 - }, - { - "date": "2020-08-12T15:35:00", - "unit": "mg/dL", - "amount": 13.781984447273944 - }, - { - "date": "2020-08-12T15:40:00", - "unit": "mg/dL", - "amount": 14.19865111394061 - }, - { - "date": "2020-08-12T15:45:00", - "unit": "mg/dL", - "amount": 14.615317780607274 - }, - { - "date": "2020-08-12T15:50:00", - "unit": "mg/dL", - "amount": 15.031984447273942 - }, - { - "date": "2020-08-12T15:55:00", - "unit": "mg/dL", - "amount": 15.44865111394061 - }, - { - "date": "2020-08-12T16:00:00", - "unit": "mg/dL", - "amount": 15.865317780607276 - }, - { - "date": "2020-08-12T16:05:00", - "unit": "mg/dL", - "amount": 16.281984447273942 - }, - { - "date": "2020-08-12T16:10:00", - "unit": "mg/dL", - "amount": 16.698651113940613 - }, - { - "date": "2020-08-12T16:15:00", - "unit": "mg/dL", - "amount": 17.115317780607278 - }, - { - "date": "2020-08-12T16:20:00", - "unit": "mg/dL", - "amount": 17.531984447273942 - }, - { - "date": "2020-08-12T16:25:00", - "unit": "mg/dL", - "amount": 17.94865111394061 - }, - { - "date": "2020-08-12T16:30:00", - "unit": "mg/dL", - "amount": 18.365317780607278 - }, - { - "date": "2020-08-12T16:35:00", - "unit": "mg/dL", - "amount": 18.781984447273945 - }, - { - "date": "2020-08-12T16:40:00", - "unit": "mg/dL", - "amount": 19.19865111394061 - }, - { - "date": "2020-08-12T16:45:00", - "unit": "mg/dL", - "amount": 19.615317780607278 - }, - { - "date": "2020-08-12T16:50:00", - "unit": "mg/dL", - "amount": 20.031984447273942 - }, - { - "date": "2020-08-12T16:55:00", - "unit": "mg/dL", - "amount": 20.44865111394061 - }, - { - "date": "2020-08-12T17:00:00", - "unit": "mg/dL", - "amount": 20.865317780607278 - }, - { - "date": "2020-08-12T17:05:00", - "unit": "mg/dL", - "amount": 21.281984447273942 - }, - { - "date": "2020-08-12T17:10:00", - "unit": "mg/dL", - "amount": 21.69865111394061 - }, - { - "date": "2020-08-12T17:15:00", - "unit": "mg/dL", - "amount": 22.115317780607278 - }, - { - "date": "2020-08-12T17:20:00", - "unit": "mg/dL", - "amount": 22.5 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_counteraction_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_counteraction_effect.json deleted file mode 100644 index c7e1881c48..0000000000 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_counteraction_effect.json +++ /dev/null @@ -1,512 +0,0 @@ -[ - { - "startDate": "2020-08-11T19:44:58", - "endDate": "2020-08-11T19:49:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T19:49:58", - "endDate": "2020-08-11T19:54:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T19:54:58", - "endDate": "2020-08-11T19:59:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T19:59:58", - "endDate": "2020-08-11T20:04:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:04:58", - "endDate": "2020-08-11T20:09:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:09:58", - "endDate": "2020-08-11T20:14:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:14:58", - "endDate": "2020-08-11T20:19:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:19:58", - "endDate": "2020-08-11T20:24:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:24:58", - "endDate": "2020-08-11T20:29:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:29:58", - "endDate": "2020-08-11T20:34:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:34:58", - "endDate": "2020-08-11T20:39:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:39:58", - "endDate": "2020-08-11T20:44:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:44:58", - "endDate": "2020-08-11T20:49:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:49:58", - "endDate": "2020-08-11T20:54:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:54:58", - "endDate": "2020-08-11T20:59:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T20:59:58", - "endDate": "2020-08-11T21:04:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:04:58", - "endDate": "2020-08-11T21:09:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:09:58", - "endDate": "2020-08-11T21:14:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:14:58", - "endDate": "2020-08-11T21:19:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:19:58", - "endDate": "2020-08-11T21:24:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:24:58", - "endDate": "2020-08-11T21:29:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:29:58", - "endDate": "2020-08-11T21:34:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:34:58", - "endDate": "2020-08-11T21:39:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:39:58", - "endDate": "2020-08-11T21:44:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:44:58", - "endDate": "2020-08-11T21:49:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:49:58", - "endDate": "2020-08-11T21:54:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:54:58", - "endDate": "2020-08-11T21:59:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T21:59:58", - "endDate": "2020-08-11T22:04:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:04:58", - "endDate": "2020-08-11T22:09:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:09:58", - "endDate": "2020-08-11T22:14:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:14:58", - "endDate": "2020-08-11T22:19:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:19:58", - "endDate": "2020-08-11T22:24:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:24:58", - "endDate": "2020-08-11T22:29:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:29:58", - "endDate": "2020-08-11T22:34:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:34:58", - "endDate": "2020-08-11T22:39:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:39:58", - "endDate": "2020-08-11T22:44:58", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:44:58", - "endDate": "2020-08-11T22:49:44", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:49:44", - "endDate": "2020-08-11T22:54:44", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-11T22:54:44", - "endDate": "2020-08-11T22:59:45", - "unit": "mg\/min·dL", - "value": 0.060537504513367056 - }, - { - "startDate": "2020-08-11T22:59:45", - "endDate": "2020-08-11T23:07:01", - "unit": "mg\/min·dL", - "value": 0.318789967635506 - }, - { - "startDate": "2020-08-11T23:07:01", - "endDate": "2020-08-11T23:20:52", - "unit": "mg\/min·dL", - "value": 0.4770283365992919 - }, - { - "startDate": "2020-08-11T23:20:52", - "endDate": "2020-08-11T23:48:53", - "unit": "mg\/min·dL", - "value": 0.560721533302221 - }, - { - "startDate": "2020-08-11T23:48:53", - "endDate": "2020-08-11T23:59:30", - "unit": "mg\/min·dL", - "value": 0.6389946260986602 - }, - { - "startDate": "2020-08-11T23:59:30", - "endDate": "2020-08-12T00:04:20", - "unit": "mg\/min·dL", - "value": 0.6935601631312946 - }, - { - "startDate": "2020-08-12T00:04:20", - "endDate": "2020-08-12T01:00:27", - "unit": "mg\/min·dL", - "value": 0.688973517799663 - }, - { - "startDate": "2020-08-12T01:00:27", - "endDate": "2020-08-12T02:58:40", - "unit": "mg\/min·dL", - "value": 0.5439342789219825 - }, - { - "startDate": "2020-08-12T02:58:40", - "endDate": "2020-08-12T03:04:10", - "unit": "mg\/min·dL", - "value": 0.3751525560480912 - }, - { - "startDate": "2020-08-12T03:04:10", - "endDate": "2020-08-12T03:16:07", - "unit": "mg\/min·dL", - "value": 0.48551004284584887 - }, - { - "startDate": "2020-08-12T03:16:07", - "endDate": "2020-08-12T09:39:22", - "unit": "mg\/min·dL", - "value": 0.0 - }, - { - "startDate": "2020-08-12T09:39:22", - "endDate": "2020-08-12T09:44:22", - "unit": "mg\/min·dL", - "value": 3.6693499969069935e-07 - }, - { - "startDate": "2020-08-12T09:44:22", - "endDate": "2020-08-12T09:49:22", - "unit": "mg\/min·dL", - "value": 1.23039439366464e-05 - }, - { - "startDate": "2020-08-12T09:49:22", - "endDate": "2020-08-12T09:54:22", - "unit": "mg\/min·dL", - "value": 2.8175153427568468e-05 - }, - { - "startDate": "2020-08-12T09:54:22", - "endDate": "2020-08-12T09:59:22", - "unit": "mg\/min·dL", - "value": 4.2046202615375436e-05 - }, - { - "startDate": "2020-08-12T09:59:22", - "endDate": "2020-08-12T10:04:22", - "unit": "mg\/min·dL", - "value": 5.409396554054199e-05 - }, - { - "startDate": "2020-08-12T10:04:22", - "endDate": "2020-08-12T10:09:22", - "unit": "mg\/min·dL", - "value": 6.448192040302968e-05 - }, - { - "startDate": "2020-08-12T10:09:22", - "endDate": "2020-08-12T10:14:22", - "unit": "mg\/min·dL", - "value": 7.336107701339417e-05 - }, - { - "startDate": "2020-08-12T10:14:22", - "endDate": "2020-08-12T10:19:22", - "unit": "mg\/min·dL", - "value": 8.08708437316198e-05 - }, - { - "startDate": "2020-08-12T10:19:22", - "endDate": "2020-08-12T10:24:22", - "unit": "mg\/min·dL", - "value": 8.713983767792378e-05 - }, - { - "startDate": "2020-08-12T10:24:22", - "endDate": "2020-08-12T10:29:22", - "unit": "mg\/min·dL", - "value": 9.228664177056543e-05 - }, - { - "startDate": "2020-08-12T10:29:22", - "endDate": "2020-08-12T10:34:22", - "unit": "mg\/min·dL", - "value": 9.642051192999891e-05 - }, - { - "startDate": "2020-08-12T10:34:22", - "endDate": "2020-08-12T10:39:22", - "unit": "mg\/min·dL", - "value": 9.964203758581272e-05 - }, - { - "startDate": "2020-08-12T10:39:22", - "endDate": "2020-08-12T10:44:22", - "unit": "mg\/min·dL", - "value": 0.0001020437584319726 - }, - { - "startDate": "2020-08-12T10:44:22", - "endDate": "2020-08-12T10:49:22", - "unit": "mg\/min·dL", - "value": 0.00010371074019636158 - }, - { - "startDate": "2020-08-12T10:49:22", - "endDate": "2020-08-12T10:54:22", - "unit": "mg\/min·dL", - "value": 0.00010472111202159181 - }, - { - "startDate": "2020-08-12T10:54:22", - "endDate": "2020-08-12T10:59:22", - "unit": "mg\/min·dL", - "value": 0.00010514656789532351 - }, - { - "startDate": "2020-08-12T10:59:22", - "endDate": "2020-08-12T11:04:22", - "unit": "mg\/min·dL", - "value": 0.00010505283441879423 - }, - { - "startDate": "2020-08-12T11:04:22", - "endDate": "2020-08-12T11:09:22", - "unit": "mg\/min·dL", - "value": 0.00010450010706183134 - }, - { - "startDate": "2020-08-12T11:09:22", - "endDate": "2020-08-12T11:14:22", - "unit": "mg\/min·dL", - "value": 0.00010354345692046938 - }, - { - "startDate": "2020-08-12T11:14:22", - "endDate": "2020-08-12T11:19:22", - "unit": "mg\/min·dL", - "value": 0.0001022332098690782 - }, - { - "startDate": "2020-08-12T11:19:22", - "endDate": "2020-08-12T11:24:22", - "unit": "mg\/min·dL", - "value": 0.00010061529988214819 - }, - { - "startDate": "2020-08-12T11:24:22", - "endDate": "2020-08-12T11:29:22", - "unit": "mg\/min·dL", - "value": 9.873159819104443e-05 - }, - { - "startDate": "2020-08-12T11:29:22", - "endDate": "2020-08-12T11:34:22", - "unit": "mg\/min·dL", - "value": 9.662021983793364e-05 - }, - { - "startDate": "2020-08-12T11:34:22", - "endDate": "2020-08-12T11:39:22", - "unit": "mg\/min·dL", - "value": 9.431580909200209e-05 - }, - { - "startDate": "2020-08-12T11:39:22", - "endDate": "2020-08-12T11:44:22", - "unit": "mg\/min·dL", - "value": 9.184980510203684e-05 - }, - { - "startDate": "2020-08-12T11:44:22", - "endDate": "2020-08-12T11:49:22", - "unit": "mg\/min·dL", - "value": 8.925068907371241e-05 - }, - { - "startDate": "2020-08-12T11:49:22", - "endDate": "2020-08-12T11:54:22", - "unit": "mg\/min·dL", - "value": 8.654421417950385e-05 - }, - { - "startDate": "2020-08-12T11:54:22", - "endDate": "2020-08-12T11:59:22", - "unit": "mg\/min·dL", - "value": 8.375361933351428e-05 - }, - { - "startDate": "2020-08-12T11:59:22", - "endDate": "2020-08-12T12:04:22", - "unit": "mg\/min·dL", - "value": 8.089982789249161e-05 - }, - { - "startDate": "2020-08-12T12:04:22", - "endDate": "2020-08-12T12:09:22", - "unit": "mg\/min·dL", - "value": 7.800163227757589e-05 - }, - { - "startDate": "2020-08-12T12:09:22", - "endDate": "2020-08-12T12:14:22", - "unit": "mg\/min·dL", - "value": 7.507586544868751e-05 - }, - { - "startDate": "2020-08-12T12:14:22", - "endDate": "2020-08-12T12:19:22", - "unit": "mg\/min·dL", - "value": 7.213756010459904e-05 - }, - { - "startDate": "2020-08-12T12:19:22", - "endDate": "2020-08-12T12:24:22", - "unit": "mg\/min·dL", - "value": 6.920009642648118e-05 - }, - { - "startDate": "2020-08-12T12:24:22", - "endDate": "2020-08-12T12:29:22", - "unit": "mg\/min·dL", - "value": 6.627533913084806e-05 - }, - { - "startDate": "2020-08-12T12:29:22", - "endDate": "2020-08-12T12:34:22", - "unit": "mg\/min·dL", - "value": 6.337376454910829e-05 - }, - { - "startDate": "2020-08-12T12:34:22", - "endDate": "2020-08-12T12:38:59", - "unit": "mg\/min·dL", - "value": 6.563204470819873e-05 - } -] diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_insulin_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_insulin_effect.json deleted file mode 100644 index e27206385c..0000000000 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_insulin_effect.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-12T12:40:00", - "amount": 0.0, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T12:45:00", - "amount": 0.0, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T12:50:00", - "amount": -0.00010857088891486093, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T12:55:00", - "amount": -0.11764496465132551, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:00:00", - "amount": -0.43873902047529706, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:05:00", - "amount": -0.9379108424564665, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:10:00", - "amount": -1.5919285563573975, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:15:00", - "amount": -2.379638252059979, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:20:00", - "amount": -3.281805691343955, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:25:00", - "amount": -4.280969013729399, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:30:00", - "amount": -5.361301721085654, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:35:00", - "amount": -6.508485266770114, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:40:00", - "amount": -7.709590617387781, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:45:00", - "amount": -8.952968195018745, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:50:00", - "amount": -10.228145645097738, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T13:55:00", - "amount": -11.525732910191868, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:00:00", - "amount": -12.837334122842806, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:05:00", - "amount": -14.15546586154826, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:10:00", - "amount": -15.473481342970688, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:15:00", - "amount": -16.785500150695594, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:20:00", - "amount": -18.08634312642022, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:25:00", - "amount": -19.3714720734388, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:30:00", - "amount": -20.636933944795025, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:35:00", - "amount": -21.8793092095861, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:40:00", - "amount": -23.095664110708345, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:45:00", - "amount": -24.28350654591071, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:50:00", - "amount": -25.440745321443842, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T14:55:00", - "amount": -26.565652543928085, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:00:00", - "amount": -27.65682893137946, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:05:00", - "amount": -28.71317183868988, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:10:00", - "amount": -29.733845806315355, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:15:00", - "amount": -30.71825545353683, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:20:00", - "amount": -31.666020549476087, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:25:00", - "amount": -32.576953106120015, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:30:00", - "amount": -33.45103634797771, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:35:00", - "amount": -34.2884054227078, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:40:00", - "amount": -35.08932972614962, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:45:00", - "amount": -35.854196723707794, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:50:00", - "amount": -36.58349715801203, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T15:55:00", - "amount": -37.27781154023619, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:00:00", - "amount": -37.93779782944274, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:05:00", - "amount": -38.564180210852335, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:10:00", - "amount": -39.15773889005006, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:15:00", - "amount": -39.7193008258551, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:20:00", - "amount": -40.24973132992669, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:25:00", - "amount": -40.749926466175516, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:30:00", - "amount": -41.220806187721365, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:35:00", - "amount": -41.66330815350251, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:40:00", - "amount": -42.078382170721305, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:45:00", - "amount": -42.46698521311987, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:50:00", - "amount": -42.8300769686383, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T16:55:00", - "amount": -43.16861587332966, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:00:00", - "amount": -43.483555591507375, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:05:00", - "amount": -43.77584190499485, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:10:00", - "amount": -44.04640997704762, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:15:00", - "amount": -44.296181959036595, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:20:00", - "amount": -44.52606491033073, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:25:00", - "amount": -44.736949004006426, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:30:00", - "amount": -44.92970599305237, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:35:00", - "amount": -45.10518791363997, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:40:00", - "amount": -45.264226003800765, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:45:00", - "amount": -45.40762981750194, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:50:00", - "amount": -45.53618651564582, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T17:55:00", - "amount": -45.650660316948574, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:00:00", - "amount": -45.75179209298248, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:05:00", - "amount": -45.8402990929014, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:10:00", - "amount": -45.9168747845193, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:15:00", - "amount": -45.98218879947835, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:20:00", - "amount": -46.036886971235035, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:25:00", - "amount": -46.08159145551451, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:30:00", - "amount": -46.116900923736516, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:35:00", - "amount": -46.14339082071065, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:40:00", - "amount": -46.16161367863287, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:45:00", - "amount": -46.17209948009722, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:50:00", - "amount": -46.175359225273134, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T18:55:00", - "amount": -46.175359225273134, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_momentum_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_momentum_effect.json deleted file mode 100644 index 4d59e70865..0000000000 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_momentum_effect.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2020-08-12T12:35:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:40:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:45:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:50:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-12T12:55:00", - "unit": "mg/dL", - "amount": 0.0 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_predicted_glucose.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_predicted_glucose.json deleted file mode 100644 index 5f757341ae..0000000000 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_predicted_glucose.json +++ /dev/null @@ -1,387 +0,0 @@ -[ - { - "date": "2020-08-12T12:39:22", - "unit": "mg/dL", - "amount": 200.0 - }, - { - "date": "2020-08-12T12:40:00", - "unit": "mg/dL", - "amount": 200.0 - }, - { - "date": "2020-08-12T12:45:00", - "unit": "mg/dL", - "amount": 200.00001542044052 - }, - { - "date": "2020-08-12T12:50:00", - "unit": "mg/dL", - "amount": 200.0120908555042 - }, - { - "date": "2020-08-12T12:55:00", - "unit": "mg/dL", - "amount": 200.22415504165645 - }, - { - "date": "2020-08-12T13:00:00", - "unit": "mg/dL", - "amount": 200.31998733993237 - }, - { - "date": "2020-08-12T13:05:00", - "unit": "mg/dL", - "amount": 200.23770477384636 - }, - { - "date": "2020-08-12T13:10:00", - "unit": "mg/dL", - "amount": 200.00053921763583 - }, - { - "date": "2020-08-12T13:15:00", - "unit": "mg/dL", - "amount": 199.6296445814189 - }, - { - "date": "2020-08-12T13:20:00", - "unit": "mg/dL", - "amount": 199.14425510341582 - }, - { - "date": "2020-08-12T13:25:00", - "unit": "mg/dL", - "amount": 198.56183264410652 - }, - { - "date": "2020-08-12T13:30:00", - "unit": "mg/dL", - "amount": 197.8982037016217 - }, - { - "date": "2020-08-12T13:35:00", - "unit": "mg/dL", - "amount": 197.1676868226039 - }, - { - "date": "2020-08-12T13:40:00", - "unit": "mg/dL", - "amount": 196.3832481386529 - }, - { - "date": "2020-08-12T13:45:00", - "unit": "mg/dL", - "amount": 195.5565372276886 - }, - { - "date": "2020-08-12T13:50:00", - "unit": "mg/dL", - "amount": 194.69802644427628 - }, - { - "date": "2020-08-12T13:55:00", - "unit": "mg/dL", - "amount": 193.81710584584883 - }, - { - "date": "2020-08-12T14:00:00", - "unit": "mg/dL", - "amount": 192.92217129986454 - }, - { - "date": "2020-08-12T14:05:00", - "unit": "mg/dL", - "amount": 192.02070622782577 - }, - { - "date": "2020-08-12T14:10:00", - "unit": "mg/dL", - "amount": 191.11935741307002 - }, - { - "date": "2020-08-12T14:15:00", - "unit": "mg/dL", - "amount": 190.2240052720118 - }, - { - "date": "2020-08-12T14:20:00", - "unit": "mg/dL", - "amount": 189.33982896295385 - }, - { - "date": "2020-08-12T14:25:00", - "unit": "mg/dL", - "amount": 188.47136668260194 - }, - { - "date": "2020-08-12T14:30:00", - "unit": "mg/dL", - "amount": 187.62257147791237 - }, - { - "date": "2020-08-12T14:35:00", - "unit": "mg/dL", - "amount": 186.79686287978797 - }, - { - "date": "2020-08-12T14:40:00", - "unit": "mg/dL", - "amount": 185.9971746453324 - }, - { - "date": "2020-08-12T14:45:00", - "unit": "mg/dL", - "amount": 185.2259988767967 - }, - { - "date": "2020-08-12T14:50:00", - "unit": "mg/dL", - "amount": 184.48542676793022 - }, - { - "date": "2020-08-12T14:55:00", - "unit": "mg/dL", - "amount": 183.77718621211264 - }, - { - "date": "2020-08-12T15:00:00", - "unit": "mg/dL", - "amount": 183.10267649132794 - }, - { - "date": "2020-08-12T15:05:00", - "unit": "mg/dL", - "amount": 182.4630002506842 - }, - { - "date": "2020-08-12T15:10:00", - "unit": "mg/dL", - "amount": 181.85899294972538 - }, - { - "date": "2020-08-12T15:15:00", - "unit": "mg/dL", - "amount": 181.29124996917056 - }, - { - "date": "2020-08-12T15:20:00", - "unit": "mg/dL", - "amount": 180.76015153989798 - }, - { - "date": "2020-08-12T15:25:00", - "unit": "mg/dL", - "amount": 180.26588564992073 - }, - { - "date": "2020-08-12T15:30:00", - "unit": "mg/dL", - "amount": 179.80846907472971 - }, - { - "date": "2020-08-12T15:35:00", - "unit": "mg/dL", - "amount": 179.3877666666663 - }, - { - "date": "2020-08-12T15:40:00", - "unit": "mg/dL", - "amount": 179.00350902989112 - }, - { - "date": "2020-08-12T15:45:00", - "unit": "mg/dL", - "amount": 178.65530869899962 - }, - { - "date": "2020-08-12T15:50:00", - "unit": "mg/dL", - "amount": 178.34267493136204 - }, - { - "date": "2020-08-12T15:55:00", - "unit": "mg/dL", - "amount": 178.06502721580455 - }, - { - "date": "2020-08-12T16:00:00", - "unit": "mg/dL", - "amount": 177.82170759326468 - }, - { - "date": "2020-08-12T16:05:00", - "unit": "mg/dL", - "amount": 177.61199187852174 - }, - { - "date": "2020-08-12T16:10:00", - "unit": "mg/dL", - "amount": 177.43509986599068 - }, - { - "date": "2020-08-12T16:15:00", - "unit": "mg/dL", - "amount": 177.2902045968523 - }, - { - "date": "2020-08-12T16:20:00", - "unit": "mg/dL", - "amount": 177.1764407594474 - }, - { - "date": "2020-08-12T16:25:00", - "unit": "mg/dL", - "amount": 177.09291228986524 - }, - { - "date": "2020-08-12T16:30:00", - "unit": "mg/dL", - "amount": 177.03869923498607 - }, - { - "date": "2020-08-12T16:35:00", - "unit": "mg/dL", - "amount": 177.0128639358716 - }, - { - "date": "2020-08-12T16:40:00", - "unit": "mg/dL", - "amount": 177.01445658531946 - }, - { - "date": "2020-08-12T16:45:00", - "unit": "mg/dL", - "amount": 177.04252020958756 - }, - { - "date": "2020-08-12T16:50:00", - "unit": "mg/dL", - "amount": 177.0960951207358 - }, - { - "date": "2020-08-12T16:55:00", - "unit": "mg/dL", - "amount": 177.17422288271112 - }, - { - "date": "2020-08-12T17:00:00", - "unit": "mg/dL", - "amount": 177.27594983120008 - }, - { - "date": "2020-08-12T17:05:00", - "unit": "mg/dL", - "amount": 177.40033018437927 - }, - { - "date": "2020-08-12T17:10:00", - "unit": "mg/dL", - "amount": 177.54642877899317 - }, - { - "date": "2020-08-12T17:15:00", - "unit": "mg/dL", - "amount": 177.71332346367086 - }, - { - "date": "2020-08-12T17:20:00", - "unit": "mg/dL", - "amount": 177.86812273176946 - }, - { - "date": "2020-08-12T17:25:00", - "unit": "mg/dL", - "amount": 177.65723863809376 - }, - { - "date": "2020-08-12T17:30:00", - "unit": "mg/dL", - "amount": 177.4644816490478 - }, - { - "date": "2020-08-12T17:35:00", - "unit": "mg/dL", - "amount": 177.2889997284602 - }, - { - "date": "2020-08-12T17:40:00", - "unit": "mg/dL", - "amount": 177.1299616382994 - }, - { - "date": "2020-08-12T17:45:00", - "unit": "mg/dL", - "amount": 176.9865578245982 - }, - { - "date": "2020-08-12T17:50:00", - "unit": "mg/dL", - "amount": 176.85800112645433 - }, - { - "date": "2020-08-12T17:55:00", - "unit": "mg/dL", - "amount": 176.74352732515158 - }, - { - "date": "2020-08-12T18:00:00", - "unit": "mg/dL", - "amount": 176.64239554911768 - }, - { - "date": "2020-08-12T18:05:00", - "unit": "mg/dL", - "amount": 176.55388854919875 - }, - { - "date": "2020-08-12T18:10:00", - "unit": "mg/dL", - "amount": 176.47731285758084 - }, - { - "date": "2020-08-12T18:15:00", - "unit": "mg/dL", - "amount": 176.41199884262178 - }, - { - "date": "2020-08-12T18:20:00", - "unit": "mg/dL", - "amount": 176.3573006708651 - }, - { - "date": "2020-08-12T18:25:00", - "unit": "mg/dL", - "amount": 176.3125961865856 - }, - { - "date": "2020-08-12T18:30:00", - "unit": "mg/dL", - "amount": 176.2772867183636 - }, - { - "date": "2020-08-12T18:35:00", - "unit": "mg/dL", - "amount": 176.25079682138946 - }, - { - "date": "2020-08-12T18:40:00", - "unit": "mg/dL", - "amount": 176.23257396346725 - }, - { - "date": "2020-08-12T18:45:00", - "unit": "mg/dL", - "amount": 176.22208816200288 - }, - { - "date": "2020-08-12T18:50:00", - "unit": "mg/dL", - "amount": 176.21882841682697 - }, - { - "date": "2020-08-12T18:55:00", - "unit": "mg/dL", - "amount": 176.21882841682697 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_carb_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_carb_effect.json deleted file mode 100644 index 3c22d51132..0000000000 --- a/LoopTests/Fixtures/low_and_falling/low_and_falling_carb_effect.json +++ /dev/null @@ -1,322 +0,0 @@ -[ - { - "date": "2020-08-11T21:35:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:40:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:45:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 1.113814925485187 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 2.641592703262965 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 4.169370481040743 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 5.697148258818521 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 7.224926036596299 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 8.752703814374076 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 10.280481592151855 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 11.808259369929631 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 13.336037147707408 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 14.863814925485187 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 16.391592703262965 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 17.919370481040744 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 19.44714825881852 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 20.974926036596298 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 22.502703814374076 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 24.030481592151855 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 25.558259369929633 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 27.086037147707408 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 28.613814925485187 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 30.141592703262965 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 31.66937048104074 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 33.197148258818515 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 34.7249260365963 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 36.25270381437407 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 37.78048159215186 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 39.30825936992963 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 40.83603714770741 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 42.36381492548519 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 43.891592703262965 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 45.419370481040744 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 46.947148258818515 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 48.47492603659629 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 50.00270381437408 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 51.53048159215186 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 53.05825936992963 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 54.58603714770741 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 56.113814925485194 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 57.641592703262965 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 59.169370481040744 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 60.697148258818515 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 62.2249260365963 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 63.75270381437407 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 65.28048159215186 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 66.80825936992963 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 68.33603714770742 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 69.86381492548519 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 71.39159270326296 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 72.91937048104073 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 74.44714825881853 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 75.9749260365963 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 77.50270381437407 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 79.03048159215186 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 80.55825936992963 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 82.08603714770742 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 82.5 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_counteraction_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_counteraction_effect.json deleted file mode 100644 index 5e9442a191..0000000000 --- a/LoopTests/Fixtures/low_and_falling/low_and_falling_counteraction_effect.json +++ /dev/null @@ -1,218 +0,0 @@ -[ - { - "startDate": "2020-08-11T19:06:06", - "endDate": "2020-08-11T19:11:06", - "unit": "mg\/min·dL", - "value": -0.3485359639226971 - }, - { - "startDate": "2020-08-11T19:11:06", - "endDate": "2020-08-11T19:16:06", - "unit": "mg\/min·dL", - "value": -0.34571948711910916 - }, - { - "startDate": "2020-08-11T19:16:06", - "endDate": "2020-08-11T19:21:06", - "unit": "mg\/min·dL", - "value": -0.3110996208816001 - }, - { - "startDate": "2020-08-11T19:21:06", - "endDate": "2020-08-11T19:26:06", - "unit": "mg\/min·dL", - "value": -0.17115290442012446 - }, - { - "startDate": "2020-08-11T19:26:06", - "endDate": "2020-08-11T19:31:06", - "unit": "mg\/min·dL", - "value": -0.035078937546724906 - }, - { - "startDate": "2020-08-11T19:31:06", - "endDate": "2020-08-11T19:36:06", - "unit": "mg\/min·dL", - "value": 0.08735109214809061 - }, - { - "startDate": "2020-08-11T19:36:06", - "endDate": "2020-08-11T19:41:06", - "unit": "mg\/min·dL", - "value": 0.19746935782304254 - }, - { - "startDate": "2020-08-11T19:41:06", - "endDate": "2020-08-11T19:46:06", - "unit": "mg\/min·dL", - "value": 0.2964814415989495 - }, - { - "startDate": "2020-08-11T19:46:06", - "endDate": "2020-08-11T19:51:06", - "unit": "mg\/min·dL", - "value": 0.3854747645772338 - }, - { - "startDate": "2020-08-11T19:51:06", - "endDate": "2020-08-11T19:56:06", - "unit": "mg\/min·dL", - "value": 0.4654266535840445 - }, - { - "startDate": "2020-08-11T19:56:06", - "endDate": "2020-08-11T20:01:06", - "unit": "mg\/min·dL", - "value": 0.5372120677140927 - }, - { - "startDate": "2020-08-11T20:01:06", - "endDate": "2020-08-11T20:06:06", - "unit": "mg\/min·dL", - "value": 0.6016110049547307 - }, - { - "startDate": "2020-08-11T20:06:06", - "endDate": "2020-08-11T20:11:06", - "unit": "mg\/min·dL", - "value": 0.6593156065538323 - }, - { - "startDate": "2020-08-11T20:11:06", - "endDate": "2020-08-11T20:16:06", - "unit": "mg\/min·dL", - "value": 0.7109369743543738 - }, - { - "startDate": "2020-08-11T20:16:06", - "endDate": "2020-08-11T20:21:06", - "unit": "mg\/min·dL", - "value": 0.7570117140551543 - }, - { - "startDate": "2020-08-11T20:21:06", - "endDate": "2020-08-11T20:26:06", - "unit": "mg\/min·dL", - "value": 0.7980082152690883 - }, - { - "startDate": "2020-08-11T20:26:06", - "endDate": "2020-08-11T20:31:06", - "unit": "mg\/min·dL", - "value": 0.8343326773392674 - }, - { - "startDate": "2020-08-11T20:31:06", - "endDate": "2020-08-11T20:36:06", - "unit": "mg\/min·dL", - "value": 0.8663348881353556 - }, - { - "startDate": "2020-08-11T20:36:06", - "endDate": "2020-08-11T20:41:06", - "unit": "mg\/min·dL", - "value": 0.894313761489434 - }, - { - "startDate": "2020-08-11T20:41:06", - "endDate": "2020-08-11T20:46:06", - "unit": "mg\/min·dL", - "value": 0.9185226375377566 - }, - { - "startDate": "2020-08-11T20:46:06", - "endDate": "2020-08-11T20:51:06", - "unit": "mg\/min·dL", - "value": 0.9391743490118711 - }, - { - "startDate": "2020-08-11T20:51:06", - "endDate": "2020-08-11T20:56:06", - "unit": "mg\/min·dL", - "value": 0.9564460554651047 - }, - { - "startDate": "2020-08-11T20:56:06", - "endDate": "2020-08-11T21:01:06", - "unit": "mg\/min·dL", - "value": 0.9704838465264228 - }, - { - "startDate": "2020-08-11T21:01:06", - "endDate": "2020-08-11T21:06:06", - "unit": "mg\/min·dL", - "value": 0.9814071145378676 - }, - { - "startDate": "2020-08-11T21:06:06", - "endDate": "2020-08-11T21:11:06", - "unit": "mg\/min·dL", - "value": 0.9893126963505664 - }, - { - "startDate": "2020-08-11T21:11:06", - "endDate": "2020-08-11T21:16:06", - "unit": "mg\/min·dL", - "value": 0.9942787836220887 - }, - { - "startDate": "2020-08-11T21:16:06", - "endDate": "2020-08-11T21:21:06", - "unit": "mg\/min·dL", - "value": 0.9963686006690381 - }, - { - "startDate": "2020-08-11T21:21:06", - "endDate": "2020-08-11T21:26:06", - "unit": "mg\/min·dL", - "value": 0.9956338487771189 - }, - { - "startDate": "2020-08-11T21:26:06", - "endDate": "2020-08-11T21:31:06", - "unit": "mg\/min·dL", - "value": 0.9921179158496414 - }, - { - "startDate": "2020-08-11T21:31:06", - "endDate": "2020-08-11T21:36:06", - "unit": "mg\/min·dL", - "value": 0.9858588503769765 - }, - { - "startDate": "2020-08-11T21:36:06", - "endDate": "2020-08-11T21:41:06", - "unit": "mg\/min·dL", - "value": 0.9768920989266177 - }, - { - "startDate": "2020-08-11T21:41:06", - "endDate": "2020-08-11T21:46:06", - "unit": "mg\/min·dL", - "value": 0.965253006677156 - }, - { - "startDate": "2020-08-11T21:46:06", - "endDate": "2020-08-11T21:51:06", - "unit": "mg\/min·dL", - "value": 0.9509790809419911 - }, - { - "startDate": "2020-08-11T21:51:06", - "endDate": "2020-08-11T21:56:06", - "unit": "mg\/min·dL", - "value": 0.9341120181395608 - }, - { - "startDate": "2020-08-11T21:56:06", - "endDate": "2020-08-11T22:01:06", - "unit": "mg\/min·dL", - "value": 0.9146994952586709 - }, - { - "startDate": "2020-08-11T22:01:06", - "endDate": "2020-08-11T22:06:06", - "unit": "mg\/min·dL", - "value": 0.8927967275284316 - } -] diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_insulin_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_insulin_effect.json deleted file mode 100644 index fadbdb4765..0000000000 --- a/LoopTests/Fixtures/low_and_falling/low_and_falling_insulin_effect.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-11T22:05:00", - "amount": 0.0, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:10:00", - "amount": 0.0, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:15:00", - "amount": 0.0, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:20:00", - "amount": -0.1458612769290415, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:25:00", - "amount": -0.9512697097060898, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:30:00", - "amount": -2.3842190211605305, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:35:00", - "amount": -4.364249056420911, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:40:00", - "amount": -6.818055744021179, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:45:00", - "amount": -9.678947529165939, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:50:00", - "amount": -12.88633950788323, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:55:00", - "amount": -16.385282799253694, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:00:00", - "amount": -20.126026847056842, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:05:00", - "amount": -24.06361248698291, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:10:00", - "amount": -28.157493751577, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:15:00", - "amount": -32.37118651282725, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:20:00", - "amount": -36.67194218227609, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:25:00", - "amount": -41.03044480117815, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:30:00", - "amount": -45.420529958992645, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:35:00", - "amount": -49.81892407778424, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:40:00", - "amount": -54.205002693305985, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:45:00", - "amount": -58.56056645101005, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:50:00", - "amount": -62.869633617321924, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:55:00", - "amount": -67.11824798354047, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:00:00", - "amount": -71.29430111199574, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:05:00", - "amount": -75.38736794189215, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:10:00", - "amount": -79.38855483585641, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:15:00", - "amount": -83.29035920784969, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:20:00", - "amount": -87.0865399290309, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:25:00", - "amount": -90.77199776059544, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:30:00", - "amount": -94.34266511177375, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:35:00", - "amount": -97.79540446725352, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:40:00", - "amount": -101.12791487147612, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:45:00", - "amount": -104.33864589772718, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:50:00", - "amount": -107.42671856785853, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:55:00", - "amount": -110.39185272399912, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:00:00", - "amount": -113.23430038688294, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:05:00", - "amount": -115.95478466657976, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:10:00", - "amount": -118.55444382058852, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:15:00", - "amount": -121.03478008156584, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:20:00", - "amount": -123.39761290252783, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:25:00", - "amount": -125.64503629128907, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:30:00", - "amount": -127.7793799282899, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:35:00", - "amount": -129.8031737829074, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:40:00", - "amount": -131.71911596293566, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:45:00", - "amount": -133.53004355024044, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:50:00", - "amount": -135.23890619272336, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:55:00", - "amount": -136.84874223874277, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:00:00", - "amount": -138.3626572151035, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:05:00", - "amount": -139.78380446371185, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:10:00", - "amount": -141.1153677650564, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:15:00", - "amount": -142.3605457888761, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:20:00", - "amount": -143.5225382237712, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:25:00", - "amount": -144.6045334481494, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:30:00", - "amount": -145.60969761482852, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:35:00", - "amount": -146.54116503087826, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:40:00", - "amount": -147.40202972292892, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:45:00", - "amount": -148.19533808623217, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:50:00", - "amount": -148.9240825232751, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:55:00", - "amount": -149.5911959847532, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:00:00", - "amount": -150.1995473322352, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:05:00", - "amount": -150.7519374479337, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:10:00", - "amount": -151.251096022658, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:15:00", - "amount": -151.69967895829902, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:20:00", - "amount": -152.1002663261011, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:25:00", - "amount": -152.45536082654326, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:30:00", - "amount": -152.76738670089628, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:35:00", - "amount": -153.03868904847147, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:40:00", - "amount": -153.2715335072446, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:45:00", - "amount": -153.4681062589482, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:50:00", - "amount": -153.63051432289012, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:55:00", - "amount": -153.76078610569454, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:00:00", - "amount": -153.86087217688896, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:05:00", - "amount": -153.9326462427885, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:10:00", - "amount": -153.97790629347384, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:15:00", - "amount": -153.99837589983005, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:20:00", - "amount": -154.0, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_momentum_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_momentum_effect.json deleted file mode 100644 index 984694a465..0000000000 --- a/LoopTests/Fixtures/low_and_falling/low_and_falling_momentum_effect.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 1.35325 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 3.09052 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 4.8278 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 6.56507 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_predicted_glucose.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_predicted_glucose.json deleted file mode 100644 index 06e2b7a85e..0000000000 --- a/LoopTests/Fixtures/low_and_falling/low_and_falling_predicted_glucose.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-11T22:06:06", - "unit": "mg/dL", - "amount": 75.10768374646841 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 76.46093289895596 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 79.04942397908675 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 83.00725362848293 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 87.52123075828584 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 91.12697884165053 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 93.68408625591766 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 95.26585707255327 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 95.93898284635277 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 95.76404848128813 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 94.7960028582787 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 93.08459653354495 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 90.67478867139667 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 88.10868518458037 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 85.4227702011079 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 82.64979230943683 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 79.81906746831255 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 76.95676008827584 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 74.08614374726203 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 71.22784290951806 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 68.40005692959177 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 65.61876754105768 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 62.8979309526169 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 60.24965560193941 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 57.684366549820766 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 55.21095743363429 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 52.836930839418784 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 50.568527896015354 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 48.41084784222859 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 46.36795826882805 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 44.442996691126055 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 42.63826406468122 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 40.955310816207955 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 39.39501592385437 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 37.95765954549155 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 36.642989660385524 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 35.45028315846649 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 34.3784017822355 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 33.42584329903596 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 32.59078825585176 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 31.871142644868286 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 31.264576785645232 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 30.768560708805495 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 30.380396306555042 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 30.09724649702804 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 29.91616163232291 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 29.834103364081273 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 29.84796616549835 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 29.95459669466777 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 30.150811171100997 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 30.433410925059064 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 30.799196267941767 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 31.244978821341334 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 31.767592432439983 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 32.36390279416804 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 33.03081587989516 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 33.765285294369704 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 33.45050370961934 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 32.783390248141245 - }, - { - "date": "2020-08-12T03:00:00", - "unit": "mg/dL", - "amount": 32.175038900659246 - }, - { - "date": "2020-08-12T03:05:00", - "unit": "mg/dL", - "amount": 31.622648784960745 - }, - { - "date": "2020-08-12T03:10:00", - "unit": "mg/dL", - "amount": 31.12349021023644 - }, - { - "date": "2020-08-12T03:15:00", - "unit": "mg/dL", - "amount": 30.674907274595427 - }, - { - "date": "2020-08-12T03:20:00", - "unit": "mg/dL", - "amount": 30.27431990679335 - }, - { - "date": "2020-08-12T03:25:00", - "unit": "mg/dL", - "amount": 29.919225406351188 - }, - { - "date": "2020-08-12T03:30:00", - "unit": "mg/dL", - "amount": 29.607199531998162 - }, - { - "date": "2020-08-12T03:35:00", - "unit": "mg/dL", - "amount": 29.335897184422976 - }, - { - "date": "2020-08-12T03:40:00", - "unit": "mg/dL", - "amount": 29.10305272564983 - }, - { - "date": "2020-08-12T03:45:00", - "unit": "mg/dL", - "amount": 28.906479973946233 - }, - { - "date": "2020-08-12T03:50:00", - "unit": "mg/dL", - "amount": 28.744071910004322 - }, - { - "date": "2020-08-12T03:55:00", - "unit": "mg/dL", - "amount": 28.61380012719991 - }, - { - "date": "2020-08-12T04:00:00", - "unit": "mg/dL", - "amount": 28.513714056005483 - }, - { - "date": "2020-08-12T04:05:00", - "unit": "mg/dL", - "amount": 28.441939990105936 - }, - { - "date": "2020-08-12T04:10:00", - "unit": "mg/dL", - "amount": 28.396679939420608 - }, - { - "date": "2020-08-12T04:15:00", - "unit": "mg/dL", - "amount": 28.376210333064392 - }, - { - "date": "2020-08-12T04:20:00", - "unit": "mg/dL", - "amount": 28.374586232894444 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_carb_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_carb_effect.json deleted file mode 100644 index c72f05d1b8..0000000000 --- a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_carb_effect.json +++ /dev/null @@ -1,312 +0,0 @@ -[ - { - "date": "2020-08-11T21:50:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T21:55:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:00:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:05:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:10:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:15:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:20:00", - "unit": "mg/dL", - "amount": 0.0 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 3.3782119779158717 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 4.90598975569365 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 6.4337675334714275 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 13.234180198944518 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 20.873069087833407 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 28.511957976722293 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 36.150846865611186 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 43.78973575450007 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 51.428624643388964 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 59.06751353227786 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 66.70640242116674 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 74.34529131005563 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 76.71154531124921 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 78.23932308902698 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 79.76710086680475 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 81.29487864458254 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 82.82265642236032 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 84.3504342001381 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 85.87821197791587 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 87.40598975569364 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 88.93376753347144 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 90.46154531124921 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 91.98932308902698 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 93.51710086680475 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 95.04487864458254 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 96.57265642236032 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 98.1004342001381 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 99.62821197791587 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 101.15598975569364 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 102.68376753347144 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 104.21154531124921 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 105.73932308902698 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 107.26710086680475 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 108.79487864458254 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 110.32265642236032 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 111.8504342001381 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 113.37821197791587 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 114.90598975569367 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 116.43376753347144 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 117.96154531124921 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 119.48932308902698 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 121.01710086680477 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 122.54487864458254 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 124.07265642236031 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 125.6004342001381 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 127.12821197791588 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 128.65598975569367 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 130.18376753347144 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 131.7115453112492 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 133.23932308902698 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 134.76710086680475 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 136.29487864458252 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 137.5 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 137.5 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 137.5 - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_counteraction_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_counteraction_effect.json deleted file mode 100644 index 04a954b411..0000000000 --- a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_counteraction_effect.json +++ /dev/null @@ -1,230 +0,0 @@ -[ - { - "startDate": "2020-08-11T19:06:06", - "endDate": "2020-08-11T19:11:06", - "unit": "mg\/min·dL", - "value": -0.3485359639226971 - }, - { - "startDate": "2020-08-11T19:11:06", - "endDate": "2020-08-11T19:16:06", - "unit": "mg\/min·dL", - "value": -0.34571948711910916 - }, - { - "startDate": "2020-08-11T19:16:06", - "endDate": "2020-08-11T19:21:06", - "unit": "mg\/min·dL", - "value": -0.3110996208816001 - }, - { - "startDate": "2020-08-11T19:21:06", - "endDate": "2020-08-11T19:26:06", - "unit": "mg\/min·dL", - "value": -0.17115290442012446 - }, - { - "startDate": "2020-08-11T19:26:06", - "endDate": "2020-08-11T19:31:06", - "unit": "mg\/min·dL", - "value": -0.035078937546724906 - }, - { - "startDate": "2020-08-11T19:31:06", - "endDate": "2020-08-11T19:36:06", - "unit": "mg\/min·dL", - "value": 0.08735109214809061 - }, - { - "startDate": "2020-08-11T19:36:06", - "endDate": "2020-08-11T19:41:06", - "unit": "mg\/min·dL", - "value": 0.19746935782304254 - }, - { - "startDate": "2020-08-11T19:41:06", - "endDate": "2020-08-11T19:46:06", - "unit": "mg\/min·dL", - "value": 0.2964814415989495 - }, - { - "startDate": "2020-08-11T19:46:06", - "endDate": "2020-08-11T19:51:06", - "unit": "mg\/min·dL", - "value": 0.3854747645772338 - }, - { - "startDate": "2020-08-11T19:51:06", - "endDate": "2020-08-11T19:56:06", - "unit": "mg\/min·dL", - "value": 0.4654266535840445 - }, - { - "startDate": "2020-08-11T19:56:06", - "endDate": "2020-08-11T20:01:06", - "unit": "mg\/min·dL", - "value": 0.5372120677140927 - }, - { - "startDate": "2020-08-11T20:01:06", - "endDate": "2020-08-11T20:06:06", - "unit": "mg\/min·dL", - "value": 0.6016110049547307 - }, - { - "startDate": "2020-08-11T20:06:06", - "endDate": "2020-08-11T20:11:06", - "unit": "mg\/min·dL", - "value": 0.6593156065538323 - }, - { - "startDate": "2020-08-11T20:11:06", - "endDate": "2020-08-11T20:16:06", - "unit": "mg\/min·dL", - "value": 0.7109369743543738 - }, - { - "startDate": "2020-08-11T20:16:06", - "endDate": "2020-08-11T20:21:06", - "unit": "mg\/min·dL", - "value": 0.7570117140551543 - }, - { - "startDate": "2020-08-11T20:21:06", - "endDate": "2020-08-11T20:26:06", - "unit": "mg\/min·dL", - "value": 0.7980082152690883 - }, - { - "startDate": "2020-08-11T20:26:06", - "endDate": "2020-08-11T20:31:06", - "unit": "mg\/min·dL", - "value": 0.8343326773392674 - }, - { - "startDate": "2020-08-11T20:31:06", - "endDate": "2020-08-11T20:36:06", - "unit": "mg\/min·dL", - "value": 0.8663348881353556 - }, - { - "startDate": "2020-08-11T20:36:06", - "endDate": "2020-08-11T20:41:06", - "unit": "mg\/min·dL", - "value": 0.894313761489434 - }, - { - "startDate": "2020-08-11T20:41:06", - "endDate": "2020-08-11T20:46:06", - "unit": "mg\/min·dL", - "value": 0.9185226375377566 - }, - { - "startDate": "2020-08-11T20:46:06", - "endDate": "2020-08-11T20:51:06", - "unit": "mg\/min·dL", - "value": 0.9391743490118711 - }, - { - "startDate": "2020-08-11T20:51:06", - "endDate": "2020-08-11T20:56:06", - "unit": "mg\/min·dL", - "value": 0.9564460554651047 - }, - { - "startDate": "2020-08-11T20:56:06", - "endDate": "2020-08-11T21:01:06", - "unit": "mg\/min·dL", - "value": 0.9704838465264228 - }, - { - "startDate": "2020-08-11T21:01:06", - "endDate": "2020-08-11T21:06:06", - "unit": "mg\/min·dL", - "value": 0.9814071145378676 - }, - { - "startDate": "2020-08-11T21:06:06", - "endDate": "2020-08-11T21:11:06", - "unit": "mg\/min·dL", - "value": 0.9893126963505664 - }, - { - "startDate": "2020-08-11T21:11:06", - "endDate": "2020-08-11T21:16:06", - "unit": "mg\/min·dL", - "value": 0.9942787836220887 - }, - { - "startDate": "2020-08-11T21:16:06", - "endDate": "2020-08-11T21:21:06", - "unit": "mg\/min·dL", - "value": 0.9963686006690381 - }, - { - "startDate": "2020-08-11T21:21:06", - "endDate": "2020-08-11T21:26:06", - "unit": "mg\/min·dL", - "value": 0.9956338487771189 - }, - { - "startDate": "2020-08-11T21:26:06", - "endDate": "2020-08-11T21:31:06", - "unit": "mg\/min·dL", - "value": 0.9921179158496414 - }, - { - "startDate": "2020-08-11T21:31:06", - "endDate": "2020-08-11T21:36:06", - "unit": "mg\/min·dL", - "value": 0.9858588503769765 - }, - { - "startDate": "2020-08-11T21:36:06", - "endDate": "2020-08-11T21:41:06", - "unit": "mg\/min·dL", - "value": 0.9768920989266177 - }, - { - "startDate": "2020-08-11T21:41:06", - "endDate": "2020-08-11T21:46:06", - "unit": "mg\/min·dL", - "value": 0.965253006677156 - }, - { - "startDate": "2020-08-11T21:46:06", - "endDate": "2020-08-11T21:51:06", - "unit": "mg\/min·dL", - "value": 0.9509790809419911 - }, - { - "startDate": "2020-08-11T21:51:06", - "endDate": "2020-08-11T21:56:06", - "unit": "mg\/min·dL", - "value": 0.9341120181395608 - }, - { - "startDate": "2020-08-11T21:56:06", - "endDate": "2020-08-11T22:01:06", - "unit": "mg\/min·dL", - "value": 0.9146994952586709 - }, - { - "startDate": "2020-08-11T22:01:06", - "endDate": "2020-08-11T22:06:06", - "unit": "mg\/min·dL", - "value": 0.8927967275284316 - }, - { - "startDate": "2020-08-11T22:06:06", - "endDate": "2020-08-11T22:17:16", - "unit": "mg\/min·dL", - "value": 0.3597357885896396 - }, - { - "startDate": "2020-08-11T22:17:16", - "endDate": "2020-08-11T22:23:55", - "unit": "mg\/min·dL", - "value": 0.45827708950324664 - } -] diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_insulin_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_insulin_effect.json deleted file mode 100644 index c4576feeae..0000000000 --- a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_insulin_effect.json +++ /dev/null @@ -1,377 +0,0 @@ -[ - { - "date": "2020-08-11T22:25:00", - "amount": -0.9512697097060898, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:30:00", - "amount": -2.3813732447934624, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:35:00", - "amount": -4.341390140188103, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:40:00", - "amount": -6.753751683906663, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:45:00", - "amount": -9.55387357996081, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:50:00", - "amount": -12.683720606187977, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T22:55:00", - "amount": -16.090664681076284, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:00:00", - "amount": -19.72706463270244, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:05:00", - "amount": -23.549875518271012, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:10:00", - "amount": -27.520285545942553, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:15:00", - "amount": -31.60337877339881, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:20:00", - "amount": -35.76782187287874, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:25:00", - "amount": -39.98557336066623, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:30:00", - "amount": -44.23161379064487, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:35:00", - "amount": -48.48369550694377, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:40:00", - "amount": -52.72211064025665, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:45:00", - "amount": -56.92947611647066, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:50:00", - "amount": -61.090534525122315, - "unit": "mg/dL" - }, - { - "date": "2020-08-11T23:55:00", - "amount": -65.19196976921322, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:00:00", - "amount": -69.22223648736112, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:05:00", - "amount": -73.17140230440528, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:10:00", - "amount": -77.03100202768849, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:15:00", - "amount": -80.79390296354336, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:20:00", - "amount": -84.45418058224833, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:25:00", - "amount": -88.00700381010158, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:30:00", - "amount": -91.44852927449627, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:35:00", - "amount": -94.77580387215212, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:40:00", - "amount": -97.98667507215376, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:45:00", - "amount": -101.07970840432662, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:50:00", - "amount": -104.05411161991226, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T00:55:00", - "amount": -106.9096650456303, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:00:00", - "amount": -109.64665768417882, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:05:00", - "amount": -112.26582864415883, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:10:00", - "amount": -114.7683135104363, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:15:00", - "amount": -117.15559529219412, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:20:00", - "amount": -119.42945961048726, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:25:00", - "amount": -121.59195381009803, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:30:00", - "amount": -123.64534970199563, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:35:00", - "amount": -125.592109662823, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:40:00", - "amount": -127.43485583665301, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:45:00", - "amount": -129.17634220185357, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:50:00", - "amount": -130.81942928235597, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T01:55:00", - "amount": -132.36706129800115, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:00:00", - "amount": -133.8222455630132, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:05:00", - "amount": -135.18803395508212, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:10:00", - "amount": -136.46750629008383, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:15:00", - "amount": -137.66375544918807, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:20:00", - "amount": -138.779874116044, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:25:00", - "amount": -139.81894299195267, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:30:00", - "amount": -140.78402036646978, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:35:00", - "amount": -141.67813292977746, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:40:00", - "amount": -142.50426772146503, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:45:00", - "amount": -143.2653651180992, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:50:00", - "amount": -143.9643127691786, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T02:55:00", - "amount": -144.60394039779732, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:00:00", - "amount": -145.18701538860697, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:05:00", - "amount": -145.71623909150875, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:10:00", - "amount": -146.19424377494204, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:15:00", - "amount": -146.62359016770122, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:20:00", - "amount": -147.00676553292078, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:25:00", - "amount": -147.3461822222548, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:30:00", - "amount": -147.64417666235195, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:35:00", - "amount": -147.90300872951764, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:40:00", - "amount": -148.12486147197743, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:45:00", - "amount": -148.3118411424277, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:50:00", - "amount": -148.46597750659902, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T03:55:00", - "amount": -148.58922439637678, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:00:00", - "amount": -148.68346047864273, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:05:00", - "amount": -148.75049021342636, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:10:00", - "amount": -148.79204497720696, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:15:00", - "amount": -148.8097843292894, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:20:00", - "amount": -148.80959176148318, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:25:00", - "amount": -148.80862975663214, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:30:00", - "amount": -148.8083823028405, - "unit": "mg/dL" - }, - { - "date": "2020-08-12T04:35:00", - "amount": -148.80836238795683, - "unit": "mg/dL" - } -] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_momentum_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_momentum_effect.json deleted file mode 100644 index 0637a088a0..0000000000 --- a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_momentum_effect.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_predicted_glucose.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_predicted_glucose.json deleted file mode 100644 index 4ac4d64f44..0000000000 --- a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_predicted_glucose.json +++ /dev/null @@ -1,382 +0,0 @@ -[ - { - "date": "2020-08-11T22:23:55", - "unit": "mg/dL", - "amount": 81.22399763523448 - }, - { - "date": "2020-08-11T22:25:00", - "unit": "mg/dL", - "amount": 87.005525216014 - }, - { - "date": "2020-08-11T22:30:00", - "unit": "mg/dL", - "amount": 89.28803182494407 - }, - { - "date": "2020-08-11T22:35:00", - "unit": "mg/dL", - "amount": 90.82214183694292 - }, - { - "date": "2020-08-11T22:40:00", - "unit": "mg/dL", - "amount": 96.95805885168919 - }, - { - "date": "2020-08-11T22:45:00", - "unit": "mg/dL", - "amount": 103.32620850089171 - }, - { - "date": "2020-08-11T22:50:00", - "unit": "mg/dL", - "amount": 109.14614978329723 - }, - { - "date": "2020-08-11T22:55:00", - "unit": "mg/dL", - "amount": 114.47051078041765 - }, - { - "date": "2020-08-11T23:00:00", - "unit": "mg/dL", - "amount": 119.34693266417625 - }, - { - "date": "2020-08-11T23:05:00", - "unit": "mg/dL", - "amount": 123.81846037736848 - }, - { - "date": "2020-08-11T23:10:00", - "unit": "mg/dL", - "amount": 127.92390571183377 - }, - { - "date": "2020-08-11T23:15:00", - "unit": "mg/dL", - "amount": 131.69818460989035 - }, - { - "date": "2020-08-11T23:20:00", - "unit": "mg/dL", - "amount": 135.1726303992993 - }, - { - "date": "2020-08-11T23:25:00", - "unit": "mg/dL", - "amount": 133.3211329127054 - }, - { - "date": "2020-08-11T23:30:00", - "unit": "mg/dL", - "amount": 130.60287026050452 - }, - { - "date": "2020-08-11T23:35:00", - "unit": "mg/dL", - "amount": 127.87856632198339 - }, - { - "date": "2020-08-11T23:40:00", - "unit": "mg/dL", - "amount": 125.16792896644829 - }, - { - "date": "2020-08-11T23:45:00", - "unit": "mg/dL", - "amount": 122.48834126801206 - }, - { - "date": "2020-08-11T23:50:00", - "unit": "mg/dL", - "amount": 119.85506063713818 - }, - { - "date": "2020-08-11T23:55:00", - "unit": "mg/dL", - "amount": 117.28140317082506 - }, - { - "date": "2020-08-12T00:00:00", - "unit": "mg/dL", - "amount": 114.77891423045493 - }, - { - "date": "2020-08-12T00:05:00", - "unit": "mg/dL", - "amount": 112.35752619118857 - }, - { - "date": "2020-08-12T00:10:00", - "unit": "mg/dL", - "amount": 110.02570424568313 - }, - { - "date": "2020-08-12T00:15:00", - "unit": "mg/dL", - "amount": 107.79058108760603 - }, - { - "date": "2020-08-12T00:20:00", - "unit": "mg/dL", - "amount": 105.65808124667883 - }, - { - "date": "2020-08-12T00:25:00", - "unit": "mg/dL", - "amount": 103.63303579660337 - }, - { - "date": "2020-08-12T00:30:00", - "unit": "mg/dL", - "amount": 101.71928810998646 - }, - { - "date": "2020-08-12T00:35:00", - "unit": "mg/dL", - "amount": 99.91979129010838 - }, - { - "date": "2020-08-12T00:40:00", - "unit": "mg/dL", - "amount": 98.23669786788452 - }, - { - "date": "2020-08-12T00:45:00", - "unit": "mg/dL", - "amount": 96.67144231348942 - }, - { - "date": "2020-08-12T00:50:00", - "unit": "mg/dL", - "amount": 95.22481687568158 - }, - { - "date": "2020-08-12T00:55:00", - "unit": "mg/dL", - "amount": 93.89704122774131 - }, - { - "date": "2020-08-12T01:00:00", - "unit": "mg/dL", - "amount": 92.68782636697057 - }, - { - "date": "2020-08-12T01:05:00", - "unit": "mg/dL", - "amount": 91.59643318476833 - }, - { - "date": "2020-08-12T01:10:00", - "unit": "mg/dL", - "amount": 90.62172609626865 - }, - { - "date": "2020-08-12T01:15:00", - "unit": "mg/dL", - "amount": 89.76222209228861 - }, - { - "date": "2020-08-12T01:20:00", - "unit": "mg/dL", - "amount": 89.01613555177325 - }, - { - "date": "2020-08-12T01:25:00", - "unit": "mg/dL", - "amount": 88.38141912994024 - }, - { - "date": "2020-08-12T01:30:00", - "unit": "mg/dL", - "amount": 87.85580101582045 - }, - { - "date": "2020-08-12T01:35:00", - "unit": "mg/dL", - "amount": 87.43681883277084 - }, - { - "date": "2020-08-12T01:40:00", - "unit": "mg/dL", - "amount": 87.1218504367186 - }, - { - "date": "2020-08-12T01:45:00", - "unit": "mg/dL", - "amount": 86.90814184929582 - }, - { - "date": "2020-08-12T01:50:00", - "unit": "mg/dL", - "amount": 86.79283254657122 - }, - { - "date": "2020-08-12T01:55:00", - "unit": "mg/dL", - "amount": 86.77297830870381 - }, - { - "date": "2020-08-12T02:00:00", - "unit": "mg/dL", - "amount": 86.84557182146952 - }, - { - "date": "2020-08-12T02:05:00", - "unit": "mg/dL", - "amount": 87.00756120717838 - }, - { - "date": "2020-08-12T02:10:00", - "unit": "mg/dL", - "amount": 87.25586664995447 - }, - { - "date": "2020-08-12T02:15:00", - "unit": "mg/dL", - "amount": 87.58739526862803 - }, - { - "date": "2020-08-12T02:20:00", - "unit": "mg/dL", - "amount": 87.99905437954988 - }, - { - "date": "2020-08-12T02:25:00", - "unit": "mg/dL", - "amount": 88.48776328141898 - }, - { - "date": "2020-08-12T02:30:00", - "unit": "mg/dL", - "amount": 89.05046368467964 - }, - { - "date": "2020-08-12T02:35:00", - "unit": "mg/dL", - "amount": 89.68412889914973 - }, - { - "date": "2020-08-12T02:40:00", - "unit": "mg/dL", - "amount": 90.38577188523993 - }, - { - "date": "2020-08-12T02:45:00", - "unit": "mg/dL", - "amount": 90.82979584402324 - }, - { - "date": "2020-08-12T02:50:00", - "unit": "mg/dL", - "amount": 90.13084819294383 - }, - { - "date": "2020-08-12T02:55:00", - "unit": "mg/dL", - "amount": 89.49122056432512 - }, - { - "date": "2020-08-12T03:00:00", - "unit": "mg/dL", - "amount": 88.90814557351547 - }, - { - "date": "2020-08-12T03:05:00", - "unit": "mg/dL", - "amount": 88.37892187061368 - }, - { - "date": "2020-08-12T03:10:00", - "unit": "mg/dL", - "amount": 87.9009171871804 - }, - { - "date": "2020-08-12T03:15:00", - "unit": "mg/dL", - "amount": 87.47157079442121 - }, - { - "date": "2020-08-12T03:20:00", - "unit": "mg/dL", - "amount": 87.08839542920165 - }, - { - "date": "2020-08-12T03:25:00", - "unit": "mg/dL", - "amount": 86.74897873986762 - }, - { - "date": "2020-08-12T03:30:00", - "unit": "mg/dL", - "amount": 86.45098429977048 - }, - { - "date": "2020-08-12T03:35:00", - "unit": "mg/dL", - "amount": 86.1921522326048 - }, - { - "date": "2020-08-12T03:40:00", - "unit": "mg/dL", - "amount": 85.97029949014501 - }, - { - "date": "2020-08-12T03:45:00", - "unit": "mg/dL", - "amount": 85.78331981969473 - }, - { - "date": "2020-08-12T03:50:00", - "unit": "mg/dL", - "amount": 85.62918345552342 - }, - { - "date": "2020-08-12T03:55:00", - "unit": "mg/dL", - "amount": 85.50593656574566 - }, - { - "date": "2020-08-12T04:00:00", - "unit": "mg/dL", - "amount": 85.4117004834797 - }, - { - "date": "2020-08-12T04:05:00", - "unit": "mg/dL", - "amount": 85.34467074869607 - }, - { - "date": "2020-08-12T04:10:00", - "unit": "mg/dL", - "amount": 85.30311598491548 - }, - { - "date": "2020-08-12T04:15:00", - "unit": "mg/dL", - "amount": 85.28537663283302 - }, - { - "date": "2020-08-12T04:20:00", - "unit": "mg/dL", - "amount": 85.28556920063926 - }, - { - "date": "2020-08-12T04:25:00", - "unit": "mg/dL", - "amount": 85.28653120549029 - }, - { - "date": "2020-08-12T04:30:00", - "unit": "mg/dL", - "amount": 85.28677865928194 - }, - { - "date": "2020-08-12T04:35:00", - "unit": "mg/dL", - "amount": 85.2867985741656 - } -] \ No newline at end of file diff --git a/LoopTests/Managers/Alerts/AlertManagerTests.swift b/LoopTests/Managers/Alerts/AlertManagerTests.swift index 5eeca9cebd..2250c1a16c 100644 --- a/LoopTests/Managers/Alerts/AlertManagerTests.swift +++ b/LoopTests/Managers/Alerts/AlertManagerTests.swift @@ -11,153 +11,9 @@ import UserNotifications import XCTest @testable import Loop +@MainActor class AlertManagerTests: XCTestCase { - class MockBluetoothProvider: BluetoothProvider { - var bluetoothAuthorization: BluetoothAuthorization = .authorized - - var bluetoothState: BluetoothState = .poweredOn - - func authorizeBluetooth(_ completion: @escaping (BluetoothAuthorization) -> Void) { - completion(bluetoothAuthorization) - } - - func addBluetoothObserver(_ observer: BluetoothObserver, queue: DispatchQueue) { - } - - func removeBluetoothObserver(_ observer: BluetoothObserver) { - } - } - - class MockModalAlertScheduler: InAppModalAlertScheduler { - var scheduledAlert: Alert? - override func scheduleAlert(_ alert: Alert) { - scheduledAlert = alert - } - var unscheduledAlertIdentifier: Alert.Identifier? - override func unscheduleAlert(identifier: Alert.Identifier) { - unscheduledAlertIdentifier = identifier - } - } - - class MockUserNotificationAlertScheduler: UserNotificationAlertScheduler { - var scheduledAlert: Alert? - var muted: Bool? - - override func scheduleAlert(_ alert: Alert, muted: Bool) { - scheduledAlert = alert - self.muted = muted - } - var unscheduledAlertIdentifier: Alert.Identifier? - override func unscheduleAlert(identifier: Alert.Identifier) { - unscheduledAlertIdentifier = identifier - } - } - - class MockResponder: AlertResponder { - var acknowledged: [Alert.AlertIdentifier: Bool] = [:] - func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - completion(nil) - acknowledged[alertIdentifier] = true - } - } - - class MockFileManager: FileManager { - - var fileExists = true - let newer = Date() - let older = Date.distantPast - - var createdDirURL: URL? - override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { - createdDirURL = url - } - override func fileExists(atPath path: String) -> Bool { - return !path.contains("doesntExist") - } - override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey : Any] { - return path.contains("Sounds") ? path.contains("existsNewer") ? [.creationDate: newer] : [.creationDate: older] : - [.creationDate: newer] - } - var removedURLs = [URL]() - override func removeItem(at URL: URL) throws { - removedURLs.append(URL) - } - var copiedSrcURLs = [URL]() - var copiedDstURLs = [URL]() - override func copyItem(at srcURL: URL, to dstURL: URL) throws { - copiedSrcURLs.append(srcURL) - copiedDstURLs.append(dstURL) - } - override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { - return [] - } - } - - class MockPresenter: AlertPresenter { - func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { completion?() } - func dismissTopMost(animated: Bool, completion: (() -> Void)?) { completion?() } - func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { completion?() } - } - - class MockAlertManagerResponder: AlertManagerResponder { - func acknowledgeAlert(identifier: LoopKit.Alert.Identifier) { } - } - - class MockSoundVendor: AlertSoundVendor { - func getSoundBaseURL() -> URL? { - // Hm. It's not easy to make a "fake" URL, so we'll use this one: - return Bundle.main.resourceURL - } - - func getSounds() -> [Alert.Sound] { - return [.sound(name: "doesntExist"), .sound(name: "existsNewer"), .sound(name: "existsOlder")] - } - } - - class MockAlertStore: AlertStore { - - var issuedAlert: Alert? - override public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { - issuedAlert = alert - completion?(.success) - } - - var retractedAlert: Alert? - var retractedAlertDate: Date? - override public func recordRetractedAlert(_ alert: Alert, at date: Date, completion: ((Result) -> Void)? = nil) { - retractedAlert = alert - retractedAlertDate = date - completion?(.success) - } - - var acknowledgedAlertIdentifier: Alert.Identifier? - var acknowledgedAlertDate: Date? - override public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date(), - completion: ((Result) -> Void)? = nil) { - acknowledgedAlertIdentifier = identifier - acknowledgedAlertDate = date - completion?(.success) - } - - var retractededAlertIdentifier: Alert.Identifier? - override public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date(), - completion: ((Result) -> Void)? = nil) { - retractededAlertIdentifier = identifier - retractedAlertDate = date - completion?(.success) - } - - var storedAlerts = [StoredAlert]() - override public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - completion(.success(storedAlerts)) - } - - override public func lookupAllUnretracted(managerIdentifier: String?, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - completion(.success(storedAlerts)) - } - } - static let mockManagerIdentifier = "mockManagerIdentifier" static let mockTypeIdentifier = "mockTypeIdentifier" static let mockIdentifier = Alert.Identifier(managerIdentifier: mockManagerIdentifier, alertIdentifier: mockTypeIdentifier) @@ -531,39 +387,3 @@ extension Swift.Result { } } } - -class MockUserNotificationCenter: UserNotificationCenter { - - var pendingRequests = [UNNotificationRequest]() - var deliveredRequests = [UNNotificationRequest]() - - func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: ((Error?) -> Void)? = nil) { - pendingRequests.append(request) - } - - func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { - identifiers.forEach { identifier in - pendingRequests.removeAll { $0.identifier == identifier } - } - } - - func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { - identifiers.forEach { identifier in - deliveredRequests.removeAll { $0.identifier == identifier } - } - } - - func deliverAll() { - deliveredRequests = pendingRequests - pendingRequests = [] - } - - func getDeliveredNotifications(completionHandler: @escaping ([UNNotification]) -> Void) { - // Sadly, we can't create UNNotifications. - completionHandler([]) - } - - func getPendingNotificationRequests(completionHandler: @escaping ([UNNotificationRequest]) -> Void) { - completionHandler(pendingRequests) - } -} diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift new file mode 100644 index 0000000000..6872bf9590 --- /dev/null +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -0,0 +1,200 @@ +// +// DeviceDataManagerTests.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +import LoopKitUI +@testable import Loop + +@MainActor +final class DeviceDataManagerTests: XCTestCase { + + var deviceDataManager: DeviceDataManager! + let mockDecisionStore = MockDosingDecisionStore() + let pumpManager: MockPumpManager = MockPumpManager() + let cgmManager: MockCGMManager = MockCGMManager() + let trustedTimeChecker = MockTrustedTimeChecker() + let loopControlMock = LoopControlMock() + var settingsManager: SettingsManager! + var uploadEventListener: MockUploadEventListener! + + + class MockAlertIssuer: AlertIssuer { + func issueAlert(_ alert: LoopKit.Alert) { + } + + func retractAlert(identifier: LoopKit.Alert.Identifier) { + } + } + + override func setUpWithError() throws { + let mockUserNotificationCenter = MockUserNotificationCenter() + let mockBluetoothProvider = MockBluetoothProvider() + let alertPresenter = MockPresenter() + let automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + + let alertManager = AlertManager( + alertPresenter: alertPresenter, + userNotificationAlertScheduler: MockUserNotificationAlertScheduler(userNotificationCenter: mockUserNotificationCenter), + bluetoothProvider: mockBluetoothProvider, + analyticsServicesManager: AnalyticsServicesManager() + ) + + let persistenceController = PersistenceController.mock() + + let healthStore = HKHealthStore() + + let carbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) + + let carbStore = CarbStore( + cacheStore: persistenceController, + cacheLength: .days(1), + defaultAbsorptionTimes: carbAbsorptionTimes + ) + + let doseStore = DoseStore( + cacheStore: persistenceController, + insulinModelProvider: PresetInsulinModelProvider(defaultRapidActingModel: nil) + ) + + let glucoseStore = GlucoseStore(cacheStore: persistenceController) + + let cgmEventStore = CgmEventStore(cacheStore: persistenceController) + + self.settingsManager = SettingsManager(cacheStore: persistenceController, expireAfter: .days(1), alertMuter: AlertMuter()) + + self.uploadEventListener = MockUploadEventListener() + + deviceDataManager = DeviceDataManager( + pluginManager: PluginManager(), + alertManager: alertManager, + settingsManager: settingsManager, + healthStore: healthStore, + carbStore: carbStore, + doseStore: doseStore, + glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, + uploadEventListener: uploadEventListener, + crashRecoveryManager: CrashRecoveryManager(alertIssuer: MockAlertIssuer()), + loopControl: loopControlMock, + analyticsServicesManager: AnalyticsServicesManager(), + activeServicesProvider: self, + activeStatefulPluginsProvider: self, + bluetoothProvider: mockBluetoothProvider, + alertPresenter: alertPresenter, + automaticDosingStatus: automaticDosingStatus, + cacheStore: persistenceController, + localCacheDuration: .days(1), + displayGlucosePreference: DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter), + displayGlucoseUnitBroadcaster: self + ) + + deviceDataManager.pumpManager = pumpManager + deviceDataManager.cgmManager = cgmManager + } + + func testValidateMaxTempBasalDoesntCancelTempBasalIfHigher() async throws { + let dose = DoseEntry( + type: .tempBasal, + startDate: Date(), + value: 3.0, + unit: .unitsPerHour, + automatic: true + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + let newLimits = DeliveryLimits( + maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 5), + maximumBolus: nil + ) + let limits = try await deviceDataManager.syncDeliveryLimits(deliveryLimits: newLimits) + + XCTAssertNil(loopControlMock.lastCancelActiveTempBasalReason) + XCTAssertTrue(mockDecisionStore.dosingDecisions.isEmpty) + XCTAssertEqual(limits.maximumBasalRate, newLimits.maximumBasalRate) + } + + func testValidateMaxTempBasalCancelsTempBasalIfLower() async throws { + let dose = DoseEntry( + type: .tempBasal, + startDate: Date(), + endDate: nil, + value: 5.0, + unit: .unitsPerHour + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + let newLimits = DeliveryLimits( + maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 3), + maximumBolus: nil + ) + let limits = try await deviceDataManager.syncDeliveryLimits(deliveryLimits: newLimits) + + XCTAssertEqual(.maximumBasalRateChanged, loopControlMock.lastCancelActiveTempBasalReason) + XCTAssertEqual(limits.maximumBasalRate, newLimits.maximumBasalRate) + } + + func testReceivedUnreliableCGMReadingCancelsTempBasal() { + let dose = DoseEntry( + type: .tempBasal, + startDate: Date(), + value: 5.0, + unit: .unitsPerHour + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + settingsManager.mutateLoopSettings { settings in + settings.basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 3.0)]) + } + + loopControlMock.cancelExpectation = expectation(description: "Temp basal cancel") + + if let deviceManager = self.deviceDataManager { + cgmManager.delegateQueue.async { + deviceManager.cgmManager(self.cgmManager, hasNew: .unreliableData) + } + } + + wait(for: [loopControlMock.cancelExpectation!], timeout: 1) + + XCTAssertEqual(loopControlMock.lastCancelActiveTempBasalReason, .unreliableCGMData) + } + + func testUploadEventListener() { + let alertStore = AlertStore() + deviceDataManager.alertStoreHasUpdatedAlertData(alertStore) + XCTAssertEqual(uploadEventListener.lastUploadTriggeringType, .alert) + } + +} + +extension DeviceDataManagerTests: ActiveServicesProvider { + var activeServices: [LoopKit.Service] { + return [] + } + + +} + +extension DeviceDataManagerTests: ActiveStatefulPluginsProvider { + var activeStatefulPlugins: [LoopKit.StatefulPluggable] { + return [] + } +} + +extension DeviceDataManagerTests: DisplayGlucoseUnitBroadcaster { + func addDisplayGlucoseUnitObserver(_ observer: LoopKitUI.DisplayGlucoseUnitObserver) { + } + + func removeDisplayGlucoseUnitObserver(_ observer: LoopKitUI.DisplayGlucoseUnitObserver) { + } + + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { + } +} diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 4820ecc869..eddfac1a9a 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -21,136 +21,9 @@ extension MockPumpManagerError: LocalizedError { } -class MockPumpManager: PumpManager { - - var enactBolusCalled: ((Double, BolusActivationType) -> Void)? - - var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? - - var enactTempBasalError: PumpManagerError? - - init() { - - } - - // PumpManager implementation - static var onboardingMaximumBasalScheduleEntryCount: Int = 24 - - static var onboardingSupportedBasalRates: [Double] = [1,2,3] - - static var onboardingSupportedBolusVolumes: [Double] = [1,2,3] - - static var onboardingSupportedMaximumBolusVolumes: [Double] = [1,2,3] - - let deliveryUnitsPerMinute = 1.5 - - var supportedBasalRates: [Double] = [1,2,3] - - var supportedBolusVolumes: [Double] = [1,2,3] - - var supportedMaximumBolusVolumes: [Double] = [1,2,3] - - var maximumBasalScheduleEntryCount: Int = 24 - - var minimumBasalScheduleEntryDuration: TimeInterval = .minutes(30) - - var pumpManagerDelegate: PumpManagerDelegate? - - var pumpRecordsBasalProfileStartEvents: Bool = false - - var pumpReservoirCapacity: Double = 50 - - var lastSync: Date? - - var status: PumpManagerStatus = - PumpManagerStatus( - timeZone: TimeZone.current, - device: HKDevice(name: "MockPumpManager", manufacturer: nil, model: nil, hardwareVersion: nil, firmwareVersion: nil, softwareVersion: nil, localIdentifier: nil, udiDeviceIdentifier: nil), - pumpBatteryChargeRemaining: nil, - basalDeliveryState: nil, - bolusState: .noBolus, - insulinType: .novolog) - - func addStatusObserver(_ observer: PumpManagerStatusObserver, queue: DispatchQueue) { - } - - func removeStatusObserver(_ observer: PumpManagerStatusObserver) { - } - - func ensureCurrentPumpData(completion: ((Date?) -> Void)?) { - completion?(Date()) - } - - func setMustProvideBLEHeartbeat(_ mustProvideBLEHeartbeat: Bool) { - } - - func createBolusProgressReporter(reportingOn dispatchQueue: DispatchQueue) -> DoseProgressReporter? { - return nil - } - - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { - enactBolusCalled?(units, activationType) - completion(nil) - } - - func cancelBolus(completion: @escaping (PumpManagerResult) -> Void) { - completion(.success(nil)) - } - - func enactTempBasal(unitsPerHour: Double, for duration: TimeInterval, completion: @escaping (PumpManagerError?) -> Void) { - enactTempBasalCalled?(unitsPerHour, duration) - completion(enactTempBasalError) - } - - func suspendDelivery(completion: @escaping (Error?) -> Void) { - completion(nil) - } - - func resumeDelivery(completion: @escaping (Error?) -> Void) { - completion(nil) - } - - func syncBasalRateSchedule(items scheduleItems: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { - } - - func syncDeliveryLimits(limits deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { - - } - - func estimatedDuration(toBolus units: Double) -> TimeInterval { - .minutes(units / deliveryUnitsPerMinute) - } - - var pluginIdentifier: String = "MockPumpManager" - - var localizedTitle: String = "MockPumpManager" - - var delegateQueue: DispatchQueue! - - required init?(rawState: RawStateValue) { - - } - - var rawState: RawStateValue = [:] - - var isOnboarded: Bool = true - - var debugDescription: String = "MockPumpManager" - - func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - } - - func getSoundBaseURL() -> URL? { - return nil - } - - func getSounds() -> [Alert.Sound] { - return [.sound(name: "doesntExist")] - } -} class DoseEnactorTests: XCTestCase { - func testBasalAndBolusDosedSerially() { + func testBasalAndBolusDosedSerially() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 0, duration: 0) // Cancel let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 1.5) @@ -165,15 +38,13 @@ class DoseEnactorTests: XCTestCase { pumpManager.enactBolusCalled = { (amount, automatic) in bolusExpectation.fulfill() } - - enactor.enact(recommendation: recommendation, with: pumpManager) { error in - XCTAssertNil(error) - } - - wait(for: [tempBasalExpectation, bolusExpectation], timeout: 5, enforceOrder: true) + + try await enactor.enact(recommendation: recommendation, with: pumpManager) + + await fulfillment(of: [tempBasalExpectation, bolusExpectation], timeout: 5, enforceOrder: true) } - func testBolusDoesNotIssueIfTempBasalAdjustmentFailed() { + func testBolusDoesNotIssueIfTempBasalAdjustmentFailed() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 0, duration: 0) // Cancel let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 1.5) @@ -190,14 +61,16 @@ class DoseEnactorTests: XCTestCase { pumpManager.enactTempBasalError = .configuration(MockPumpManagerError.failed) - enactor.enact(recommendation: recommendation, with: pumpManager) { error in - XCTAssertNotNil(error) + do { + try await enactor.enact(recommendation: recommendation, with: pumpManager) + XCTFail("Expected enact to throw error on failure.") + } catch { } - - waitForExpectations(timeout: 2) + + await fulfillment(of: [tempBasalExpectation]) } - func testTempBasalOnly() { + func testTempBasalOnly() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 1.2, duration: .minutes(30)) // Cancel let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 0) @@ -213,13 +86,10 @@ class DoseEnactorTests: XCTestCase { pumpManager.enactBolusCalled = { (amount, automatic) in XCTFail("Should not enact bolus") } - - enactor.enact(recommendation: recommendation, with: pumpManager) { error in - XCTAssertNil(error) - } - - waitForExpectations(timeout: 2) + try await enactor.enact(recommendation: recommendation, with: pumpManager) + + await fulfillment(of: [tempBasalExpectation]) } diff --git a/LoopTests/Managers/LoopAlgorithmTests.swift b/LoopTests/Managers/LoopAlgorithmTests.swift index 084a72a3cf..e63f86bb46 100644 --- a/LoopTests/Managers/LoopAlgorithmTests.swift +++ b/LoopTests/Managers/LoopAlgorithmTests.swift @@ -60,6 +60,7 @@ final class LoopAlgorithmTests: XCTestCase { let input = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) let prediction = LoopAlgorithm.generatePrediction( + start: input.glucoseHistory.last?.startDate ?? Date(), glucoseHistory: input.glucoseHistory, doses: input.doses, carbEntries: input.carbEntries, @@ -80,4 +81,144 @@ final class LoopAlgorithmTests: XCTestCase { XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) } } + + func testAutoBolusMaxIOBClamping() async { + let now = ISO8601DateFormatter().date(from: "2020-03-11T12:13:14-0700")! + + var input = LoopAlgorithmInput.mock(for: now) + input.recommendationType = .automaticBolus + + // 8U bolus on board, and 100g carbs; CR = 10, so that should be 10U to cover the carbs + input.doses = [DoseEntry(type: .bolus, startDate: now.addingTimeInterval(-.minutes(5)), value: 8, unit: .units)] + input.carbEntries = [ + StoredCarbEntry(startDate: now.addingTimeInterval(.minutes(-5)), quantity: .carbs(value: 100)) + ] + + // Max activeInsulin = 2 x maxBolus = 16U + input.maxBolus = 8 + var output = LoopAlgorithm.run(input: input) + var recommendedBolus = output.recommendation!.automatic?.bolusUnits + var activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedBolus!, 1.71, accuracy: 0.01) + + // Now try with maxBolus of 4; should not recommend any more insulin, as we're at our max iob + input.maxBolus = 4 + output = LoopAlgorithm.run(input: input) + recommendedBolus = output.recommendation!.automatic?.bolusUnits + activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedBolus!, 0, accuracy: 0.01) + } + + func testTempBasalMaxIOBClamping() { + let now = ISO8601DateFormatter().date(from: "2020-03-11T12:13:14-0700")! + + var input = LoopAlgorithmInput.mock(for: now) + input.recommendationType = .tempBasal + + // 8U bolus on board, and 100g carbs; CR = 10, so that should be 10U to cover the carbs + input.doses = [DoseEntry(type: .bolus, startDate: now.addingTimeInterval(-.minutes(5)), value: 8, unit: .units)] + input.carbEntries = [ + StoredCarbEntry(startDate: now.addingTimeInterval(.minutes(-5)), quantity: .carbs(value: 100)) + ] + + // Max activeInsulin = 2 x maxBolus = 16U + input.maxBolus = 8 + var output = LoopAlgorithm.run(input: input) + var recommendedRate = output.recommendation!.automatic!.basalAdjustment!.unitsPerHour + var activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedRate, 8.0, accuracy: 0.01) + + // Now try with maxBolus of 4; should only recommend scheduled basal (1U/hr), as we're at our max iob + input.maxBolus = 4 + output = LoopAlgorithm.run(input: input) + recommendedRate = output.recommendation!.automatic!.basalAdjustment!.unitsPerHour + activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedRate, 1.0, accuracy: 0.01) + } +} + + +extension LoopAlgorithmInput { + static func mock(for date: Date, glucose: [Double] = [100, 120, 140, 160]) -> LoopAlgorithmInput { + + func d(_ interval: TimeInterval) -> Date { + return date.addingTimeInterval(interval) + } + + var input = LoopAlgorithmInput( + predictionStart: date, + glucoseHistory: [], + doses: [], + carbEntries: [], + basal: [], + sensitivity: [], + carbRatio: [], + target: [], + suspendThreshold: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 65), + maxBolus: 6, + maxBasalRate: 8, + recommendationInsulinType: .novolog, + recommendationType: .automaticBolus + ) + + for (idx, value) in glucose.enumerated() { + let entry = StoredGlucoseSample(startDate: d(.minutes(Double(-(glucose.count - idx)*5)) + .minutes(1)), quantity: .glucose(value: value)) + input.glucoseHistory.append(entry) + } + + input.doses = [ + DoseEntry(type: .bolus, startDate: d(.minutes(-3)), value: 1.0, unit: .units) + ] + + input.carbEntries = [ + StoredCarbEntry(startDate: d(.minutes(-4)), quantity: .carbs(value: 20)) + ] + + let forecastEndTime = date.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(.minutes(GlucoseMath.defaultDelta)) + let dosesStart = date.addingTimeInterval(-(CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration)) + let carbsStart = date.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) + + + let basalRateSchedule = BasalRateSchedule( + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: 1), + ], + timeZone: .utcTimeZone + )! + input.basal = basalRateSchedule.between(start: dosesStart, end: date) + + let insulinSensitivitySchedule = InsulinSensitivitySchedule( + unit: .milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: 45), + RepeatingScheduleValue(startTime: 32400, value: 55) + ], + timeZone: .utcTimeZone + )! + input.sensitivity = insulinSensitivitySchedule.quantitiesBetween(start: dosesStart, end: forecastEndTime) + + let carbRatioSchedule = CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 10.0), + ], + timeZone: .utcTimeZone + )! + input.carbRatio = carbRatioSchedule.between(start: carbsStart, end: date) + + let targetSchedule = GlucoseRangeSchedule( + unit: .milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100, maxValue: 110)), + ], + timeZone: .utcTimeZone + )! + input.target = targetSchedule.quantityBetween(start: date, end: forecastEndTime) + return input + } } + diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift deleted file mode 100644 index 9cdb1f43cd..0000000000 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ /dev/null @@ -1,647 +0,0 @@ -// -// 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 { 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? -} - -class LoopDataManagerDosingTests: LoopDataManagerTests { - // MARK: Functions to load fixtures - func loadLocalDateGlucoseEffect(_ name: String) -> [GlucoseEffect] { - let fixture: [JSONDictionary] = loadFixture(name) - let localDateFormatter = ISO8601DateFormatter.localTimeDate() - - return fixture.map { - return GlucoseEffect(startDate: localDateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) - } - } - - func loadPredictedGlucoseFixture(_ name: String) -> [PredictedGlucoseValue] { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - let url = bundle.url(forResource: name, withExtension: "json")! - return try! decoder.decode([PredictedGlucoseValue].self, from: try! Data(contentsOf: url)) - } - - // MARK: Tests - func testForecastFromLiveCaptureInputData() { - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let url = bundle.url(forResource: "live_capture_input", withExtension: "json")! - let predictionInput = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) - - // Therapy settings in the "live capture" input only have one value, so we can fake some schedules - // from the first entry of each therapy setting's history. - let basalRateSchedule = BasalRateSchedule(dailyItems: [ - RepeatingScheduleValue(startTime: 0, value: predictionInput.basal.first!.value) - ]) - let insulinSensitivitySchedule = InsulinSensitivitySchedule( - unit: .milligramsPerDeciliter, - dailyItems: [ - RepeatingScheduleValue(startTime: 0, value: predictionInput.sensitivity.first!.value.doubleValue(for: .milligramsPerDeciliter)) - ], - timeZone: .utcTimeZone - )! - let carbRatioSchedule = CarbRatioSchedule( - unit: .gram(), - dailyItems: [ - RepeatingScheduleValue(startTime: 0.0, value: predictionInput.carbRatio.first!.value) - ], - timeZone: .utcTimeZone - )! - - let settings = LoopSettings( - dosingEnabled: false, - glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, - insulinSensitivitySchedule: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - carbRatioSchedule: carbRatioSchedule, - maximumBasalRatePerHour: 10, - maximumBolus: 5, - suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 65), - automaticDosingStrategy: .automaticBolus - ) - - let glucoseStore = MockGlucoseStore() - glucoseStore.storedGlucose = predictionInput.glucoseHistory - - let currentDate = glucoseStore.latestGlucose!.startDate - now = currentDate - - let doseStore = MockDoseStore() - doseStore.basalProfile = basalRateSchedule - doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile - doseStore.sensitivitySchedule = insulinSensitivitySchedule - doseStore.doseHistory = predictionInput.doses - doseStore.lastAddedPumpData = predictionInput.doses.last!.startDate - let carbStore = MockCarbStore() - carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule - carbStore.carbRatioSchedule = carbRatioSchedule - carbStore.carbRatioScheduleApplyingOverrideHistory = carbRatioSchedule - carbStore.carbHistory = predictionInput.carbEntries - - - dosingDecisionStore = MockDosingDecisionStore() - automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) - loopDataManager = LoopDataManager( - lastLoopCompleted: currentDate, - basalDeliveryState: .active(currentDate), - 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 expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") - - let updateGroup = DispatchGroup() - updateGroup.enter() - var predictedGlucose: [PredictedGlucoseValue]? - var recommendedBasal: TempBasalRecommendation? - self.loopDataManager.getLoopState { _, state in - predictedGlucose = state.predictedGlucoseIncludingPendingInsulin - recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment - updateGroup.leave() - } - // We need to wait until the task completes to get outputs - updateGroup.wait() - - XCTAssertNotNil(predictedGlucose) - - XCTAssertEqual(expectedPredictedGlucose.count, predictedGlucose!.count) - - for (expected, calculated) in zip(expectedPredictedGlucose, predictedGlucose!) { - XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) - } - } - - - func testFlatAndStable() { - setUp(for: .flatAndStable) - let predictedGlucoseOutput = loadLocalDateGlucoseEffect("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 = loadLocalDateGlucoseEffect("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 = loadLocalDateGlucoseEffect("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 = loadLocalDateGlucoseEffect("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 = loadLocalDateGlucoseEffect("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 = loadLocalDateGlucoseEffect("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.automaticDosingEnabled = 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, "automaticDosingDisabled") - 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.automaticDosingEnabled = 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(for: .flatAndStable) - let carbStore = MockCarbStore() - - let currentDate = Date() - - dosingDecisionStore = MockDosingDecisionStore() - automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: false, isAutomaticDosingAllowed: 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() { - /// `maxBolus` is set to clamp the automatic dose - /// Autobolus without clamping: 0.65 U. Clamped recommendation: 0.2 U. - setUp(for: .highAndRisingWithCOB, maxBolus: 5, dosingStrategy: .automaticBolus) - - // 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.5) - - /// 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.5) - } - - func testTempBasalMaxIOBClamping() { - /// `maximumBolus` is set to 5U to clamp max IOB at 10U - /// Without clamping: 4.25 U/hr. Clamped recommendation: 2.0 U/hr. - setUp(for: .highAndRisingWithCOB, 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, 2.0, accuracy: 0.01) - XCTAssertEqual(insulinOnBoard?.value, 9.5) - - /// 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.5) - } - -} diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 32c7d66f19..2380ba701b 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -9,69 +9,12 @@ import XCTest import HealthKit import LoopKit +import HealthKit @testable import LoopCore @testable import Loop public typealias JSONDictionary = [String: Any] -enum DosingTestScenario { - case liveCapture // Includes actual dosing history, bg history, etc. - case flatAndStable - case highAndStable - case highAndRisingWithCOB - case lowAndFallingWithCOB - case lowWithLowTreatment - case highAndFalling - - var fixturePrefix: String { - switch self { - case .liveCapture: - return "live_capture_" - case .flatAndStable: - return "flat_and_stable_" - case .highAndStable: - return "high_and_stable_" - case .highAndRisingWithCOB: - return "high_rising_with_cob_" - case .lowAndFallingWithCOB: - return "low_and_falling_with_cob_" - case .lowWithLowTreatment: - return "low_with_low_treatment_" - case .highAndFalling: - return "high_and_falling_" - } - } - - static let localDateFormatter = ISO8601DateFormatter.localTimeDate() - - static var dateFormatter: ISO8601DateFormatter = { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withInternetDateTime] - return dateFormatter - }() - - - var currentDate: Date { - switch self { - case .liveCapture: - return Self.dateFormatter.date(from: "2023-07-29T19:21:00Z")! - case .flatAndStable: - return Self.localDateFormatter.date(from: "2020-08-11T20:45:02")! - case .highAndStable: - return Self.localDateFormatter.date(from: "2020-08-12T12:39:22")! - case .highAndRisingWithCOB: - return Self.localDateFormatter.date(from: "2020-08-11T21:48:17")! - case .lowAndFallingWithCOB: - return Self.localDateFormatter.date(from: "2020-08-11T22:06:06")! - case .lowWithLowTreatment: - return Self.localDateFormatter.date(from: "2020-08-11T22:23:55")! - case .highAndFalling: - return Self.localDateFormatter.date(from: "2020-08-11T22:59:45")! - } - } - -} - extension TimeZone { static var fixtureTimeZone: TimeZone { return TimeZone(secondsFromGMT: 25200)! @@ -94,6 +37,7 @@ extension ISO8601DateFormatter { } } +@MainActor class LoopDataManagerTests: XCTestCase { // MARK: Constants for testing let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) @@ -117,18 +61,23 @@ class LoopDataManagerTests: XCTestCase { ], timeZone: .utcTimeZone)! } - // MARK: Mock stores + // MARK: Stores var now: Date! + let persistenceController = PersistenceController.mock() + var doseStore = MockDoseStore() + var glucoseStore = MockGlucoseStore() + var carbStore = MockCarbStore() var dosingDecisionStore: MockDosingDecisionStore! var automaticDosingStatus: AutomaticDosingStatus! var loopDataManager: LoopDataManager! - - func setUp(for test: DosingTestScenario, - basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil, - maxBolus: Double = 10, - maxBasalRate: Double = 5.0, - dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly) - { + var deliveryDelegate: MockDeliveryDelegate! + var settingsProvider: MockSettingsProvider! + + func d(_ interval: TimeInterval) -> Date { + return now.addingTimeInterval(interval) + } + + override func setUp() async throws { let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") let insulinSensitivitySchedule = InsulinSensitivitySchedule( unit: .milligramsPerDeciliter, @@ -146,54 +95,318 @@ class LoopDataManagerTests: XCTestCase { timeZone: .utcTimeZone )! - let settings = LoopSettings( + let settings = StoredSettings( dosingEnabled: false, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, - insulinSensitivitySchedule: insulinSensitivitySchedule, + maximumBasalRatePerHour: 6, + maximumBolus: 5, + suspendThreshold: suspendThreshold, basalRateSchedule: basalRateSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, carbRatioSchedule: carbRatioSchedule, - maximumBasalRatePerHour: maxBasalRate, - maximumBolus: maxBolus, - suspendThreshold: suspendThreshold, - automaticDosingStrategy: dosingStrategy + automaticDosingStrategy: .automaticBolus ) - - let doseStore = MockDoseStore(for: test) - doseStore.basalProfile = basalRateSchedule - doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile - doseStore.sensitivitySchedule = insulinSensitivitySchedule - let glucoseStore = MockGlucoseStore(for: test) - let carbStore = MockCarbStore(for: test) - carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule - carbStore.carbRatioSchedule = carbRatioSchedule - - let currentDate = glucoseStore.latestGlucose!.startDate - now = currentDate - + + settingsProvider = MockSettingsProvider(settings: settings) + + now = dateFormatter.date(from: "2023-07-29T19:21:00Z")! + dosingDecisionStore = MockDosingDecisionStore() automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + + let temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsProvider) + loopDataManager = LoopDataManager( - lastLoopCompleted: currentDate, - basalDeliveryState: basalDeliveryState ?? .active(currentDate), - settings: settings, - overrideHistory: TemporaryScheduleOverrideHistory(), - analyticsServicesManager: AnalyticsServicesManager(), - localCacheDuration: .days(1), + lastLoopCompleted: now, + temporaryPresetsManager: temporaryPresetsManager, + settingsProvider: settingsProvider, doseStore: doseStore, glucoseStore: glucoseStore, carbStore: carbStore, dosingDecisionStore: dosingDecisionStore, - latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), - now: { currentDate }, - pumpInsulinType: .novolog, + now: { [weak self] in self?.now ?? Date() }, automaticDosingStatus: automaticDosingStatus, - trustedTimeOffset: { 0 } + trustedTimeOffset: { 0 }, + analyticsServicesManager: nil, + carbAbsorptionModel: .piecewiseLinear ) + + deliveryDelegate = MockDeliveryDelegate() + loopDataManager.deliveryDelegate = deliveryDelegate + + deliveryDelegate.basalDeliveryState = .active(now.addingTimeInterval(-.hours(2))) } - + override func tearDownWithError() throws { loopDataManager = nil } + + // MARK: Functions to load fixtures + func loadLocalDateGlucoseEffect(_ name: String) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(name) + let localDateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseEffect(startDate: localDateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) + } + } + + func loadPredictedGlucoseFixture(_ name: String) -> [PredictedGlucoseValue] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let url = bundle.url(forResource: name, withExtension: "json")! + return try! decoder.decode([PredictedGlucoseValue].self, from: try! Data(contentsOf: url)) + } + + // MARK: Tests + func testForecastFromLiveCaptureInputData() async { + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = bundle.url(forResource: "live_capture_input", withExtension: "json")! + let predictionInput = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) + + // Therapy settings in the "live capture" input only have one value, so we can fake some schedules + // from the first entry of each therapy setting's history. + let basalRateSchedule = BasalRateSchedule(dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: predictionInput.basal.first!.value) + ]) + let insulinSensitivitySchedule = InsulinSensitivitySchedule( + unit: .milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: predictionInput.sensitivity.first!.value.doubleValue(for: .milligramsPerDeciliter)) + ], + timeZone: .utcTimeZone + )! + let carbRatioSchedule = CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: predictionInput.carbRatio.first!.value) + ], + timeZone: .utcTimeZone + )! + + settingsProvider.settings = StoredSettings( + dosingEnabled: false, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + maximumBasalRatePerHour: 10, + maximumBolus: 5, + suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 65), + basalRateSchedule: basalRateSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, + carbRatioSchedule: carbRatioSchedule, + automaticDosingStrategy: .automaticBolus + ) + + glucoseStore.storedGlucose = predictionInput.glucoseHistory + + let currentDate = glucoseStore.latestGlucose!.startDate + now = currentDate + + doseStore.doseHistory = predictionInput.doses + doseStore.lastAddedPumpData = predictionInput.doses.last!.startDate + carbStore.carbHistory = predictionInput.carbEntries + + let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") + + await loopDataManager.updateDisplayState() + + let predictedGlucose = loopDataManager.displayState.output?.predictedGlucose + + XCTAssertNotNil(predictedGlucose) + + XCTAssertEqual(expectedPredictedGlucose.count, predictedGlucose!.count) + + for (expected, calculated) in zip(expectedPredictedGlucose, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + await loopDataManager.loop() + + XCTAssertEqual(0, deliveryDelegate.lastEnact?.bolusUnits) + XCTAssertEqual(0, deliveryDelegate.lastEnact?.basalAdjustment?.unitsPerHour) + } + + + func testHighAndStable() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 120)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(120, loopDataManager.eventualBG) + XCTAssert(loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + XCTAssertEqual(0.2, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + } + + + func testHighAndFalling() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 200)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 190)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 180)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 170)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(150, loopDataManager.eventualBG) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Should correct high. + XCTAssertEqual(0.4, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + } + + func testHighAndRisingWithCOB() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 200)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 210)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 220)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 230)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(250, loopDataManager.eventualBG) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Should correct high. + XCTAssertEqual(1.15, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + } + + func testLowAndFalling() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 100)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 95)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 90)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 85)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(75, loopDataManager.eventualBG!, accuracy: 1.0) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Should not bolus, and should low temp. + XCTAssertEqual(0, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertEqual(0, deliveryDelegate.lastEnact!.basalAdjustment!.unitsPerHour, accuracy: defaultAccuracy) + } + + + func testLowAndFallingWithCOB() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 100)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 95)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 90)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 85)), + ] + + carbStore.carbHistory = [ + StoredCarbEntry(startDate: d(.minutes(-5)), quantity: .carbs(value: 20)) + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(185, loopDataManager.eventualBG!, accuracy: 1.0) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Because eventual is high, but mid-term is low, stay neutral in delivery. + XCTAssertEqual(0, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertNil(deliveryDelegate.lastEnact!.basalAdjustment) + } + + func testOpenLoopCancelsTempBasal() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), + ] + + let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 1.0, unit: .unitsPerHour) + deliveryDelegate.basalDeliveryState = .tempBasal(dose) + + dosingDecisionStore.storeExpectation = expectation(description: #function) + + automaticDosingStatus.automaticDosingEnabled = false + + await fulfillment(of: [dosingDecisionStore.storeExpectation!], timeout: 1.0) + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) + XCTAssertEqual(deliveryDelegate.lastEnact, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "automaticDosingDisabled") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + } + + func testLoopEnactsTempBasalWithoutManualBolusRecommendation() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), + ] + + settingsProvider.settings.automaticDosingStrategy = .tempBasalOnly + + await loopDataManager.loop() + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30))) + XCTAssertEqual(deliveryDelegate.lastEnact, 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) + } + } + + func testLoopRecommendsTempBasalWithoutEnactingIfOpenLoop() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), + ] + automaticDosingStatus.automaticDosingEnabled = false + settingsProvider.settings.automaticDosingStrategy = .tempBasalOnly + + await loopDataManager.loop() + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30))) + XCTAssertNil(deliveryDelegate.lastEnact) + 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) + } + + func testLoopGetStateRecommendsManualBolusWithoutMomentum() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 100)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 130)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 160)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 190)), + ] + + loopDataManager.usePositiveMomentumAndRCForManualBoluses = true + var recommendation = try! await loopDataManager.recommendManualBolus()! + XCTAssertEqual(recommendation.amount, 2.46, accuracy: 0.01) + + loopDataManager.usePositiveMomentumAndRCForManualBoluses = false + recommendation = try! await loopDataManager.recommendManualBolus()! + XCTAssertEqual(recommendation.amount, 1.73, accuracy: 0.01) + + } + + } extension LoopDataManagerTests { @@ -216,3 +429,20 @@ extension LoopDataManagerTests { return BasalRateSchedule(dailyItems: items, timeZone: .utcTimeZone)! } } + +extension HKQuantity { + static func glucose(value: Double) -> HKQuantity { + return .init(unit: .milligramsPerDeciliter, doubleValue: value) + } + + static func carbs(value: Double) -> HKQuantity { + return .init(unit: .gram(), doubleValue: value) + } + +} + +extension LoopDataManager { + var eventualBG: Double? { + displayState.output?.predictedGlucose.last?.quantity.doubleValue(for: .milligramsPerDeciliter) + } +} diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 3db48cc7eb..2148821f54 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -180,65 +180,100 @@ extension MissedMealTestType { } } +@MainActor 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?)! - - fileprivate var glucoseSamples: [MockGlucoseSample]! - - @discardableResult func setUp(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { - carbStore = CarbStore( - 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)), - overrideHistory: TemporaryScheduleOverrideHistory(), - provenanceIdentifier: Bundle.main.bundleIdentifier!, - test_currentDate: testType.currentDate) - + + var algorithmInput: LoopAlgorithmInput! + var algorithmOutput: LoopAlgorithmOutput! + + var mockAlgorithmState: AlgorithmDisplayState! + + var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? + + var carbRatioSchedule: CarbRatioSchedule? + + var maximumBolus: Double? = 5 + var maximumBasalRatePerHour: Double = 6 + + var bolusState: PumpManagerStatus.BolusState? = .noBolus + + func setUp(for testType: MissedMealTestType) { // 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)) - + + let date = testType.currentDate + let historyStart = date.addingTimeInterval(-.hours(24)) + + let glucoseTarget = GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [.init(startTime: 0, value: DoubleRange(minValue: 100, maxValue: 110))]) + + insulinSensitivityScheduleApplyingOverrideHistory = testType.insulinSensitivitySchedule + carbRatioSchedule = testType.carbSchedule + + algorithmInput = LoopAlgorithmInput( + predictionStart: date, + glucoseHistory: [StoredGlucoseSample(startDate: date, quantity: .init(unit: .milligramsPerDeciliter, doubleValue: 100))], + doses: [], + carbEntries: testType.carbEntries.map { $0.asStoredCarbEntry }, + basal: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)])!.between(start: historyStart, end: date), + sensitivity: testType.insulinSensitivitySchedule.quantitiesBetween(start: historyStart, end: date), + carbRatio: testType.carbSchedule.between(start: historyStart, end: date), + target: glucoseTarget!.quantityBetween(start: historyStart, end: date), + suspendThreshold: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 65), + maxBolus: maximumBolus!, + maxBasalRate: maximumBasalRatePerHour, + recommendationInsulinType: .novolog, + recommendationType: .automaticBolus + ) + + // These tests don't actually run the loop algorithm directly; they were written to take ICE from fixtures, compute carb effects, and subtract them. + let counteractionEffects = counteractionEffects(for: testType) + + let carbEntries = testType.carbEntries.map { $0.asStoredCarbEntry } + // Carb Effects + let carbStatus = carbEntries.map( + to: counteractionEffects, + carbRatio: algorithmInput.carbRatio, + insulinSensitivity: algorithmInput.sensitivity + ) + + let carbEffects = carbStatus.dynamicGlucoseEffects( + from: date.addingTimeInterval(-IntegralRetrospectiveCorrection.retrospectionInterval), + carbRatios: algorithmInput.carbRatio, + insulinSensitivities: algorithmInput.sensitivity, + absorptionModel: algorithmInput.carbAbsorptionModel.model + ) + + let effects = LoopAlgorithmEffects( + insulin: [], + carbs: carbEffects, + carbStatus: carbStatus, + retrospectiveCorrection: [], + momentum: [], + insulinCounteraction: counteractionEffects, + retrospectiveGlucoseDiscrepancies: [] + ) + + algorithmOutput = LoopAlgorithmOutput( + recommendationResult: .success(.init()), + predictedGlucose: [], + effects: effects, + dosesRelativeToBasal: [] + ) + mealDetectionManager = MealDetectionManager( - carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, - insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, - maximumBolus: 5, - test_currentDate: testType.currentDate + algorithmStateProvider: self, + settingsProvider: self, + bolusStateProvider: self ) - - glucoseSamples = [MockGlucoseSample(startDate: now)] - - bolusDurationEstimator = { units in - self.bolusUnits = units - return self.pumpManager.estimatedDuration(toBolus: units) - } - - // Fetch & return the counteraction effects for the test - return counteractionEffects(for: testType) + mealDetectionManager.test_currentDate = testType.currentDate + } private func counteractionEffects(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { @@ -253,27 +288,6 @@ class MealDetectionManagerTests: XCTestCase { } } - private func mealDetectionCarbEffects(using insulinCounteractionEffects: [GlucoseEffectVelocity]) -> [GlucoseEffect] { - let carbEffectStart = now.addingTimeInterval(-MissedMealSettings.maxRecency) - - var carbEffects: [GlucoseEffect] = [] - - let updateGroup = DispatchGroup() - updateGroup.enter() - carbStore.getGlucoseEffects(start: carbEffectStart, end: now, effectVelocities: insulinCounteractionEffects) { result in - defer { updateGroup.leave() } - - guard case .success((_, let effects)) = result else { - XCTFail("Failed to fetch glucose effects to check for missed meal") - return - } - carbEffects = effects - } - _ = updateGroup.wait(timeout: .now() + .seconds(5)) - - return carbEffects - } - override func tearDown() { mealDetectionManager.lastMissedMealNotification = nil mealDetectionManager = nil @@ -282,104 +296,128 @@ class MealDetectionManagerTests: XCTestCase { // MARK: - Algorithm Tests func testNoMissedMeal() { - let counteractionEffects = setUp(for: .noMeal) + setUp(for: .noMeal) + + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() + XCTAssertEqual(status, .noMissedMeal) } func testNoMissedMeal_WithCOB() { - let counteractionEffects = setUp(for: .noMealWithCOB) + setUp(for: .noMealWithCOB) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .noMissedMeal) } func testMissedMeal_NoCarbEntry() { let testType = MissedMealTestType.missedMealNoCOB - let counteractionEffects = setUp(for: testType) + setUp(for: testType) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 55)) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 55)) } func testDynamicCarbAutofill() { let testType = MissedMealTestType.dynamicCarbAutofill - let counteractionEffects = setUp(for: testType) + setUp(for: testType) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) } func testMissedMeal_MissedMealAndCOB() { let testType = MissedMealTestType.missedMealWithCOB - let counteractionEffects = setUp(for: testType) + setUp(for: testType) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 50)) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 50)) } func testNoisyCGM() { - let counteractionEffects = setUp(for: .noisyCGM) + setUp(for: .noisyCGM) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .noMissedMeal) } func testManyMeals() { let testType = MissedMealTestType.manyMeals - let counteractionEffects = setUp(for: testType) + setUp(for: testType) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) } func testMMOLUser() { let testType = MissedMealTestType.mmolUser - let counteractionEffects = setUp(for: testType) + setUp(for: testType) - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) - updateGroup.leave() - } - updateGroup.wait() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: algorithmInput.glucoseHistory, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) } // MARK: - Notification Tests @@ -388,8 +426,13 @@ class MealDetectionManagerTests: XCTestCase { UserDefaults.standard.missedMealNotificationsEnabled = true let status = MissedMealStatus.noMissedMeal - mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - + mealDetectionManager.manageMealNotifications( + at: now, + for: status + ) + + mealDetectionManager.manageMealNotifications(at: now, for: status) + XCTAssertNil(mealDetectionManager.lastMissedMealNotification) } @@ -398,8 +441,8 @@ class MealDetectionManagerTests: XCTestCase { UserDefaults.standard.missedMealNotificationsEnabled = true let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) - mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) } @@ -409,8 +452,8 @@ class MealDetectionManagerTests: XCTestCase { UserDefaults.standard.missedMealNotificationsEnabled = false let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) - mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + XCTAssertNil(mealDetectionManager.lastMissedMealNotification) } @@ -423,8 +466,8 @@ class MealDetectionManagerTests: XCTestCase { mealDetectionManager.lastMissedMealNotification = oldNotification let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: MissedMealSettings.minCarbThreshold) - mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification, oldNotification) } @@ -433,8 +476,8 @@ class MealDetectionManagerTests: XCTestCase { UserDefaults.standard.missedMealNotificationsEnabled = true let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 120) - mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 75) } @@ -444,10 +487,9 @@ class MealDetectionManagerTests: XCTestCase { UserDefaults.standard.missedMealNotificationsEnabled = true let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) - mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 0, bolusDurationEstimator: bolusDurationEstimator) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + /// 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) } @@ -455,11 +497,21 @@ class MealDetectionManagerTests: XCTestCase { func testMissedMealLongPendingBolus() { setUp(for: .notificationTest) UserDefaults.standard.missedMealNotificationsEnabled = true - + + bolusState = .inProgress( + DoseEntry( + type: .bolus, + startDate: now.addingTimeInterval(-.seconds(10)), + endDate: now.addingTimeInterval(.minutes(10)), + value: 20, + unit: .units, + automatic: true + ) + ) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) - mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10, bolusDurationEstimator: bolusDurationEstimator) - - XCTAssertEqual(bolusUnits, 10) + mealDetectionManager.manageMealNotifications(at: now, for: status) + /// 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) @@ -468,61 +520,104 @@ class MealDetectionManagerTests: XCTestCase { func testNoMissedMealShortPendingBolus_DelaysNotificationTime() { setUp(for: .notificationTest) UserDefaults.standard.missedMealNotificationsEnabled = true - + + bolusState = .inProgress( + DoseEntry( + type: .bolus, + startDate: now.addingTimeInterval(-.seconds(10)), + endDate: now.addingTimeInterval(20), + value: 2, + unit: .units, + automatic: 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) + mealDetectionManager.manageMealNotifications(at: now, for: status) + + let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(20)) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime) - + + bolusState = .inProgress( + DoseEntry( + type: .bolus, + startDate: now.addingTimeInterval(-.seconds(10)), + endDate: now.addingTimeInterval(.minutes(3)), + value: 4.5, + unit: .units, + automatic: true + ) + ) + mealDetectionManager.lastMissedMealNotification = nil - mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5, bolusDurationEstimator: bolusDurationEstimator) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) - XCTAssertEqual(bolusUnits, 4.5) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime2) } func testHasCalibrationPoints_NoNotification() { let testType = MissedMealTestType.manyMeals - let counteractionEffects = setUp(for: testType) + setUp(for: testType) let calibratedGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, isDisplayOnly: true)] - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: calibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() - + var status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: calibratedGlucoseSamples, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .noMissedMeal) + let manualGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, wasUserEntered: true)] - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: manualGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() + + status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: manualGlucoseSamples, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .noMissedMeal) } func testHasTooOldCalibrationPoint_NoImpactOnNotificationDelivery() { let testType = MissedMealTestType.manyMeals - let counteractionEffects = setUp(for: testType) + setUp(for: testType) let tooOldCalibratedGlucoseSamples = [MockGlucoseSample(startDate: now, isDisplayOnly: false), MockGlucoseSample(startDate: now.addingTimeInterval(-MissedMealSettings.maxRecency-1), isDisplayOnly: true)] - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: tooOldCalibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) - updateGroup.leave() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: tooOldCalibratedGlucoseSamples, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) + } +} + +extension MealDetectionManagerTests: AlgorithmDisplayStateProvider { + var algorithmState: AlgorithmDisplayState { + get async { + return mockAlgorithmState } - updateGroup.wait() } } +extension MealDetectionManagerTests: BolusStateProvider { } + +extension MealDetectionManagerTests: SettingsWithOverridesProvider { } + extension MealDetectionManagerTests { public var bundle: Bundle { return Bundle(for: type(of: self)) diff --git a/LoopTests/Managers/SettingsManagerTests.swift b/LoopTests/Managers/SettingsManagerTests.swift new file mode 100644 index 0000000000..a4768bcd28 --- /dev/null +++ b/LoopTests/Managers/SettingsManagerTests.swift @@ -0,0 +1,35 @@ +// +// SettingsManager.swift +// LoopTests +// +// Created by Pete Schwamb on 12/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +@testable import Loop + +@MainActor +final class SettingsManagerTests: XCTestCase { + + + func testChangingMaxBasalUpdatesLoopData() async { + + let persistenceController = PersistenceController.mock() + + let settingsManager = SettingsManager(cacheStore: persistenceController, expireAfter: .days(1), alertMuter: AlertMuter()) + + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in + exp.fulfill() + } + + settingsManager.mutateLoopSettings { $0.maximumBasalRatePerHour = 2.0 } + + await fulfillment(of: [exp], timeout: 1.0) + NotificationCenter.default.removeObserver(observer) + } + + +} diff --git a/LoopTests/Managers/SupportManagerTests.swift b/LoopTests/Managers/SupportManagerTests.swift index 8106b33005..54471521ae 100644 --- a/LoopTests/Managers/SupportManagerTests.swift +++ b/LoopTests/Managers/SupportManagerTests.swift @@ -12,6 +12,7 @@ import LoopKitUI import SwiftUI @testable import Loop +@MainActor class SupportManagerTests: XCTestCase { enum MockError: Error { case nothing } @@ -66,14 +67,15 @@ class SupportManagerTests: XCTestCase { } class MockDeviceSupportDelegate: DeviceSupportDelegate { + var availableSupports: [LoopKitUI.SupportUI] = [] var pumpManagerStatus: LoopKit.PumpManagerStatus? var cgmManagerStatus: LoopKit.CGMManagerStatus? - func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { - completion("Mock Issue Report") + func generateDiagnosticReport() async -> String { + "Mock Issue Report" } } diff --git a/LoopTests/LoopSettingsTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift similarity index 64% rename from LoopTests/LoopSettingsTests.swift rename to LoopTests/Managers/TemporaryPresetsManagerTests.swift index a0ad8f4503..60da1a21c2 100644 --- a/LoopTests/LoopSettingsTests.swift +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -1,22 +1,22 @@ // -// LoopSettingsTests.swift +// TemporaryPresetsManagerTests.swift // LoopTests // -// Created by Michael Pangburn on 3/1/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. +// Created by Pete Schwamb on 12/11/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. // import XCTest -import LoopCore import LoopKit +@testable import Loop -class LoopSettingsTests: XCTestCase { +class TemporaryPresetsManagerTests: XCTestCase { private let preMealRange = DoubleRange(minValue: 80, maxValue: 80).quantityRange(for: .milligramsPerDeciliter) private let targetRange = DoubleRange(minValue: 95, maxValue: 105) - - private lazy var settings: LoopSettings = { - var settings = LoopSettings() + + private lazy var settings: StoredSettings = { + var settings = StoredSettings() settings.preMealTargetRange = preMealRange settings.glucoseTargetRangeSchedule = GlucoseRangeSchedule( unit: .milligramsPerDeciliter, @@ -24,20 +24,27 @@ class LoopSettingsTests: XCTestCase { ) return settings }() - + + var manager: TemporaryPresetsManager! + + override func setUp() async throws { + let settingsProvider = MockSettingsProvider(settings: settings) + manager = TemporaryPresetsManager(settingsProvider: settingsProvider) + } + func testPreMealOverride() { var settings = self.settings let preMealStart = Date() - settings.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) - let actualPreMealRange = settings.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) + let actualPreMealRange = manager.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) XCTAssertEqual(preMealRange, actualPreMealRange) } - + func testPreMealOverrideWithPotentialCarbEntry() { var settings = self.settings let preMealStart = Date() - settings.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) - let actualRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: true)?.value(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) + let actualRange = manager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: true)?.value(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) XCTAssertEqual(targetRange, actualRange) } @@ -56,15 +63,15 @@ class LoopSettingsTests: XCTestCase { enactTrigger: .local, syncIdentifier: UUID() ) - settings.scheduleOverride = override - let actualOverrideRange = settings.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(30 /* minutes */ * 60)) + manager.scheduleOverride = override + let actualOverrideRange = manager.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(30 /* minutes */ * 60)) XCTAssertEqual(actualOverrideRange, overrideTargetRange) } func testBothPreMealAndScheduleOverride() { var settings = self.settings let preMealStart = Date() - settings.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) + manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) let overrideStart = Date() let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) @@ -79,19 +86,19 @@ class LoopSettingsTests: XCTestCase { enactTrigger: .local, syncIdentifier: UUID() ) - settings.scheduleOverride = override + manager.scheduleOverride = override - let actualPreMealRange = settings.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + let actualPreMealRange = manager.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) XCTAssertEqual(actualPreMealRange, preMealRange) // The pre-meal range should be projected into the future, despite the simultaneous schedule override - let preMealRangeDuringOverride = settings.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(2 /* hours */ * 60 * 60)) + let preMealRangeDuringOverride = manager.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(2 /* hours */ * 60 * 60)) XCTAssertEqual(preMealRangeDuringOverride, preMealRange) } func testScheduleOverrideWithExpiredPreMealOverride() { var settings = self.settings - settings.preMealOverride = TemporaryScheduleOverride( + manager.preMealOverride = TemporaryScheduleOverride( context: .preMeal, settings: TemporaryScheduleOverrideSettings(targetRange: preMealRange), startDate: Date(timeIntervalSinceNow: -2 /* hours */ * 60 * 60), @@ -113,9 +120,9 @@ class LoopSettingsTests: XCTestCase { enactTrigger: .local, syncIdentifier: UUID() ) - settings.scheduleOverride = override + manager.scheduleOverride = override - let actualOverrideRange = settings.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) + let actualOverrideRange = manager.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) XCTAssertEqual(actualOverrideRange, overrideTargetRange) } } diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 4a5c016eb5..83ef9dc4d4 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -8,170 +8,38 @@ import HealthKit import LoopKit +import LoopCore @testable import Loop class MockCarbStore: CarbStoreProtocol { - var carbHistory: [StoredCarbEntry]? + var defaultAbsorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - init(for scenario: DosingTestScenario = .flatAndStable) { - self.scenario = scenario // The store returns different effect values based on the scenario - self.carbHistory = loadHistoricCarbEntries(scenario: scenario) - } - - var scenario: DosingTestScenario - - var sampleType: HKSampleType = HKSampleType.quantityType(forIdentifier: HKQuantityTypeIdentifier.dietaryCarbohydrates)! - - var preferredUnit: HKUnit! = .gram() - - var delegate: CarbStoreDelegate? - - var carbRatioSchedule: CarbRatioSchedule? - - var insulinSensitivitySchedule: InsulinSensitivitySchedule? - - var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? = InsulinSensitivitySchedule( - unit: HKUnit.milligramsPerDeciliter, - dailyItems: [ - RepeatingScheduleValue(startTime: 0.0, value: 45.0), - RepeatingScheduleValue(startTime: 32400.0, value: 55.0) - ], - timeZone: .utcTimeZone - )! - - var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? = CarbRatioSchedule( - unit: .gram(), - dailyItems: [ - RepeatingScheduleValue(startTime: 0.0, value: 10.0), - RepeatingScheduleValue(startTime: 32400.0, value: 12.0) - ], - timeZone: .utcTimeZone - )! - - var maximumAbsorptionTimeInterval: TimeInterval { - return defaultAbsorptionTimes.slow * 2 - } - - var delta: TimeInterval = .minutes(5) - - var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) - - var authorizationRequired: Bool = false - - var sharingDenied: Bool = false - - func authorize(toShare: Bool, read: Bool, _ completion: @escaping (HealthKitSampleStoreResult) -> Void) { - completion(.success(true)) - } - - func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbStatus]>) -> Void) { - completion(.failure(.notConfigured)) - } - - func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { - completion("") - } - - func glucoseEffects(of samples: [Sample], startingAt start: Date, endingAt end: Date?, effectVelocities: [LoopKit.GlucoseEffectVelocity]) throws -> [LoopKit.GlucoseEffect] where Sample : LoopKit.CarbEntry { - return [] - } - - func getCarbsOnBoardValues(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbValue]>) -> Void) { - completion(.success([])) - } - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func getTotalCarbs(since start: Date, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func getGlucoseEffects(start: Date, end: Date?, effectVelocities: [LoopKit.GlucoseEffectVelocity], completion: @escaping (LoopKit.CarbStoreResult<(entries: [LoopKit.StoredCarbEntry], effects: [LoopKit.GlucoseEffect])>) -> Void) - { - if let carbHistory, let carbRatioScheduleApplyingOverrideHistory, let insulinSensitivityScheduleApplyingOverrideHistory { - let foodStart = start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) - let samples = carbHistory.filterDateRange(foodStart, end) - let carbDates = samples.map { $0.startDate } - let maxCarbDate = carbDates.max()! - let minCarbDate = carbDates.min()! - let carbRatio = carbRatioScheduleApplyingOverrideHistory.between(start: minCarbDate, end: maxCarbDate) - let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory.quantitiesBetween(start: minCarbDate, end: maxCarbDate) - let effects = samples.map( - to: effectVelocities, - carbRatio: carbRatio, - insulinSensitivity: insulinSensitivity - ).dynamicGlucoseEffects( - from: start, - to: end, - carbRatios: carbRatio, - insulinSensitivities: insulinSensitivity - ) - completion(.success((entries: samples, effects: effects))) + var carbHistory: [StoredCarbEntry] = [] - } else { - let fixture: [JSONDictionary] = loadFixture(fixtureToLoad) - - let dateFormatter = ISO8601DateFormatter.localTimeDate() - - return completion(.success(([], 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)) - }))) - } + func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { + return carbHistory.filterDateRange(start, end) } -} -extension MockCarbStore { - public var bundle: Bundle { - return Bundle(for: type(of: self)) + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry) async throws -> StoredCarbEntry { + let stored = newEntry.asStoredCarbEntry + carbHistory = carbHistory.map({ entry in + if entry.syncIdentifier == oldEntry.syncIdentifier { + return stored + } else { + return entry + } + }) + return stored } - 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 - } - - var fixtureToLoad: String { - switch scenario { - case .liveCapture: - fatalError("live capture scenario computes effects from carb entries, does not used pre-canned effects") - case .flatAndStable: - return "flat_and_stable_carb_effect" - case .highAndStable: - return "high_and_stable_carb_effect" - case .highAndRisingWithCOB: - return "high_and_rising_with_cob_carb_effect" - case .lowAndFallingWithCOB: - return "low_and_falling_carb_effect" - case .lowWithLowTreatment: - return "low_with_low_treatment_carb_effect" - case .highAndFalling: - return "high_and_falling_carb_effect" - } + func addCarbEntry(_ entry: NewCarbEntry) async throws -> StoredCarbEntry { + let stored = entry.asStoredCarbEntry + carbHistory.append(stored) + return stored } - public func loadHistoricCarbEntries(scenario: DosingTestScenario) -> [StoredCarbEntry]? { - if let url = bundle.url(forResource: scenario.fixturePrefix + "carb_entries", withExtension: "json"), - let data = try? Data(contentsOf: url) - { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try? decoder.decode([StoredCarbEntry].self, from: data) - } else { - return nil - } + func deleteCarbEntry(_ oldEntry: StoredCarbEntry) async throws -> Bool { + carbHistory = carbHistory.filter { $0.syncIdentifier == oldEntry.syncIdentifier } + return true } } diff --git a/LoopTests/Mock Stores/MockDoseStore.swift b/LoopTests/Mock Stores/MockDoseStore.swift index 207596f31b..985ac687fe 100644 --- a/LoopTests/Mock Stores/MockDoseStore.swift +++ b/LoopTests/Mock Stores/MockDoseStore.swift @@ -11,161 +11,26 @@ import LoopKit @testable import Loop class MockDoseStore: DoseStoreProtocol { - var doseHistory: [DoseEntry]? - var sensitivitySchedule: InsulinSensitivitySchedule? - - init(for scenario: DosingTestScenario = .flatAndStable) { - self.scenario = scenario // The store returns different effect values based on the scenario - self.pumpEventQueryAfterDate = scenario.currentDate - self.lastAddedPumpData = scenario.currentDate - self.doseHistory = loadHistoricDoses(scenario: scenario) + func getDoses(start: Date?, end: Date?) async throws -> [LoopKit.DoseEntry] { + return doseHistory ?? [] + addedDoses } - - static let dateFormatter = ISO8601DateFormatter.localTimeDate() - - var scenario: DosingTestScenario - - var basalProfileApplyingOverrideHistory: BasalRateSchedule? - - var delegate: DoseStoreDelegate? - - var device: HKDevice? - - var pumpRecordsBasalProfileStartEvents: Bool = false - - var pumpEventQueryAfterDate: Date - - var basalProfile: BasalRateSchedule? - - // Default to the adult exponential insulin model - var insulinModelProvider: InsulinModelProvider = StaticInsulinModelProvider(ExponentialInsulinModelPreset.rapidActingAdult) - var longestEffectDuration: TimeInterval = ExponentialInsulinModelPreset.rapidActingAdult.effectDuration + var addedDoses: [DoseEntry] = [] - var insulinSensitivitySchedule: InsulinSensitivitySchedule? - - var sampleType: HKSampleType = HKQuantityType.quantityType(forIdentifier: .insulinDelivery)! - - var authorizationRequired: Bool = false - - var sharingDenied: Bool = false - - var lastReservoirValue: ReservoirValue? - - var lastAddedPumpData: Date - - func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, replacePendingEvents: Bool, completion: @escaping (DoseStore.DoseStoreError?) -> Void) { - completion(nil) - } - - func addReservoirValue(_ unitVolume: Double, at date: Date, completion: @escaping (ReservoirValue?, ReservoirValue?, Bool, DoseStore.DoseStoreError?) -> Void) { - completion(nil, nil, false, nil) - } - - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - completion(.success(.init(startDate: scenario.currentDate, value: 9.5))) - } - - func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { - completion("") + func addDoses(_ doses: [DoseEntry], from device: HKDevice?) async throws { + addedDoses = doses } - func addDoses(_ doses: [DoseEntry], from device: HKDevice?, completion: @escaping (Error?) -> Void) { - completion(nil) - } - - func getInsulinOnBoardValues(start: Date, end: Date?, basalDosingEnd: Date?, completion: @escaping (DoseStoreResult<[InsulinValue]>) -> Void) { - completion(.failure(.configurationError)) - } - - func getNormalizedDoseEntries(start: Date, end: Date?, completion: @escaping (DoseStoreResult<[DoseEntry]>) -> Void) { - completion(.failure(.configurationError)) - } - - func executePumpEventQuery(fromQueryAnchor queryAnchor: DoseStore.QueryAnchor?, limit: Int, completion: @escaping (DoseStore.PumpEventQueryResult) -> Void) { - completion(.failure(DoseStore.DoseStoreError.configurationError)) - } - - func getTotalUnitsDelivered(since startDate: Date, completion: @escaping (DoseStoreResult) -> Void) { - completion(.failure(.configurationError)) - } - - func getGlucoseEffects(start: Date, end: Date? = nil, basalDosingEnd: Date? = Date(), completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) { - if let doseHistory, let sensitivitySchedule, let basalProfile = basalProfileApplyingOverrideHistory { - // To properly know glucose effects at startDate, we need to go back another DIA hours - let doseStart = start.addingTimeInterval(-longestEffectDuration) - let doses = doseHistory.filterDateRange(doseStart, end) - let trimmedDoses = doses.map { (dose) -> DoseEntry in - guard dose.type != .bolus else { - return dose - } - return dose.trimmed(to: basalDosingEnd) - } - - let annotatedDoses = trimmedDoses.annotated(with: basalProfile) - - let glucoseEffects = annotatedDoses.glucoseEffects(insulinModelProvider: self.insulinModelProvider, longestEffectDuration: self.longestEffectDuration, insulinSensitivity: sensitivitySchedule, from: start, to: end) - completion(.success(glucoseEffects.filterDateRange(start, end))) - } else { - return completion(.success(getCannedGlucoseEffects())) - } - } - - func getCannedGlucoseEffects() -> [GlucoseEffect] { - let fixture: [JSONDictionary] = loadFixture(fixtureToLoad) - 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 - ) - ) - } - } -} - -extension MockDoseStore { - public var bundle: Bundle { - return Bundle(for: type(of: self)) - } + var lastReservoirValue: LoopKit.ReservoirValue? - 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 + func getTotalUnitsDelivered(since startDate: Date) async throws -> LoopKit.InsulinValue { + return InsulinValue(startDate: lastAddedPumpData, value: 0) } - var fixtureToLoad: String { - switch scenario { - case .liveCapture: - fatalError("live capture scenario computes effects from doses, does not used pre-canned effects") - case .flatAndStable: - return "flat_and_stable_insulin_effect" - case .highAndStable: - return "high_and_stable_insulin_effect" - case .highAndRisingWithCOB: - return "high_and_rising_with_cob_insulin_effect" - case .lowAndFallingWithCOB: - return "low_and_falling_insulin_effect" - case .lowWithLowTreatment: - return "low_with_low_treatment_insulin_effect" - case .highAndFalling: - return "high_and_falling_insulin_effect" - } - } + var lastAddedPumpData = Date.distantPast - public func loadHistoricDoses(scenario: DosingTestScenario) -> [DoseEntry]? { - if let url = bundle.url(forResource: scenario.fixturePrefix + "doses", withExtension: "json"), - let data = try? Data(contentsOf: url) - { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try? decoder.decode([DoseEntry].self, from: data) - } else { - return nil - } - } + var doseHistory: [DoseEntry]? + static let dateFormatter = ISO8601DateFormatter.localTimeDate() + } diff --git a/LoopTests/Mock Stores/MockDosingDecisionStore.swift b/LoopTests/Mock Stores/MockDosingDecisionStore.swift index f8e4191d8e..f13734326a 100644 --- a/LoopTests/Mock Stores/MockDosingDecisionStore.swift +++ b/LoopTests/Mock Stores/MockDosingDecisionStore.swift @@ -7,13 +7,34 @@ // import LoopKit +import XCTest @testable import Loop class MockDosingDecisionStore: DosingDecisionStoreProtocol { + var delegate: LoopKit.DosingDecisionStoreDelegate? + + var exportName: String = "MockDosingDecision" + + func exportProgressTotalUnitCount(startDate: Date, endDate: Date?) -> Result { + return .success(1) + } + + func export(startDate: Date, endDate: Date, to stream: LoopKit.DataOutputStream, progress: Progress) -> Error? { + return nil + } + var dosingDecisions: [StoredDosingDecision] = [] - func storeDosingDecision(_ dosingDecision: StoredDosingDecision, completion: @escaping () -> Void) { + var storeExpectation: XCTestExpectation? + + func storeDosingDecision(_ dosingDecision: StoredDosingDecision) async { dosingDecisions.append(dosingDecision) - completion() + storeExpectation?.fulfill() + } + + func executeDosingDecisionQuery(fromQueryAnchor queryAnchor: LoopKit.DosingDecisionStore.QueryAnchor?, limit: Int, completion: @escaping (LoopKit.DosingDecisionStore.DosingDecisionQueryResult) -> Void) { + if let queryAnchor { + completion(.success(queryAnchor, [])) + } } } diff --git a/LoopTests/Mock Stores/MockGlucoseStore.swift b/LoopTests/Mock Stores/MockGlucoseStore.swift index 19a6bc22e8..064f3c0fba 100644 --- a/LoopTests/Mock Stores/MockGlucoseStore.swift +++ b/LoopTests/Mock Stores/MockGlucoseStore.swift @@ -11,105 +11,22 @@ import LoopKit @testable import Loop class MockGlucoseStore: GlucoseStoreProtocol { - - init(for scenario: DosingTestScenario = .flatAndStable) { - self.scenario = scenario // The store returns different effect values based on the scenario - storedGlucose = loadHistoricGlucose(scenario: scenario) - } - - let dateFormatter = ISO8601DateFormatter.localTimeDate() - - var scenario: DosingTestScenario - var storedGlucose: [StoredGlucoseSample]? - - var latestGlucose: GlucoseSampleValue? { - if let storedGlucose { - return storedGlucose.last - } else { - return StoredGlucoseSample( - sample: HKQuantitySample( - type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, - quantity: HKQuantity(unit: HKUnit.milligramsPerDeciliter, doubleValue: latestGlucoseValue), - start: glucoseStartDate, - end: glucoseStartDate - ) - ) - } + func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] { + storedGlucose?.filterDateRange(start, end) ?? [] } - - var preferredUnit: HKUnit? - - var sampleType: HKSampleType = HKSampleType.quantityType(forIdentifier: HKQuantityTypeIdentifier.bloodGlucose)! - - var delegate: GlucoseStoreDelegate? - - var managedDataInterval: TimeInterval? - - var healthKitStorageDelay = TimeInterval(0) - var authorizationRequired: Bool = false - - var sharingDenied: Bool = false - - func authorize(toShare: Bool, read: Bool, _ completion: @escaping (HealthKitSampleStoreResult) -> Void) { - completion(.success(true)) - } - - func addGlucoseSamples(_ values: [NewGlucoseSample], completion: @escaping (Result<[StoredGlucoseSample], Error>) -> Void) { + func addGlucoseSamples(_ samples: [NewGlucoseSample]) async throws -> [StoredGlucoseSample] { // Using the dose store error because we don't need to create GlucoseStore errors just for the mock store - completion(.failure(DoseStore.DoseStoreError.configurationError)) + throw DoseStore.DoseStoreError.configurationError } - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Result<[StoredGlucoseSample], Error>) -> Void) { - completion(.success([latestGlucose as! StoredGlucoseSample])) - } - - func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { - completion("") - } - - func purgeAllGlucoseSamples(healthKitPredicate: NSPredicate, completion: @escaping (Error?) -> Void) { - // Using the dose store error because we don't need to create GlucoseStore errors just for the mock store - completion(DoseStore.DoseStoreError.configurationError) - } - - func executeGlucoseQuery(fromQueryAnchor queryAnchor: GlucoseStore.QueryAnchor?, limit: Int, completion: @escaping (GlucoseStore.GlucoseQueryResult) -> Void) { - // Using the dose store error because we don't need to create GlucoseStore errors just for the mock store - completion(.failure(DoseStore.DoseStoreError.configurationError)) - } - - func counteractionEffects(for samples: [Sample], to effects: [GlucoseEffect]) -> [GlucoseEffectVelocity] where Sample : GlucoseSampleValue { - samples.counteractionEffects(to: effects) - } - - func getRecentMomentumEffect(for date: Date? = nil, _ completion: @escaping (_ effects: Result<[GlucoseEffect], Error>) -> Void) { - if let storedGlucose { - let samples = storedGlucose.filterDateRange((date ?? Date()).addingTimeInterval(-GlucoseMath.momentumDataInterval), nil) - completion(.success(samples.linearMomentumEffect())) - } else { - let fixture: [JSONDictionary] = loadFixture(momentumEffectToLoad) - let dateFormatter = ISO8601DateFormatter.localTimeDate() - return completion(.success(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)) - } - )) - } - } + let dateFormatter = ISO8601DateFormatter.localTimeDate() - func getCounteractionEffects(start: Date, end: Date? = nil, to effects: [GlucoseEffect], _ completion: @escaping (_ effects: Result<[GlucoseEffectVelocity], Error>) -> Void) { - if let storedGlucose { - let samples = storedGlucose.filterDateRange(start, end) - completion(.success(self.counteractionEffects(for: samples, to: effects))) - } else { - let fixture: [JSONDictionary] = loadFixture(counteractionEffectToLoad) - let dateFormatter = ISO8601DateFormatter.localTimeDate() - - completion(.success(fixture.map { - return 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)) - })) - } + var storedGlucose: [StoredGlucoseSample]? + + var latestGlucose: GlucoseSampleValue? { + return storedGlucose?.last } } @@ -123,92 +40,5 @@ extension MockGlucoseStore { return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T } - public func loadHistoricGlucose(scenario: DosingTestScenario) -> [StoredGlucoseSample]? { - if let url = bundle.url(forResource: scenario.fixturePrefix + "historic_glucose", withExtension: "json"), - let data = try? Data(contentsOf: url) - { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try? decoder.decode([StoredGlucoseSample].self, from: data) - } else { - return nil - } - } - - var counteractionEffectToLoad: String { - switch scenario { - case .liveCapture: - fatalError("live capture scenario computes counteraction effects from input data, does not used pre-canned effects") - case .flatAndStable: - return "flat_and_stable_counteraction_effect" - case .highAndStable: - return "high_and_stable_counteraction_effect" - case .highAndRisingWithCOB: - return "high_and_rising_with_cob_counteraction_effect" - case .lowAndFallingWithCOB: - return "low_and_falling_counteraction_effect" - case .lowWithLowTreatment: - return "low_with_low_treatment_counteraction_effect" - case .highAndFalling: - return "high_and_falling_counteraction_effect" - } - } - - var momentumEffectToLoad: String { - switch scenario { - case .liveCapture: - fatalError("live capture scenario computes momentu effects from input data, does not used pre-canned effects") - case .flatAndStable: - return "flat_and_stable_momentum_effect" - case .highAndStable: - return "high_and_stable_momentum_effect" - case .highAndRisingWithCOB: - return "high_and_rising_with_cob_momentum_effect" - case .lowAndFallingWithCOB: - return "low_and_falling_momentum_effect" - case .lowWithLowTreatment: - return "low_with_low_treatment_momentum_effect" - case .highAndFalling: - return "high_and_falling_momentum_effect" - } - } - - var glucoseStartDate: Date { - switch scenario { - case .liveCapture: - fatalError("live capture scenario uses actual glucose input data") - case .flatAndStable: - return dateFormatter.date(from: "2020-08-11T20:45:02")! - case .highAndStable: - return dateFormatter.date(from: "2020-08-12T12:39:22")! - case .highAndRisingWithCOB: - return dateFormatter.date(from: "2020-08-11T21:48:17")! - case .lowAndFallingWithCOB: - return dateFormatter.date(from: "2020-08-11T22:06:06")! - case .lowWithLowTreatment: - return dateFormatter.date(from: "2020-08-11T22:23:55")! - case .highAndFalling: - return dateFormatter.date(from: "2020-08-11T22:59:45")! - } - } - - var latestGlucoseValue: Double { - switch scenario { - case .liveCapture: - fatalError("live capture scenario uses actual glucose input data") - case .flatAndStable: - return 123.42849966275706 - case .highAndStable: - return 200.0 - case .highAndRisingWithCOB: - return 129.93174411197853 - case .lowAndFallingWithCOB: - return 75.10768374646841 - case .lowWithLowTreatment: - return 81.22399763523448 - case .highAndFalling: - return 200.0 - } - } } diff --git a/LoopTests/Mock Stores/MockSettingsStore.swift b/LoopTests/Mock Stores/MockSettingsStore.swift index 7e21268236..0113596810 100644 --- a/LoopTests/Mock Stores/MockSettingsStore.swift +++ b/LoopTests/Mock Stores/MockSettingsStore.swift @@ -10,7 +10,7 @@ import LoopKit @testable import Loop class MockLatestStoredSettingsProvider: LatestStoredSettingsProvider { - var latestSettings: StoredSettings { StoredSettings() } + var settings: StoredSettings { StoredSettings() } func storeSettings(_ settings: StoredSettings, completion: @escaping () -> Void) { completion() } diff --git a/LoopTests/Mocks/AlertMocks.swift b/LoopTests/Mocks/AlertMocks.swift new file mode 100644 index 0000000000..d13c0663db --- /dev/null +++ b/LoopTests/Mocks/AlertMocks.swift @@ -0,0 +1,192 @@ +// +// AlertMocks.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKit +@testable import Loop + +class MockBluetoothProvider: BluetoothProvider { + var bluetoothAuthorization: BluetoothAuthorization = .authorized + + var bluetoothState: BluetoothState = .poweredOn + + func authorizeBluetooth(_ completion: @escaping (BluetoothAuthorization) -> Void) { + completion(bluetoothAuthorization) + } + + func addBluetoothObserver(_ observer: BluetoothObserver, queue: DispatchQueue) { + } + + func removeBluetoothObserver(_ observer: BluetoothObserver) { + } +} + +class MockModalAlertScheduler: InAppModalAlertScheduler { + var scheduledAlert: Alert? + override func scheduleAlert(_ alert: Alert) { + scheduledAlert = alert + } + var unscheduledAlertIdentifier: Alert.Identifier? + override func unscheduleAlert(identifier: Alert.Identifier) { + unscheduledAlertIdentifier = identifier + } +} + +class MockUserNotificationAlertScheduler: UserNotificationAlertScheduler { + var scheduledAlert: Alert? + var muted: Bool? + + override func scheduleAlert(_ alert: Alert, muted: Bool) { + scheduledAlert = alert + self.muted = muted + } + var unscheduledAlertIdentifier: Alert.Identifier? + override func unscheduleAlert(identifier: Alert.Identifier) { + unscheduledAlertIdentifier = identifier + } +} + +class MockResponder: AlertResponder { + var acknowledged: [Alert.AlertIdentifier: Bool] = [:] + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + acknowledged[alertIdentifier] = true + } +} + +class MockFileManager: FileManager { + + var fileExists = true + let newer = Date() + let older = Date.distantPast + + var createdDirURL: URL? + override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { + createdDirURL = url + } + override func fileExists(atPath path: String) -> Bool { + return !path.contains("doesntExist") + } + override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey : Any] { + return path.contains("Sounds") ? path.contains("existsNewer") ? [.creationDate: newer] : [.creationDate: older] : + [.creationDate: newer] + } + var removedURLs = [URL]() + override func removeItem(at URL: URL) throws { + removedURLs.append(URL) + } + var copiedSrcURLs = [URL]() + var copiedDstURLs = [URL]() + override func copyItem(at srcURL: URL, to dstURL: URL) throws { + copiedSrcURLs.append(srcURL) + copiedDstURLs.append(dstURL) + } + override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { + return [] + } +} + +class MockPresenter: AlertPresenter { + func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { completion?() } + func dismissTopMost(animated: Bool, completion: (() -> Void)?) { completion?() } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { completion?() } +} + +class MockAlertManagerResponder: AlertManagerResponder { + func acknowledgeAlert(identifier: LoopKit.Alert.Identifier) { } +} + +class MockSoundVendor: AlertSoundVendor { + func getSoundBaseURL() -> URL? { + // Hm. It's not easy to make a "fake" URL, so we'll use this one: + return Bundle.main.resourceURL + } + + func getSounds() -> [Alert.Sound] { + return [.sound(name: "doesntExist"), .sound(name: "existsNewer"), .sound(name: "existsOlder")] + } +} + +class MockAlertStore: AlertStore { + + var issuedAlert: Alert? + override public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { + issuedAlert = alert + completion?(.success) + } + + var retractedAlert: Alert? + var retractedAlertDate: Date? + override public func recordRetractedAlert(_ alert: Alert, at date: Date, completion: ((Result) -> Void)? = nil) { + retractedAlert = alert + retractedAlertDate = date + completion?(.success) + } + + var acknowledgedAlertIdentifier: Alert.Identifier? + var acknowledgedAlertDate: Date? + override public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date(), + completion: ((Result) -> Void)? = nil) { + acknowledgedAlertIdentifier = identifier + acknowledgedAlertDate = date + completion?(.success) + } + + var retractededAlertIdentifier: Alert.Identifier? + override public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date(), + completion: ((Result) -> Void)? = nil) { + retractededAlertIdentifier = identifier + retractedAlertDate = date + completion?(.success) + } + + var storedAlerts = [StoredAlert]() + override public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + completion(.success(storedAlerts)) + } + + override public func lookupAllUnretracted(managerIdentifier: String?, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + completion(.success(storedAlerts)) + } +} + +class MockUserNotificationCenter: UserNotificationCenter { + + var pendingRequests = [UNNotificationRequest]() + var deliveredRequests = [UNNotificationRequest]() + + func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: ((Error?) -> Void)? = nil) { + pendingRequests.append(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + identifiers.forEach { identifier in + pendingRequests.removeAll { $0.identifier == identifier } + } + } + + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { + identifiers.forEach { identifier in + deliveredRequests.removeAll { $0.identifier == identifier } + } + } + + func deliverAll() { + deliveredRequests = pendingRequests + pendingRequests = [] + } + + func getDeliveredNotifications(completionHandler: @escaping ([UNNotification]) -> Void) { + // Sadly, we can't create UNNotifications. + completionHandler([]) + } + + func getPendingNotificationRequests(completionHandler: @escaping ([UNNotificationRequest]) -> Void) { + completionHandler(pendingRequests) + } +} diff --git a/LoopTests/Mocks/LoopControlMock.swift b/LoopTests/Mocks/LoopControlMock.swift new file mode 100644 index 0000000000..29be4a17bb --- /dev/null +++ b/LoopTests/Mocks/LoopControlMock.swift @@ -0,0 +1,28 @@ +// +// LoopControlMock.swift +// LoopTests +// +// Created by Pete Schwamb on 11/30/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import Foundation +@testable import Loop + + +class LoopControlMock: LoopControl { + var lastLoopCompleted: Date? + + var lastCancelActiveTempBasalReason: CancelActiveTempBasalReason? + + var cancelExpectation: XCTestExpectation? + + func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async { + lastCancelActiveTempBasalReason = reason + cancelExpectation?.fulfill() + } + + func loop() async { + } +} diff --git a/LoopTests/Mocks/MockCGMManager.swift b/LoopTests/Mocks/MockCGMManager.swift new file mode 100644 index 0000000000..38e6d6a140 --- /dev/null +++ b/LoopTests/Mocks/MockCGMManager.swift @@ -0,0 +1,63 @@ +// +// MockCGMManager.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +class MockCGMManager: CGMManager { + var cgmManagerDelegate: LoopKit.CGMManagerDelegate? + + var providesBLEHeartbeat: Bool = false + + var managedDataInterval: TimeInterval? + + var shouldSyncToRemoteService: Bool = true + + var glucoseDisplay: LoopKit.GlucoseDisplayable? + + var cgmManagerStatus: LoopKit.CGMManagerStatus { + return CGMManagerStatus(hasValidSensorSession: true, device: nil) + } + + var delegateQueue: DispatchQueue! + + func fetchNewDataIfNeeded(_ completion: @escaping (LoopKit.CGMReadingResult) -> Void) { + completion(.noData) + } + + var localizedTitle: String = "MockCGMManager" + + init() { + } + + required init?(rawState: RawStateValue) { + } + + var rawState: RawStateValue { + return [:] + } + + var isOnboarded: Bool = true + + var debugDescription: String = "MockCGMManager" + + func acknowledgeAlert(alertIdentifier: LoopKit.Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func getSoundBaseURL() -> URL? { + return nil + } + + func getSounds() -> [LoopKit.Alert.Sound] { + return [] + } + + var pluginIdentifier: String = "MockCGMManager" + +} diff --git a/LoopTests/Mocks/MockDeliveryDelegate.swift b/LoopTests/Mocks/MockDeliveryDelegate.swift new file mode 100644 index 0000000000..bc14f03f00 --- /dev/null +++ b/LoopTests/Mocks/MockDeliveryDelegate.swift @@ -0,0 +1,45 @@ +// +// MockDeliveryDelegate.swift +// LoopTests +// +// Created by Pete Schwamb on 12/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +@testable import Loop + +class MockDeliveryDelegate: DeliveryDelegate { + var isSuspended: Bool = false + + var pumpInsulinType: InsulinType? + + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? + + var isPumpConfigured: Bool = true + + var lastEnact: AutomaticDoseRecommendation? + + func enact(_ recommendation: AutomaticDoseRecommendation) async throws { + lastEnact = recommendation + } + + var lastBolus: Double? + var lastBolusActivationType: BolusActivationType? + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { + lastBolus = units + lastBolusActivationType = activationType + } + + func roundBasalRate(unitsPerHour: Double) -> Double { + (unitsPerHour * 20).rounded() / 20.0 + } + + func roundBolusVolume(units: Double) -> Double { + (units * 20).rounded() / 20.0 + } + + +} diff --git a/LoopTests/Mocks/MockPumpManager.swift b/LoopTests/Mocks/MockPumpManager.swift new file mode 100644 index 0000000000..70131ab674 --- /dev/null +++ b/LoopTests/Mocks/MockPumpManager.swift @@ -0,0 +1,141 @@ +// +// MockPumpManager.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopKitUI +import HealthKit +@testable import Loop + +class MockPumpManager: PumpManager { + + var enactBolusCalled: ((Double, BolusActivationType) -> Void)? + + var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? + + var enactTempBasalError: PumpManagerError? + + init() { + + } + + // PumpManager implementation + static var onboardingMaximumBasalScheduleEntryCount: Int = 24 + + static var onboardingSupportedBasalRates: [Double] = [1,2,3] + + static var onboardingSupportedBolusVolumes: [Double] = [1,2,3] + + static var onboardingSupportedMaximumBolusVolumes: [Double] = [1,2,3] + + let deliveryUnitsPerMinute = 1.5 + + var supportedBasalRates: [Double] = [1,2,3] + + var supportedBolusVolumes: [Double] = [1,2,3] + + var supportedMaximumBolusVolumes: [Double] = [1,2,3] + + var maximumBasalScheduleEntryCount: Int = 24 + + var minimumBasalScheduleEntryDuration: TimeInterval = .minutes(30) + + var pumpManagerDelegate: PumpManagerDelegate? + + var pumpRecordsBasalProfileStartEvents: Bool = false + + var pumpReservoirCapacity: Double = 50 + + var lastSync: Date? + + var status: PumpManagerStatus = + PumpManagerStatus( + timeZone: TimeZone.current, + device: HKDevice(name: "MockPumpManager", manufacturer: nil, model: nil, hardwareVersion: nil, firmwareVersion: nil, softwareVersion: nil, localIdentifier: nil, udiDeviceIdentifier: nil), + pumpBatteryChargeRemaining: nil, + basalDeliveryState: nil, + bolusState: .noBolus, + insulinType: .novolog) + + func addStatusObserver(_ observer: PumpManagerStatusObserver, queue: DispatchQueue) { + } + + func removeStatusObserver(_ observer: PumpManagerStatusObserver) { + } + + func ensureCurrentPumpData(completion: ((Date?) -> Void)?) { + completion?(Date()) + } + + func setMustProvideBLEHeartbeat(_ mustProvideBLEHeartbeat: Bool) { + } + + func createBolusProgressReporter(reportingOn dispatchQueue: DispatchQueue) -> DoseProgressReporter? { + return nil + } + + func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { + enactBolusCalled?(units, activationType) + completion(nil) + } + + func cancelBolus(completion: @escaping (PumpManagerResult) -> Void) { + completion(.success(nil)) + } + + func enactTempBasal(unitsPerHour: Double, for duration: TimeInterval, completion: @escaping (PumpManagerError?) -> Void) { + enactTempBasalCalled?(unitsPerHour, duration) + completion(enactTempBasalError) + } + + func suspendDelivery(completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func resumeDelivery(completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func syncBasalRateSchedule(items scheduleItems: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { + } + + func syncDeliveryLimits(limits deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { + completion(.success(deliveryLimits)) + } + + func estimatedDuration(toBolus units: Double) -> TimeInterval { + .minutes(units / deliveryUnitsPerMinute) + } + + var pluginIdentifier: String = "MockPumpManager" + + var localizedTitle: String = "MockPumpManager" + + var delegateQueue: DispatchQueue! + + required init?(rawState: RawStateValue) { + + } + + var rawState: RawStateValue = [:] + + var isOnboarded: Bool = true + + var debugDescription: String = "MockPumpManager" + + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + } + + func getSoundBaseURL() -> URL? { + return nil + } + + func getSounds() -> [Alert.Sound] { + return [.sound(name: "doesntExist")] + } +} diff --git a/LoopTests/Mocks/MockSettingsProvider.swift b/LoopTests/Mocks/MockSettingsProvider.swift new file mode 100644 index 0000000000..150608a1fe --- /dev/null +++ b/LoopTests/Mocks/MockSettingsProvider.swift @@ -0,0 +1,49 @@ +// +// MockSettingsProvider.swift +// LoopTests +// +// Created by Pete Schwamb on 11/28/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit +@testable import Loop + +class MockSettingsProvider: SettingsProvider { + + var basalHistory: [AbsoluteScheduleValue]? + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + return basalHistory ?? settings.basalRateSchedule?.between(start: startDate, end: endDate) ?? [] + } + + var carbRatioHistory: [AbsoluteScheduleValue]? + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + return carbRatioHistory ?? settings.carbRatioSchedule?.between(start: startDate, end: endDate) ?? [] + } + + var insulinSensitivityHistory: [AbsoluteScheduleValue]? + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + return insulinSensitivityHistory ?? settings.insulinSensitivitySchedule?.quantitiesBetween(start: startDate, end: endDate) ?? [] + } + + var targetRangeHistory: [AbsoluteScheduleValue>]? + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] { + return targetRangeHistory ?? settings.glucoseTargetRangeSchedule?.quantityBetween(start: startDate, end: endDate) ?? [] + } + + func getDosingLimits(at date: Date) async throws -> DosingLimits { + return DosingLimits( + suspendThreshold: settings.suspendThreshold?.quantity, + maxBolus: settings.maximumBolus, + maxBasalRate: settings.maximumBasalRatePerHour + ) + } + + var settings: StoredSettings + + init(settings: StoredSettings) { + self.settings = settings + } +} diff --git a/LoopTests/Mocks/MockTrustedTimeChecker.swift b/LoopTests/Mocks/MockTrustedTimeChecker.swift new file mode 100644 index 0000000000..137de2eede --- /dev/null +++ b/LoopTests/Mocks/MockTrustedTimeChecker.swift @@ -0,0 +1,14 @@ +// +// MockTrustedTimeChecker.swift +// LoopTests +// +// Created by Pete Schwamb on 11/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +@testable import Loop + +class MockTrustedTimeChecker: TrustedTimeChecker { + var detectedSystemTimeOffset: TimeInterval = 0 +} diff --git a/LoopTests/Mocks/MockUploadEventListener.swift b/LoopTests/Mocks/MockUploadEventListener.swift new file mode 100644 index 0000000000..75de952dd6 --- /dev/null +++ b/LoopTests/Mocks/MockUploadEventListener.swift @@ -0,0 +1,17 @@ +// +// MockUploadEventListener.swift +// LoopTests +// +// Created by Pete Schwamb on 11/30/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +@testable import Loop + +class MockUploadEventListener: UploadEventListener { + var lastUploadTriggeringType: RemoteDataType? + func triggerUpload(for triggeringType: RemoteDataType) { + self.lastUploadTriggeringType = triggeringType + } +} diff --git a/LoopTests/Mocks/PersistenceController.swift b/LoopTests/Mocks/PersistenceController.swift new file mode 100644 index 0000000000..43fca07c60 --- /dev/null +++ b/LoopTests/Mocks/PersistenceController.swift @@ -0,0 +1,16 @@ +// +// PersistenceController.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +extension PersistenceController { + static func mock() -> PersistenceController { + return PersistenceController(directoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString, isDirectory: true)) + } +} diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 8b65faa377..790a3bcd0a 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -57,7 +57,7 @@ class BolusEntryViewModelTests: XCTestCase { var bolusEntryViewModel: BolusEntryViewModel! fileprivate var delegate: MockBolusEntryViewModelDelegate! var now: Date = BolusEntryViewModelTests.now - + let mockOriginalCarbEntry = StoredCarbEntry( startDate: BolusEntryViewModelTests.exampleStartDate, quantity: BolusEntryViewModelTests.exampleCarbQuantity, @@ -87,6 +87,8 @@ class BolusEntryViewModelTests: XCTestCase { let queue = DispatchQueue(label: "BolusEntryViewModelTests") var saveAndDeliverSuccess = false + var mockDeliveryDelegate = MockDeliveryDelegate() + override func setUp(completion: @escaping (Error?) -> Void) { now = Self.now delegate = MockBolusEntryViewModelDelegate() @@ -113,6 +115,8 @@ class BolusEntryViewModelTests: XCTestCase { bolusEntryViewModel.maximumBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 10) + bolusEntryViewModel.deliveryDelegate = mockDeliveryDelegate + await bolusEntryViewModel.generateRecommendationAndStartObserving() } @@ -166,7 +170,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateGlucoseValues() async throws { XCTAssertEqual(0, bolusEntryViewModel.glucoseValues.count) - delegate.getGlucoseSamplesResponse = [StoredGlucoseSample(sample: Self.exampleCGMGlucoseSample)] + delegate.loopStateInput.glucoseHistory = [StoredGlucoseSample(sample: Self.exampleCGMGlucoseSample)] await bolusEntryViewModel.update() XCTAssertEqual(1, bolusEntryViewModel.glucoseValues.count) XCTAssertEqual([100.4], bolusEntryViewModel.glucoseValues.map { @@ -176,10 +180,10 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateGlucoseValuesWithManual() async throws { XCTAssertEqual(0, bolusEntryViewModel.glucoseValues.count) - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - delegate.getGlucoseSamplesResponse = [StoredGlucoseSample(sample: Self.exampleCGMGlucoseSample)] + bolusEntryViewModel.manualGlucoseQuantity = .glucose(value: 123) + delegate.loopStateInput.glucoseHistory = [.mock(100, at: now.addingTimeInterval(-.minutes(5)))] await bolusEntryViewModel.update() - XCTAssertEqual([100.4, 123.4], bolusEntryViewModel.glucoseValues.map { + XCTAssertEqual([100, 123], bolusEntryViewModel.glucoseValues.map { return $0.quantity.doubleValue(for: .milligramsPerDeciliter) }) } @@ -191,22 +195,26 @@ class BolusEntryViewModelTests: XCTestCase { } func testUpdatePredictedGlucoseValues() async throws { - let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] - delegate.loopState.predictGlucoseValueResult = prediction - await bolusEntryViewModel.update() - XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) + do { + let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false) + let prediction = try input.predictGlucose() + await bolusEntryViewModel.update() + XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) + } catch { + XCTFail("Unable to generate prediction") + } } func testUpdatePredictedGlucoseValuesWithManual() async throws { - let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] - delegate.loopState.predictGlucoseValueResult = prediction - await bolusEntryViewModel.update() - - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - XCTAssertEqual(prediction, - bolusEntryViewModel.predictedGlucoseValues.map { - PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) - }) + do { + let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false) + let prediction = try input.predictGlucose() + await bolusEntryViewModel.update() + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) + } catch { + XCTFail("Unable to generate prediction") + } } func testUpdateSettings() async throws { @@ -218,20 +226,20 @@ class BolusEntryViewModelTests: XCTestCase { RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! - var newSettings = LoopSettings(dosingEnabled: true, + let newSettings = StoredSettings(dosingEnabled: true, glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, maximumBasalRatePerHour: 1.0, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) let settings = TemporaryScheduleOverrideSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) - newSettings.preMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) - newSettings.scheduleOverride = TemporaryScheduleOverride(context: .custom, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) + delegate.preMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) + delegate.scheduleOverride = TemporaryScheduleOverride(context: .custom, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) delegate.settings = newSettings bolusEntryViewModel.updateSettings() await bolusEntryViewModel.update() - XCTAssertEqual(newSettings.preMealOverride, bolusEntryViewModel.preMealOverride) - XCTAssertEqual(newSettings.scheduleOverride, bolusEntryViewModel.scheduleOverride) + XCTAssertEqual(delegate.preMealOverride, bolusEntryViewModel.preMealOverride) + XCTAssertEqual(delegate.scheduleOverride, bolusEntryViewModel.scheduleOverride) XCTAssertEqual(newGlucoseTargetRangeSchedule, bolusEntryViewModel.targetGlucoseSchedule) } @@ -245,78 +253,85 @@ class BolusEntryViewModelTests: XCTestCase { RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! - var newSettings = LoopSettings(dosingEnabled: true, + let newSettings = StoredSettings(dosingEnabled: true, glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, maximumBasalRatePerHour: 1.0, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) - newSettings.preMealOverride = Self.examplePreMealOverride - newSettings.scheduleOverride = Self.exampleCustomScheduleOverride + delegate.preMealOverride = Self.examplePreMealOverride + delegate.scheduleOverride = Self.exampleCustomScheduleOverride delegate.settings = newSettings bolusEntryViewModel.updateSettings() // Pre-meal override should be ignored if we have carbs (LOOP-1964), and cleared in settings - XCTAssertEqual(newSettings.scheduleOverride, bolusEntryViewModel.scheduleOverride) + XCTAssertEqual(delegate.scheduleOverride, bolusEntryViewModel.scheduleOverride) XCTAssertEqual(newGlucoseTargetRangeSchedule, bolusEntryViewModel.targetGlucoseSchedule) // ... but restored if we cancel without bolusing bolusEntryViewModel = nil } - func testManualGlucoseChangesPredictedGlucoseValues() async throws { - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] - delegate.loopState.predictGlucoseValueResult = prediction + func testManualGlucoseIncludedInAlgorithmRun() async throws { + bolusEntryViewModel.manualGlucoseQuantity = .glucose(value: 123) await bolusEntryViewModel.update() - XCTAssertEqual(prediction, - bolusEntryViewModel.predictedGlucoseValues.map { - PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) - }) + XCTAssertEqual(123, delegate.manualGlucoseSampleForBolusRecommendation?.quantity.doubleValue(for: .milligramsPerDeciliter)) } func testUpdateInsulinOnBoard() async throws { - delegate.insulinOnBoardResult = .success(InsulinValue(startDate: Self.exampleStartDate, value: 1.5)) + delegate.activeInsulin = InsulinValue(startDate: Self.exampleStartDate, value: 1.5) XCTAssertNil(bolusEntryViewModel.activeInsulin) await bolusEntryViewModel.update() XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 1.5), bolusEntryViewModel.activeInsulin) } func testUpdateCarbsOnBoard() async throws { - delegate.carbsOnBoardResult = .success(CarbValue(startDate: Self.exampleStartDate, endDate: Self.exampleEndDate, value: Self.exampleCarbQuantity.doubleValue(for: .gram()))) + delegate.activeCarbs = CarbValue(startDate: Self.exampleStartDate, endDate: Self.exampleEndDate, value: Self.exampleCarbQuantity.doubleValue(for: .gram())) XCTAssertNil(bolusEntryViewModel.activeCarbs) await bolusEntryViewModel.update() XCTAssertEqual(Self.exampleCarbQuantity, bolusEntryViewModel.activeCarbs) } func testUpdateCarbsOnBoardFailure() async throws { - delegate.carbsOnBoardResult = .failure(CarbStore.CarbStoreError.notConfigured) + delegate.activeCarbs = nil await bolusEntryViewModel.update() XCTAssertNil(bolusEntryViewModel.activeCarbs) } func testUpdateRecommendedBolusNoNotice() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + let originalCarbEntry = StoredCarbEntry.mock(50, at: now.addingTimeInterval(-.minutes(5))) + let editedCarbEntry = NewCarbEntry.mock(40, at: now.addingTimeInterval(-.minutes(5))) + + delegate.loopStateInput.carbEntries = [originalCarbEntry] + + await setUpViewModel(originalCarbEntry: originalCarbEntry, potentialCarbEntry: editedCarbEntry) + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendation = ManualBolusRecommendation(amount: 1.25) - delegate.loopState.bolusRecommendationResult = recommendation + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) + await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) - let consideringPotentialCarbEntryPassed = try XCTUnwrap(delegate.loopState.consideringPotentialCarbEntryPassed) - XCTAssertEqual(mockPotentialCarbEntry, consideringPotentialCarbEntryPassed) - let replacingCarbEntryPassed = try XCTUnwrap(delegate.loopState.replacingCarbEntryPassed) - XCTAssertEqual(mockOriginalCarbEntry, replacingCarbEntryPassed) + + XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation?.quantity, originalCarbEntry.quantity) + XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation?.quantity, editedCarbEntry.quantity) + XCTAssertNil(delegate.manualGlucoseSampleForBolusRecommendation) + XCTAssertNil(bolusEntryViewModel.activeNotice) } func testUpdateRecommendedBolusWithNotice() async throws { delegate.settings.suspendThreshold = GlucoseThreshold(unit: .milligramsPerDeciliter, value: Self.exampleCGMGlucoseQuantity.doubleValue(for: .milligramsPerDeciliter)) XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - let recommendation = ManualBolusRecommendation(amount: 1.25, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) - delegate.loopState.bolusRecommendationResult = recommendation + let recommendation = ManualBolusRecommendation( + amount: 1.25, + notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue) + ) + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -329,7 +344,7 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) delegate.settings.suspendThreshold = nil let recommendation = ManualBolusRecommendation(amount: 1.25, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) - delegate.loopState.bolusRecommendationResult = recommendation + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -341,7 +356,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusWithOtherNotice() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendation = ManualBolusRecommendation(amount: 1.25, notice: BolusRecommendationNotice.currentGlucoseBelowTarget(glucose: Self.exampleGlucoseValue)) - delegate.loopState.bolusRecommendationResult = recommendation + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -352,7 +367,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsMissingDataError() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.missingDataError(.glucose) + delegate.algorithmOutput.recommendationResult = .failure(LoopError.missingDataError(.glucose)) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -362,7 +377,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsPumpDataTooOld() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.pumpDataTooOld(date: now) + delegate.algorithmOutput.recommendationResult = .failure(LoopError.pumpDataTooOld(date: now)) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -372,7 +387,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsGlucoseTooOld() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.glucoseTooOld(date: now) + delegate.algorithmOutput.recommendationResult = .failure(LoopError.glucoseTooOld(date: now)) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -382,7 +397,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsInvalidFutureGlucose() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.invalidFutureGlucose(date: now) + delegate.algorithmOutput.recommendationResult = .failure(LoopError.invalidFutureGlucose(date: now)) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -392,7 +407,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsOtherError() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.pumpSuspended + delegate.algorithmOutput.recommendationResult = .failure(LoopError.pumpSuspended) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -401,20 +416,31 @@ class BolusEntryViewModelTests: XCTestCase { } func testUpdateRecommendedBolusWithManual() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + let originalCarbEntry = StoredCarbEntry.mock(50, at: now.addingTimeInterval(-.minutes(5))) + let editedCarbEntry = NewCarbEntry.mock(40, at: now.addingTimeInterval(-.minutes(5))) + + delegate.loopStateInput.carbEntries = [originalCarbEntry] + + await setUpViewModel(originalCarbEntry: originalCarbEntry, potentialCarbEntry: editedCarbEntry) + + let manualGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123) + + bolusEntryViewModel.manualGlucoseQuantity = manualGlucoseQuantity XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendation = ManualBolusRecommendation(amount: 1.25) - delegate.loopState.bolusRecommendationResult = recommendation + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) - let consideringPotentialCarbEntryPassed = try XCTUnwrap(delegate.loopState.consideringPotentialCarbEntryPassed) - XCTAssertEqual(mockPotentialCarbEntry, consideringPotentialCarbEntryPassed) - let replacingCarbEntryPassed = try XCTUnwrap(delegate.loopState.replacingCarbEntryPassed) - XCTAssertEqual(mockOriginalCarbEntry, replacingCarbEntryPassed) + + XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation, editedCarbEntry) + XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation, originalCarbEntry) + XCTAssertEqual(delegate.manualGlucoseSampleForBolusRecommendation?.quantity, manualGlucoseQuantity) + XCTAssertNil(bolusEntryViewModel.activeNotice) } @@ -508,8 +534,6 @@ class BolusEntryViewModelTests: XCTestCase { bolusEntryViewModel.enteredBolus = BolusEntryViewModelTests.noBolus - delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) - let saveAndDeliverSuccess = await bolusEntryViewModel.saveAndDeliver() let expectedGlucoseSample = NewGlucoseSample(date: now, quantity: Self.exampleManualGlucoseQuantity, condition: nil, trend: nil, trendRate: nil, isDisplayOnly: false, wasUserEntered: true, syncIdentifier: mockUUID) @@ -534,7 +558,6 @@ class BolusEntryViewModelTests: XCTestCase { func testSaveCarbGlucoseNoBolus() async throws { await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) - delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) delegate.addCarbEntryResult = .success(mockFinalCarbEntry) try await saveAndDeliver(BolusEntryViewModelTests.noBolus) @@ -557,8 +580,6 @@ class BolusEntryViewModelTests: XCTestCase { func testSaveManualGlucoseAndBolus() async throws { bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) - try await saveAndDeliver(BolusEntryViewModelTests.exampleBolusQuantity) let expectedGlucoseSample = NewGlucoseSample(date: now, quantity: Self.exampleManualGlucoseQuantity, condition: nil, trend: nil, trendRate: nil, isDisplayOnly: false, wasUserEntered: true, syncIdentifier: mockUUID) @@ -609,13 +630,14 @@ class BolusEntryViewModelTests: XCTestCase { RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! - var newSettings = LoopSettings(dosingEnabled: true, + let newSettings = StoredSettings(dosingEnabled: true, glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, maximumBasalRatePerHour: 1.0, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) - newSettings.preMealOverride = Self.examplePreMealOverride - newSettings.scheduleOverride = Self.exampleCustomScheduleOverride + + delegate.preMealOverride = Self.examplePreMealOverride + delegate.scheduleOverride = Self.exampleCustomScheduleOverride delegate.settings = newSettings bolusEntryViewModel.updateSettings() @@ -633,7 +655,6 @@ class BolusEntryViewModelTests: XCTestCase { await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) delegate.addCarbEntryResult = .success(mockFinalCarbEntry) try await saveAndDeliver(BolusEntryViewModelTests.exampleBolusQuantity) @@ -798,173 +819,149 @@ class BolusEntryViewModelTests: XCTestCase { bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity XCTAssertEqual(.saveAndDeliver, bolusEntryViewModel.actionButtonAction) } -} - -// MARK: utilities - -fileprivate class MockLoopState: LoopState { - - var carbsOnBoard: CarbValue? - - var insulinOnBoard: InsulinValue? - - var error: LoopError? - - var insulinCounteractionEffects: [GlucoseEffectVelocity] = [] - - var predictedGlucose: [PredictedGlucoseValue]? - - var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? - - var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? - - var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? - - var totalRetrospectiveCorrection: HKQuantity? - - var predictGlucoseValueResult: [PredictedGlucoseValue] = [] - func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { - return predictGlucoseValueResult - } - func predictGlucoseFromManualGlucose(_ glucose: NewGlucoseSample, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { - return predictGlucoseValueResult - } - - var bolusRecommendationResult: ManualBolusRecommendation? - var bolusRecommendationError: Error? - var consideringPotentialCarbEntryPassed: NewCarbEntry?? - var replacingCarbEntryPassed: StoredCarbEntry?? - func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - consideringPotentialCarbEntryPassed = potentialCarbEntry - replacingCarbEntryPassed = replacedCarbEntry - if let error = bolusRecommendationError { throw error } - return bolusRecommendationResult - } - - func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - consideringPotentialCarbEntryPassed = potentialCarbEntry - replacingCarbEntryPassed = replacedCarbEntry - if let error = bolusRecommendationError { throw error } - return bolusRecommendationResult - } } + public enum BolusEntryViewTestError: Error { case responseUndefined } fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { - fileprivate var loopState = MockLoopState() - - private let dataAccessQueue = DispatchQueue(label: "com.loopKit.tests.dataAccessQueue", qos: .utility) + var settings = StoredSettings( + dosingEnabled: true, + glucoseTargetRangeSchedule: BolusEntryViewModelTests.exampleGlucoseRangeSchedule, + maximumBasalRatePerHour: 3.0, + maximumBolus: 10.0, + suspendThreshold: GlucoseThreshold(unit: .internationalUnit(), value: 75)) + { + didSet { + NotificationCenter.default.post(name: .LoopDataUpdated, object: nil, userInfo: [ + LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue + ]) + } + } - func updateRemoteRecommendation() { - } + var scheduleOverride: LoopKit.TemporaryScheduleOverride? + + var preMealOverride: LoopKit.TemporaryScheduleOverride? + + var pumpInsulinType: LoopKit.InsulinType? + + var mostRecentGlucoseDataDate: Date? + + var mostRecentPumpDataDate: Date? - func roundBolusVolume(units: Double) -> Double { - // 0.05 units for rates between 0.05-30U/hr - // 0 is not a supported bolus volume - let supportedBolusVolumes = (1...600).map { Double($0) / 20.0 } - return ([0.0] + supportedBolusVolumes).enumerated().min( by: { abs($0.1 - units) < abs($1.1 - units) } )!.1 + var loopStateInput = LoopAlgorithmInput( + predictionStart: Date(), + glucoseHistory: [], + doses: [], + carbEntries: [], + basal: [], + sensitivity: [], + carbRatio: [], + target: [], + suspendThreshold: nil, + maxBolus: 3, + maxBasalRate: 6, + carbAbsorptionModel: .piecewiseLinear, + recommendationInsulinType: .novolog, + recommendationType: .manualBolus, + automaticBolusApplicationFactor: 0.4 + ) + + func fetchData(for baseTime: Date, disablingPreMeal: Bool) async throws -> LoopAlgorithmInput { + loopStateInput.predictionStart = baseTime + return loopStateInput + } + + func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? { + return nil } - + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { - return .hours(6) + .minutes(10) + return InsulinMath.defaultInsulinActivityDuration } - var pumpInsulinType: InsulinType? - - var displayGlucosePreference: DisplayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) - - func withLoopState(do block: @escaping (LoopState) -> Void) { - dataAccessQueue.async { - block(self.loopState) + var carbEntriesAdded = [(NewCarbEntry, StoredCarbEntry?)]() + var addCarbEntryResult: Result = .failure(BolusEntryViewTestError.responseUndefined) + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { + carbEntriesAdded.append((carbEntry, replacingEntry)) + switch addCarbEntryResult { + case .success(let success): + return success + case .failure(let failure): + throw failure } } - func saveGlucose(sample: LoopKit.NewGlucoseSample) async -> LoopKit.StoredGlucoseSample? { - glucoseSamplesAdded.append(sample) - return StoredGlucoseSample(sample: sample.quantitySample) - } - var glucoseSamplesAdded = [NewGlucoseSample]() - var addGlucoseSamplesResult: Swift.Result<[StoredGlucoseSample], Error> = .failure(BolusEntryViewTestError.responseUndefined) - func addGlucoseSamples(_ samples: [NewGlucoseSample], completion: ((Swift.Result<[StoredGlucoseSample], Error>) -> Void)?) { - glucoseSamplesAdded.append(contentsOf: samples) - completion?(addGlucoseSamplesResult) - } - - var carbEntriesAdded = [(NewCarbEntry, StoredCarbEntry?)]() - var addCarbEntryResult: Result = .failure(BolusEntryViewTestError.responseUndefined) - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { - carbEntriesAdded.append((carbEntry, replacingEntry)) - completion(addCarbEntryResult) + var saveGlucoseError: Error? + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample { + glucoseSamplesAdded.append(sample) + if let saveGlucoseError { + throw saveGlucoseError + } else { + return sample.asStoredGlucoseStample + } } var bolusDosingDecisionsAdded = [(BolusDosingDecision, Date)]() - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { bolusDosingDecisionsAdded.append((bolusDosingDecision, date)) } - + var enactedBolusUnits: Double? var enactedBolusActivationType: BolusActivationType? - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (Error?) -> Void) { + func enactBolus(units: Double, activationType: BolusActivationType) async throws { enactedBolusUnits = units enactedBolusActivationType = activationType } - - var getGlucoseSamplesResponse: [StoredGlucoseSample] = [] - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - completion(.success(getGlucoseSamplesResponse)) - } - - var insulinOnBoardResult: DoseStoreResult? - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - if let insulinOnBoardResult = insulinOnBoardResult { - completion(insulinOnBoardResult) - } else { - completion(.failure(.configurationError)) - } + + var activeInsulin: InsulinValue? + + var activeCarbs: CarbValue? + + var prediction: [PredictedGlucoseValue] = [] + var lastGeneratePredictionInput: LoopAlgorithmInput? + + func generatePrediction(input: LoopAlgorithmInput) throws -> [PredictedGlucoseValue] { + lastGeneratePredictionInput = input + return prediction } - - var carbsOnBoardResult: CarbStoreResult? - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { - if let carbsOnBoardResult = carbsOnBoardResult { - completion(carbsOnBoardResult) + + var algorithmOutput: LoopAlgorithmOutput = LoopAlgorithmOutput( + recommendationResult: .success(.init()), + predictedGlucose: [], + effects: LoopAlgorithmEffects.emptyMock, + dosesRelativeToBasal: [], + activeInsulin: nil, + activeCarbs: nil + ) + + var manualGlucoseSampleForBolusRecommendation: NewGlucoseSample? + var potentialCarbEntryForBolusRecommendation: NewCarbEntry? + var originalCarbEntryForBolusRecommendation: StoredCarbEntry? + + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample?, + potentialCarbEntry: NewCarbEntry?, + originalCarbEntry: StoredCarbEntry? + ) async throws -> ManualBolusRecommendation? { + + manualGlucoseSampleForBolusRecommendation = manualGlucoseSample + potentialCarbEntryForBolusRecommendation = potentialCarbEntry + originalCarbEntryForBolusRecommendation = originalCarbEntry + + switch algorithmOutput.recommendationResult { + case .success(let recommendation): + return recommendation.manual + case .failure(let error): + throw error } } - - var ensureCurrentPumpDataCompletion: ((Date?) -> Void)? - func ensureCurrentPumpData(completion: @escaping (Date?) -> Void) { - ensureCurrentPumpDataCompletion = completion - } - - var mostRecentGlucoseDataDate: Date? - - var mostRecentPumpDataDate: Date? - - var isPumpConfigured: Bool = true - - var preferredGlucoseUnit: HKUnit = .milligramsPerDeciliter - - var insulinModel: InsulinModel? = MockInsulinModel() - - var settings: LoopSettings = LoopSettings( - dosingEnabled: true, - glucoseTargetRangeSchedule: BolusEntryViewModelTests.exampleGlucoseRangeSchedule, - maximumBasalRatePerHour: 3.0, - maximumBolus: 10.0, - suspendThreshold: GlucoseThreshold(unit: .internationalUnit(), value: 75)) { - didSet { - NotificationCenter.default.post(name: .LoopDataUpdated, object: nil, userInfo: [ - LoopDataManager.LoopUpdateContextKey: LoopDataManager.LoopUpdateContext.preferences.rawValue - ]) - } - } - } fileprivate struct MockInsulinModel: InsulinModel { @@ -1012,3 +1009,40 @@ extension ManualBolusRecommendationWithDate: Equatable { return lhs.recommendation == rhs.recommendation && lhs.date == rhs.date } } + +extension LoopAlgorithmEffects { + public static var emptyMock: LoopAlgorithmEffects { + return LoopAlgorithmEffects( + insulin: [], + carbs: [], + carbStatus: [], + retrospectiveCorrection: [], + momentum: [], + insulinCounteraction: [], + retrospectiveGlucoseDiscrepancies: [] + ) + } +} + +extension NewCarbEntry { + static func mock(_ grams: Double, at date: Date) -> NewCarbEntry { + NewCarbEntry( + quantity: .init(unit: .gram(), doubleValue: grams), + startDate: date, + foodType: nil, + absorptionTime: nil + ) + } +} + +extension StoredCarbEntry { + static func mock(_ grams: Double, at date: Date) -> StoredCarbEntry { + StoredCarbEntry(startDate: date, quantity: .init(unit: .gram(), doubleValue: grams)) + } +} + +extension StoredGlucoseSample { + static func mock(_ value: Double, at date: Date) -> StoredGlucoseSample { + StoredGlucoseSample(startDate: date, quantity: .glucose(value: value)) + } +} diff --git a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift index 55104e5a1b..46cb1e75a3 100644 --- a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift +++ b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift @@ -12,6 +12,7 @@ import LoopKit import XCTest @testable import Loop +@MainActor class ManualEntryDoseViewModelTests: XCTestCase { static let now = Date.distantFuture @@ -24,13 +25,6 @@ class ManualEntryDoseViewModelTests: XCTestCase { static let noBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) - var authenticateOverrideCompletion: ((Swift.Result) -> Void)? - private func authenticateOverride(_ message: String, _ completion: @escaping (Swift.Result) -> Void) { - authenticateOverrideCompletion = completion - } - - var saveAndDeliverSuccess = false - fileprivate var delegate: MockManualEntryDoseViewModelDelegate! static let mockUUID = UUID() @@ -39,100 +33,67 @@ class ManualEntryDoseViewModelTests: XCTestCase { override func setUpWithError() throws { now = Self.now delegate = MockManualEntryDoseViewModelDelegate() - delegate.mostRecentGlucoseDataDate = now - delegate.mostRecentPumpDataDate = now - saveAndDeliverSuccess = false setUpViewModel() } func setUpViewModel() { manualEntryDoseViewModel = ManualEntryDoseViewModel(delegate: delegate, now: { self.now }, - screenWidth: 512, debounceIntervalMilliseconds: 0, uuidProvider: { self.mockUUID }, timeZone: TimeZone(abbreviation: "GMT")!) - manualEntryDoseViewModel.authenticate = authenticateOverride + manualEntryDoseViewModel.authenticationHandler = { _ in return true } } - func testDoseLogging() throws { + func testDoseLogging() async throws { XCTAssertEqual(.novolog, manualEntryDoseViewModel.selectedInsulinType) manualEntryDoseViewModel.enteredBolus = Self.exampleBolusQuantity - try saveAndDeliver(ManualEntryDoseViewModelTests.exampleBolusQuantity) + try await manualEntryDoseViewModel.saveManualDose() + XCTAssertEqual(delegate.manualEntryBolusUnits, Self.exampleBolusQuantity.doubleValue(for: .internationalUnit())) XCTAssertEqual(delegate.manuallyEnteredDoseInsulinType, .novolog) } - - private func saveAndDeliver(_ bolus: HKQuantity, file: StaticString = #file, line: UInt = #line) throws { - manualEntryDoseViewModel.enteredBolus = bolus - manualEntryDoseViewModel.saveManualDose { self.saveAndDeliverSuccess = true } - if bolus != ManualEntryDoseViewModelTests.noBolus { - let authenticateOverrideCompletion = try XCTUnwrap(self.authenticateOverrideCompletion, file: file, line: line) - authenticateOverrideCompletion(.success(())) - } + + func testDoseNotSavedIfNotAuthenticated() async throws { + XCTAssertEqual(.novolog, manualEntryDoseViewModel.selectedInsulinType) + manualEntryDoseViewModel.enteredBolus = Self.exampleBolusQuantity + + manualEntryDoseViewModel.authenticationHandler = { _ in return false } + + do { + try await manualEntryDoseViewModel.saveManualDose() + XCTFail("Saving should fail if not authenticated.") + } catch { } + + XCTAssertNil(delegate.manualEntryBolusUnits) + XCTAssertNil(delegate.manuallyEnteredDoseInsulinType) } + } fileprivate class MockManualEntryDoseViewModelDelegate: ManualDoseViewModelDelegate { - - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { - return .hours(6) + .minutes(10) - } - - var pumpInsulinType: InsulinType? - + var pumpInsulinType: LoopKit.InsulinType? + var manualEntryBolusUnits: Double? var manualEntryDoseStartDate: Date? var manuallyEnteredDoseInsulinType: InsulinType? + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) { manualEntryBolusUnits = units manualEntryDoseStartDate = startDate manuallyEnteredDoseInsulinType = insulinType } - var loopStateCallBlock: ((LoopState) -> Void)? - func withLoopState(do block: @escaping (LoopState) -> Void) { - loopStateCallBlock = block + func insulinActivityDuration(for type: LoopKit.InsulinType?) -> TimeInterval { + return InsulinMath.defaultInsulinActivityDuration } - var enactedBolusUnits: Double? - func enactBolus(units: Double, automatic: Bool, completion: @escaping (Error?) -> Void) { - enactedBolusUnits = units - } - - var getGlucoseSamplesResponse: [StoredGlucoseSample] = [] - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - completion(.success(getGlucoseSamplesResponse)) - } - - var insulinOnBoardResult: DoseStoreResult? - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - if let insulinOnBoardResult = insulinOnBoardResult { - completion(insulinOnBoardResult) - } - } - - var carbsOnBoardResult: CarbStoreResult? - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { - if let carbsOnBoardResult = carbsOnBoardResult { - completion(carbsOnBoardResult) - } - } - - var ensureCurrentPumpDataCompletion: (() -> Void)? - func ensureCurrentPumpData(completion: @escaping () -> Void) { - ensureCurrentPumpDataCompletion = completion - } - - var mostRecentGlucoseDataDate: Date? - - var mostRecentPumpDataDate: Date? - - var isPumpConfigured: Bool = true - - var preferredGlucoseUnit: HKUnit = .milligramsPerDeciliter - - var settings: LoopSettings = LoopSettings() + var algorithmDisplayState = AlgorithmDisplayState() + + var settings = StoredSettings() + + var scheduleOverride: TemporaryScheduleOverride? + } diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift index 92d7de8b7e..b46077c9bf 100644 --- a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -14,6 +14,7 @@ import LoopCore @testable import Loop +@MainActor class SimpleBolusViewModelTests: XCTestCase { enum MockError: Error { @@ -37,44 +38,31 @@ class SimpleBolusViewModelTests: XCTestCase { enactedBolus = nil currentRecommendation = 0 } - - func testFailedAuthenticationShouldNotSaveDataOrBolus() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) - viewModel.authenticate = { (description, completion) in + + func testFailedAuthenticationShouldNotSaveDataOrBolus() async { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) + viewModel.setAuthenticationMethdod { description, completion in completion(.failure(MockError.authentication)) } - + viewModel.enteredBolusString = "3" - let saveExpectation = expectation(description: "Save completion callback") - - viewModel.saveAndDeliver { (success) in - saveExpectation.fulfill() - } - - waitForExpectations(timeout: 2) - + let _ = await viewModel.saveAndDeliver() + XCTAssertNil(enactedBolus) XCTAssertNil(addedCarbEntry) XCTAssert(addedGlucose.isEmpty) - } - func testIssuingBolus() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + func testIssuingBolus() async { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } viewModel.enteredBolusString = "3" - let saveExpectation = expectation(description: "Save completion callback") - - viewModel.saveAndDeliver { (success) in - saveExpectation.fulfill() - } - - waitForExpectations(timeout: 2) + let _ = await viewModel.saveAndDeliver() XCTAssertNil(addedCarbEntry) XCTAssert(addedGlucose.isEmpty) @@ -83,8 +71,8 @@ class SimpleBolusViewModelTests: XCTestCase { } - func testMealCarbsAndManualGlucoseWithRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + func testMealCarbsAndManualGlucoseWithRecommendation() async { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -94,13 +82,7 @@ class SimpleBolusViewModelTests: XCTestCase { viewModel.enteredCarbString = "20" viewModel.manualGlucoseString = "180" - let saveExpectation = expectation(description: "Save completion callback") - - viewModel.saveAndDeliver { (success) in - saveExpectation.fulfill() - } - - waitForExpectations(timeout: 2) + let _ = await viewModel.saveAndDeliver() XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram())) XCTAssertEqual(180, addedGlucose.first?.quantity.doubleValue(for: .milligramsPerDeciliter)) @@ -111,8 +93,8 @@ class SimpleBolusViewModelTests: XCTestCase { XCTAssertEqual(storedBolusDecision?.carbEntry?.quantity, addedCarbEntry?.quantity) } - func testMealCarbsWithUserOverridingRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + func testMealCarbsWithUserOverridingRecommendation() async { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -127,13 +109,7 @@ class SimpleBolusViewModelTests: XCTestCase { viewModel.enteredBolusString = "0.1" - let saveExpectation = expectation(description: "Save completion callback") - - viewModel.saveAndDeliver { (success) in - saveExpectation.fulfill() - } - - waitForExpectations(timeout: 2) + let _ = await viewModel.saveAndDeliver() XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram())) @@ -145,7 +121,7 @@ class SimpleBolusViewModelTests: XCTestCase { } func testDeleteCarbsRemovesRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -164,7 +140,7 @@ class SimpleBolusViewModelTests: XCTestCase { } func testDeleteCurrentGlucoseRemovesRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -183,7 +159,7 @@ class SimpleBolusViewModelTests: XCTestCase { } func testDeleteCurrentGlucoseRemovesActiveInsulin() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -201,7 +177,7 @@ class SimpleBolusViewModelTests: XCTestCase { func testManualGlucoseStringMatchesDisplayGlucoseUnit() { // used "260" mg/dL ("14.4" mmol/L) since 14.40 mmol/L -> 259 mg/dL and 14.43 mmol/L -> 260 mg/dL - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) XCTAssertEqual(viewModel.manualGlucoseString, "") viewModel.manualGlucoseString = "260" XCTAssertEqual(viewModel.manualGlucoseString, "260") @@ -221,8 +197,8 @@ class SimpleBolusViewModelTests: XCTestCase { } func testGlucoseEntryWarnings() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) - + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) + currentRecommendation = 2 viewModel.manualGlucoseString = "180" XCTAssertNil(viewModel.activeNotice) @@ -252,26 +228,26 @@ class SimpleBolusViewModelTests: XCTestCase { } func testGlucoseEntryWarningsForMealBolus() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true, displayGlucosePreference: displayGlucosePreference) viewModel.manualGlucoseString = "69" viewModel.enteredCarbString = "25" XCTAssertEqual(viewModel.activeNotice, .glucoseWarning) } func testOutOfBoundsGlucoseShowsNoRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true, displayGlucosePreference: displayGlucosePreference) viewModel.manualGlucoseString = "699" XCTAssert(!viewModel.bolusRecommended) } func testOutOfBoundsCarbsShowsNoRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true, displayGlucosePreference: displayGlucosePreference) viewModel.enteredCarbString = "400" XCTAssert(!viewModel.bolusRecommended) } func testMaxBolusWarnings() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.enteredBolusString = "20" XCTAssertEqual(viewModel.activeNotice, .maxBolusExceeded) @@ -285,13 +261,12 @@ class SimpleBolusViewModelTests: XCTestCase { } extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - addedGlucose = samples - completion(.success([])) + func saveGlucose(sample: LoopKit.NewGlucoseSample) async throws -> StoredGlucoseSample { + addedGlucose.append(sample) + return sample.asStoredGlucoseStample } - - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { - + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { addedCarbEntry = carbEntry let storedCarbEntry = StoredCarbEntry( startDate: carbEntry.startDate, @@ -305,35 +280,38 @@ extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { createdByCurrentApp: true, userCreatedDate: Date(), userUpdatedDate: nil) - completion(.success(storedCarbEntry)) + return storedCarbEntry } - func enactBolus(units: Double, activationType: BolusActivationType) { + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { + storedBolusDecision = bolusDosingDecision + } + + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { enactedBolus = (units: units, activationType: activationType) } - - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - completion(.success(currentIOB)) + + + func insulinOnBoard(at date: Date) async -> InsulinValue? { + return currentIOB } - + + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { - var decision = BolusDosingDecision(for: .simpleBolus) decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: currentRecommendation, notice: .none), date: date) decision.insulinOnBoard = currentIOB return decision } - - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { - storedBolusDecision = bolusDosingDecision - } - var maximumBolus: Double { + + var maximumBolus: Double? { return 3.0 } - var suspendThreshold: HKQuantity { + var suspendThreshold: HKQuantity? { return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) } } diff --git a/WatchApp Extension/Controllers/ActionHUDController.swift b/WatchApp Extension/Controllers/ActionHUDController.swift index dce285b9d9..bb2df24563 100644 --- a/WatchApp Extension/Controllers/ActionHUDController.swift +++ b/WatchApp Extension/Controllers/ActionHUDController.swift @@ -60,13 +60,13 @@ final class ActionHUDController: HUDInterfaceController { super.update() let activeOverrideContext: TemporaryScheduleOverride.Context? - if let override = loopManager.settings.scheduleOverride, override.isActive() { + if let override = loopManager.watchInfo.scheduleOverride, override.isActive() { activeOverrideContext = override.context } else { activeOverrideContext = nil } - updateForPreMeal(enabled: loopManager.settings.preMealOverride?.isActive() == true) + updateForPreMeal(enabled: loopManager.watchInfo.preMealOverride?.isActive() == true) updateForOverrideContext(activeOverrideContext) let isClosedLoop = loopManager.activeContext?.isClosedLoop ?? false @@ -80,7 +80,7 @@ final class ActionHUDController: HUDInterfaceController { carbsButtonGroup.state = .off bolusButtonGroup.state = .off - if loopManager.settings.preMealTargetRange == nil { + if loopManager.watchInfo.loopSettings.preMealTargetRange == nil { preMealButtonGroup.state = .disabled } else if preMealButtonGroup.state == .disabled { preMealButtonGroup.state = .off @@ -98,9 +98,9 @@ final class ActionHUDController: HUDInterfaceController { private var canEnableOverride: Bool { if FeatureFlags.sensitivityOverridesEnabled { - return !loopManager.settings.overridePresets.isEmpty + return !loopManager.watchInfo.loopSettings.overridePresets.isEmpty } else { - return loopManager.settings.legacyWorkoutTargetRange != nil + return loopManager.watchInfo.loopSettings.legacyWorkoutTargetRange != nil } } @@ -133,11 +133,11 @@ final class ActionHUDController: HUDInterfaceController { private let glucoseFormatter = QuantityFormatter(for: .milligramsPerDeciliter) @IBAction func togglePreMealMode() { - guard let range = loopManager.settings.preMealTargetRange else { + guard let range = loopManager.watchInfo.loopSettings.preMealTargetRange else { return } - let buttonToSelect = loopManager.settings.preMealOverride?.isActive() == true ? SelectedButton.on : SelectedButton.off + let buttonToSelect = loopManager.watchInfo.preMealOverride?.isActive() == true ? SelectedButton.on : SelectedButton.off let viewModel = OnOffSelectionViewModel( title: NSLocalizedString("Pre-Meal", comment: "Title for sheet to enable/disable pre-meal on watch"), message: formattedGlucoseRangeString(from: range), @@ -152,30 +152,29 @@ final class ActionHUDController: HUDInterfaceController { updateForPreMeal(enabled: isPreMealEnabled) pendingMessageResponses += 1 - var settings = loopManager.settings - let overrideContext = settings.scheduleOverride?.context + var watchInfo = loopManager.watchInfo + let overrideContext = watchInfo.scheduleOverride?.context if isPreMealEnabled { - settings.enablePreMealOverride(for: .hours(1)) + watchInfo.enablePreMealOverride(for: .hours(1)) if !FeatureFlags.sensitivityOverridesEnabled { - settings.clearOverride(matching: .legacyWorkout) + watchInfo.clearOverride(matching: .legacyWorkout) updateForOverrideContext(nil) } } else { - settings.clearOverride(matching: .preMeal) + watchInfo.clearOverride(matching: .preMeal) } - let userInfo = LoopSettingsUserInfo(settings: settings) do { - try WCSession.default.sendSettingsUpdateMessage(userInfo, completionHandler: { (result) in + try WCSession.default.sendSettingsUpdateMessage(watchInfo, completionHandler: { (result) in DispatchQueue.main.async { self.pendingMessageResponses -= 1 switch result { case .success(let context): if self.pendingMessageResponses == 0 { - self.loopManager.settings.preMealOverride = settings.preMealOverride - self.loopManager.settings.scheduleOverride = settings.scheduleOverride + self.loopManager.watchInfo.preMealOverride = watchInfo.preMealOverride + self.loopManager.watchInfo.scheduleOverride = watchInfo.scheduleOverride } ExtensionDelegate.shared().loopManager.updateContext(context) @@ -208,14 +207,14 @@ final class ActionHUDController: HUDInterfaceController { overrideButtonGroup.state == .on ? sendOverride(nil) : presentController(withName: OverrideSelectionController.className, context: self as OverrideSelectionControllerDelegate) - } else if let range = loopManager.settings.legacyWorkoutTargetRange { - let buttonToSelect = loopManager.settings.nonPreMealOverrideEnabled() == true ? SelectedButton.on : SelectedButton.off - + } else if let range = loopManager.watchInfo.loopSettings.legacyWorkoutTargetRange { + let buttonToSelect = loopManager.watchInfo.nonPreMealOverrideEnabled() == true ? SelectedButton.on : SelectedButton.off + let viewModel = OnOffSelectionViewModel( title: NSLocalizedString("Workout", comment: "Title for sheet to enable/disable workout mode on watch"), message: formattedGlucoseRangeString(from: range), onSelection: { isWorkoutEnabled in - let override = isWorkoutEnabled ? self.loopManager.settings.legacyWorkoutOverride(for: .infinity) : nil + let override = isWorkoutEnabled ? self.loopManager.watchInfo.legacyWorkoutOverride(for: .infinity) : nil self.sendOverride(override) }, selectedButton: buttonToSelect, @@ -244,24 +243,23 @@ final class ActionHUDController: HUDInterfaceController { updateForOverrideContext(override?.context) pendingMessageResponses += 1 - var settings = loopManager.settings - let isPreMealEnabled = settings.preMealOverride?.isActive() == true + var watchInfo = loopManager.watchInfo + let isPreMealEnabled = watchInfo.preMealOverride?.isActive() == true if override?.context == .legacyWorkout { - settings.preMealOverride = nil + watchInfo.preMealOverride = nil } - settings.scheduleOverride = override + watchInfo.scheduleOverride = override - let userInfo = LoopSettingsUserInfo(settings: settings) do { - try WCSession.default.sendSettingsUpdateMessage(userInfo, completionHandler: { (result) in + try WCSession.default.sendSettingsUpdateMessage(watchInfo, completionHandler: { (result) in DispatchQueue.main.async { self.pendingMessageResponses -= 1 switch result { case .success(let context): if self.pendingMessageResponses == 0 { - self.loopManager.settings.scheduleOverride = override - self.loopManager.settings.preMealOverride = settings.preMealOverride + self.loopManager.watchInfo.scheduleOverride = override + self.loopManager.watchInfo.preMealOverride = watchInfo.preMealOverride } ExtensionDelegate.shared().loopManager.updateContext(context) diff --git a/WatchApp Extension/Controllers/OverrideSelectionController.swift b/WatchApp Extension/Controllers/OverrideSelectionController.swift index ba79776138..93537cd987 100644 --- a/WatchApp Extension/Controllers/OverrideSelectionController.swift +++ b/WatchApp Extension/Controllers/OverrideSelectionController.swift @@ -23,7 +23,7 @@ final class OverrideSelectionController: WKInterfaceController, IdentifiableClas @IBOutlet private var table: WKInterfaceTable! private let loopManager = ExtensionDelegate.shared().loopManager - private lazy var presets = loopManager.settings.overridePresets + private lazy var presets = loopManager.watchInfo.loopSettings.overridePresets weak var delegate: OverrideSelectionControllerDelegate? diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 1ef1d13d75..946669adf4 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -219,9 +219,9 @@ extension ExtensionDelegate: WCSessionDelegate { switch name { case LoopSettingsUserInfo.name: - if let settings = LoopSettingsUserInfo(rawValue: userInfo)?.settings { + if let loopSettings = LoopSettingsUserInfo(rawValue: userInfo) { DispatchQueue.main.async { - self.loopManager.settings = settings + self.loopManager.watchInfo = loopSettings } } else { log.error("Could not decode LoopSettingsUserInfo: %{public}@", userInfo) diff --git a/WatchApp Extension/Extensions/WCSession.swift b/WatchApp Extension/Extensions/WCSession.swift index 246eff2b2c..6eb309309f 100644 --- a/WatchApp Extension/Extensions/WCSession.swift +++ b/WatchApp Extension/Extensions/WCSession.swift @@ -73,7 +73,7 @@ extension WCSession { ) } - func sendSettingsUpdateMessage(_ userInfo: LoopSettingsUserInfo, completionHandler: @escaping (Result) -> Void) throws { + func sendSettingsUpdateMessage(_ userInfo: LoopSettingsUserInfo, completionHandler: @escaping (Result) -> Void) throws { guard activationState == .activated else { throw MessageError.activation } @@ -159,7 +159,7 @@ extension WCSession { ) } - func sendContextRequestMessage(_ userInfo: WatchContextRequestUserInfo, completionHandler: @escaping (Result) -> Void) throws { + func sendContextRequestMessage(_ userInfo: WatchContextRequestUserInfo, completionHandler: @escaping (Result) -> Void) throws { guard activationState == .activated else { throw MessageError.activation } diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 579b6a2148..1fcbdbd30c 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -1,5 +1,5 @@ // -// LoopDataManager.swift +// LoopDosingManager.swift // WatchApp Extension // // Created by Bharat Mediratta on 6/21/18. @@ -20,14 +20,14 @@ class LoopDataManager { let glucoseStore: GlucoseStore @PersistedProperty(key: "Settings") - private var rawSettings: LoopSettings.RawValue? + private var rawWatchInfo: LoopSettingsUserInfo.RawValue? // Main queue only - var settings: LoopSettings { + var watchInfo: LoopSettingsUserInfo { didSet { needsDidUpdateContextNotification = true sendDidUpdateContextNotificationIfNecessary() - rawSettings = settings.rawValue + rawWatchInfo = watchInfo.rawValue } } @@ -40,7 +40,7 @@ class LoopDataManager { } } - private let log = OSLog(category: "LoopDataManager") + private let log = OSLog(category: "LoopDosingManager") // Main queue only private(set) var activeContext: WatchContext? { @@ -67,19 +67,21 @@ class LoopDataManager { cacheStore: cacheStore, cacheLength: .hours(24), // Require 24 hours to store recent carbs "since midnight" for CarbEntryListController defaultAbsorptionTimes: LoopCoreConstants.defaultCarbAbsorptionTimes, - syncVersion: 0, - provenanceIdentifier: HKSource.default().bundleIdentifier + syncVersion: 0 ) glucoseStore = GlucoseStore( cacheStore: cacheStore, - cacheLength: .hours(4), - provenanceIdentifier: HKSource.default().bundleIdentifier + cacheLength: .hours(4) ) - settings = LoopSettings() + self.watchInfo = LoopSettingsUserInfo( + loopSettings: LoopSettings(), + scheduleOverride: nil, + preMealOverride: nil + ) - if let rawSettings = rawSettings, let storedSettings = LoopSettings(rawValue: rawSettings) { - self.settings = storedSettings + if let rawWatchInfo = rawWatchInfo, let watchInfo = LoopSettingsUserInfo(rawValue: rawWatchInfo) { + self.watchInfo = watchInfo } } } @@ -207,9 +209,9 @@ extension LoopDataManager { } let chartData = GlucoseChartData( unit: activeContext.displayGlucoseUnit, - correctionRange: self.settings.glucoseTargetRangeSchedule, - preMealOverride: self.settings.preMealOverride, - scheduleOverride: self.settings.scheduleOverride, + correctionRange: self.watchInfo.loopSettings.glucoseTargetRangeSchedule, + preMealOverride: self.watchInfo.preMealOverride, + scheduleOverride: self.watchInfo.scheduleOverride, historicalGlucose: historicalGlucose, predictedGlucose: (activeContext.isClosedLoop ?? false) ? activeContext.predictedGlucose?.values : nil ) diff --git a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift index 396da33e6b..8241fab62a 100644 --- a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift +++ b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift @@ -59,7 +59,7 @@ final class CarbAndBolusFlowViewModel: ObservableObject { self._bolusPickerValues = Published( initialValue: BolusPickerValues( supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, - maxBolus: loopManager.settings.maximumBolus ?? Self.defaultMaxBolus + maxBolus: loopManager.watchInfo.loopSettings.maximumBolus ?? Self.defaultMaxBolus ) ) @@ -80,7 +80,7 @@ final class CarbAndBolusFlowViewModel: ObservableObject { self.bolusPickerValues = BolusPickerValues( supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, - maxBolus: loopManager.settings.maximumBolus ?? Self.defaultMaxBolus + maxBolus: loopManager.watchInfo.loopSettings.maximumBolus ?? Self.defaultMaxBolus ) switch self.configuration {