diff --git a/Common/Extensions/GlucoseRangeSchedule.swift b/Common/Extensions/GlucoseRangeSchedule.swift index c8ef74fedb..ccc75b0b8c 100644 --- a/Common/Extensions/GlucoseRangeSchedule.swift +++ b/Common/Extensions/GlucoseRangeSchedule.swift @@ -13,6 +13,9 @@ extension GlucoseRangeSchedule { func minQuantity(at date: Date) -> HKQuantity { return HKQuantity(unit: unit, doubleValue: value(at: date).minValue) } + func maxQuantity(at date: Date) -> HKQuantity { + return HKQuantity(unit: unit, doubleValue: value(at: date).maxValue) + } } diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 3ed4b4a4eb..197eed6d49 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -39,6 +39,7 @@ struct FeatureFlagConfiguration: Decodable { let adultChildInsulinModelSelectionEnabled: Bool let profileExpirationSettingsViewEnabled: Bool let missedMealNotifications: Bool + let allowAlgorithmExperiments: Bool fileprivate init() { @@ -229,6 +230,11 @@ struct FeatureFlagConfiguration: Decodable { self.missedMealNotifications = true #endif + #if ALLOW_ALGORITHM_EXPERIMENTS + self.allowAlgorithmExperiments = true + #else + self.allowAlgorithmExperiments = false + #endif } } @@ -263,7 +269,8 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* dynamicCarbAbsorptionEnabled: \(dynamicCarbAbsorptionEnabled)", "* adultChildInsulinModelSelectionEnabled: \(adultChildInsulinModelSelectionEnabled)", "* profileExpirationSettingsViewEnabled: \(profileExpirationSettingsViewEnabled)", - "* missedMealNotifications: \(missedMealNotifications)" + "* missedMealNotifications: \(missedMealNotifications)", + "* allowAlgorithmExperiments: \(allowAlgorithmExperiments)" ].joined(separator: "\n") } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index d9e4871882..84547bfac4 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -331,6 +331,7 @@ 89F9119424358E4500ECCAF3 /* CarbAbsorptionTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */; }; 89F9119624358E6900ECCAF3 /* BolusPickerValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9119524358E6900ECCAF3 /* BolusPickerValues.swift */; }; 89FE21AD24AC57E30033F501 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FE21AC24AC57E30033F501 /* Collection.swift */; }; + 9E1B653A26F7D14E00D5BD24 /* IntegralRetrospectiveCorrection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1B653926F7D14E00D5BD24 /* IntegralRetrospectiveCorrection.swift */; }; A90EF53C25DEF06200F32D61 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; A90EF54425DEF0A000F32D61 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91D2A3E26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift */; }; @@ -518,6 +519,12 @@ C1FB428D21791D2500FAB378 /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; }; C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; C1FB4290217922A100FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; + DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */; }; + DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */; }; + DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */; }; + 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 */; }; @@ -1316,6 +1323,7 @@ 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAbsorptionTime.swift; sourceTree = ""; }; 89F9119524358E6900ECCAF3 /* BolusPickerValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusPickerValues.swift; sourceTree = ""; }; 89FE21AC24AC57E30033F501 /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; + 9E1B653926F7D14E00D5BD24 /* IntegralRetrospectiveCorrection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrection.swift; sourceTree = ""; }; A900531A28D60862000BC15B /* Loop.shortcut */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop.shortcut; sourceTree = ""; }; A900531B28D608CA000BC15B /* Cancel Override.shortcut */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Cancel Override.shortcut"; sourceTree = ""; }; A900531C28D6090D000BC15B /* Loop Remote Overrides.shortcut */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Loop Remote Overrides.shortcut"; sourceTree = ""; }; @@ -1687,6 +1695,12 @@ C1FF3D4B29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; C1FF3D4C29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; C1FF3D4D29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; + DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrectionSelectionView.swift; sourceTree = ""; }; + DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationFactorStrategy.swift; sourceTree = ""; }; + DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorStrategy.swift; sourceTree = ""; }; + 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 = ""; }; @@ -2030,6 +2044,7 @@ children = ( 43511CDF21FD80E400566C63 /* RetrospectiveCorrection.swift */, 43511CE021FD80E400566C63 /* StandardRetrospectiveCorrection.swift */, + 9E1B653926F7D14E00D5BD24 /* IntegralRetrospectiveCorrection.swift */, ); path = RetrospectiveCorrection; sourceTree = ""; @@ -2037,24 +2052,27 @@ 43757D131C06F26C00910CB9 /* Models */ = { isa = PBXGroup; children = ( - A99A114029A581D6007919CE /* Remote */, - 43511CDD21FD80AD00566C63 /* RetrospectiveCorrection */, - A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, - C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */, + DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */, + A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, + DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */, + C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */, + DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */, B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */, 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */, + C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */, 436A0DA41D236A2A00104B24 /* LoopError.swift */, + E9C00EF424C623EF00628F35 /* LoopSettings+Loop.swift */, A9B996EF27235191002DC09C /* LoopWarning.swift */, + C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */, 4F526D601DF8D9A900A04910 /* NetBasal.swift */, 438D42F81D7C88BC003244B0 /* PredictionInputEffect.swift */, - 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, - E9C00EF424C623EF00628F35 /* LoopSettings+Loop.swift */, - A987CD4824A58A0100439ADC /* ZipArchive.swift */, + A99A114029A581D6007919CE /* Remote */, + 43511CDD21FD80AD00566C63 /* RetrospectiveCorrection */, C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */, C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */, - C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */, - C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */, + 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, + A987CD4824A58A0100439ADC /* ZipArchive.swift */, ); path = Models; sourceTree = ""; @@ -2396,10 +2414,13 @@ 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */, 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, + DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */, 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */, C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, + DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, + DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, ); path = Views; sourceTree = ""; @@ -3863,6 +3884,7 @@ A9CBE45A248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift in Sources */, A9C62D8A2331703100535612 /* ServicesManager.swift in Sources */, 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */, + 9E1B653A26F7D14E00D5BD24 /* IntegralRetrospectiveCorrection.swift in Sources */, A9347F2F24E7508A00C99C34 /* WatchHistoricalCarbs.swift in Sources */, A9B996F027235191002DC09C /* LoopWarning.swift in Sources */, C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */, @@ -3872,6 +3894,7 @@ C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */, + DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */, 892D7C5123B54A15008A9656 /* CarbEntryViewController.swift in Sources */, B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, @@ -3896,6 +3919,7 @@ C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */, 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, + DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, A9347F3124E7521800C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */, A9CBE458248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift in Sources */, 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */, @@ -3903,6 +3927,7 @@ 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */, A9C62D882331703100535612 /* Service.swift in Sources */, 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */, + DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */, 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */, A9F703732489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift in Sources */, 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */, @@ -3933,6 +3958,7 @@ E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, + DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, @@ -3967,6 +3993,7 @@ A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */, + DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */, 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */, 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */, A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */, @@ -3974,6 +4001,7 @@ 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, + DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index ffc66ee314..e4d6dadeb5 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1698,7 +1698,23 @@ extension LoopDataManager { return self.delegate?.roundBolusVolume(units: units) ?? units } - let maxAutomaticBolus = min(iobHeadroom, maxBolus! * LoopConstants.bolusPartialApplicationFactor) + // 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!, @@ -1708,7 +1724,7 @@ extension LoopDataManager { model: doseStore.insulinModelProvider.model(for: pumpInsulinType), basalRates: basalRateSchedule!, maxAutomaticBolus: maxAutomaticBolus, - partialApplicationFactor: LoopConstants.bolusPartialApplicationFactor * self.timeBasedDoseApplicationFactor, + partialApplicationFactor: effectiveBolusApplicationFactor * self.timeBasedDoseApplicationFactor, lastTempBasal: lastTempBasal, volumeRounder: volumeRounder, rateRounder: rateRounder, @@ -2104,9 +2120,11 @@ extension LoopDataManager { }), "]", + "integralRetrospectiveCorrectionEnabled: \(UserDefaults.standard.integralRetrospectiveCorrectionEnabled)", + "retrospectiveGlucoseDiscrepancies: [", "* GlucoseEffect(start, mg/dL)", - (state.retrospectiveGlucoseDiscrepancies ?? []).reduce(into: "", { (entries, entry) in + (manager.retrospectiveGlucoseDiscrepancies ?? []).reduce(into: "", { (entries, entry) in entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") }), "]", @@ -2128,6 +2146,7 @@ extension LoopDataManager { "insulinOnBoard: \(String(describing: manager.insulinOnBoard))", "error: \(String(describing: state.error))", "overrideInUserDefaults: \(String(describing: UserDefaults.appGroup?.intentExtensionOverrideToSet))", + "glucoseBasedApplicationFactorEnabled: \(UserDefaults.standard.glucoseBasedApplicationFactorEnabled)", "", String(reflecting: self.retrospectiveCorrection), "", diff --git a/Loop/Models/ApplicationFactorStrategy.swift b/Loop/Models/ApplicationFactorStrategy.swift new file mode 100644 index 0000000000..bf67935c4e --- /dev/null +++ b/Loop/Models/ApplicationFactorStrategy.swift @@ -0,0 +1,20 @@ +// +// ApplicationFactorStrategy.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-03. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopCore + +protocol ApplicationFactorStrategy { + func calculateDosingFactor( + for glucose: HKQuantity, + correctionRangeSchedule: GlucoseRangeSchedule, + settings: LoopSettings + ) -> Double +} diff --git a/Loop/Models/ConstantApplicationFactorStrategy.swift b/Loop/Models/ConstantApplicationFactorStrategy.swift new file mode 100644 index 0000000000..e13c40c42e --- /dev/null +++ b/Loop/Models/ConstantApplicationFactorStrategy.swift @@ -0,0 +1,23 @@ +// +// ConstantDosingStrategy.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-03. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopCore + +struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy { + func calculateDosingFactor( + for glucose: HKQuantity, + correctionRangeSchedule: GlucoseRangeSchedule, + settings: LoopSettings + ) -> Double { + // The original strategy uses a constant dosing factor. + return LoopConstants.bolusPartialApplicationFactor + } +} diff --git a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift new file mode 100644 index 0000000000..41caa3d773 --- /dev/null +++ b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift @@ -0,0 +1,42 @@ +// +// GlucoseBasedApplicationFactorStrategy.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-03. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopCore + +struct GlucoseBasedApplicationFactorStrategy: ApplicationFactorStrategy { + static let minPartialApplicationFactor = 0.20 // min fraction of correction when glucose > minGlucoseSlidingScale + static let maxPartialApplicationFactor = 0.80 // max fraction of correction when glucose > maxGlucoseSlidingScale + // set minGlucoseSlidingScale based on user setting for correction range + // use mg/dL for calculations + static let minGlucoseDeltaSlidingScale = 10.0 // mg/dL + static let maxGlucoseSlidingScale = 200.0 // mg/dL + + func calculateDosingFactor( + for glucose: HKQuantity, + correctionRangeSchedule: GlucoseRangeSchedule, + settings: LoopSettings + ) -> 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 + let minGlucoseSlidingScale = GlucoseBasedApplicationFactorStrategy.minGlucoseDeltaSlidingScale + lowerBoundTarget + let scalingFraction = (GlucoseBasedApplicationFactorStrategy.maxPartialApplicationFactor - GlucoseBasedApplicationFactorStrategy.minPartialApplicationFactor) / (GlucoseBasedApplicationFactorStrategy.maxGlucoseSlidingScale - minGlucoseSlidingScale) + let scalingGlucose = max(currentGlucose - minGlucoseSlidingScale, 0.0) + + // Calculate effectiveBolusApplicationFactor + let effectiveBolusApplicationFactor = min(GlucoseBasedApplicationFactorStrategy.minPartialApplicationFactor + scalingGlucose * scalingFraction, GlucoseBasedApplicationFactorStrategy.maxPartialApplicationFactor) + + return effectiveBolusApplicationFactor + } +} diff --git a/Loop/Models/LoopSettings+Loop.swift b/Loop/Models/LoopSettings+Loop.swift index ce7433a945..ff6f4d2f74 100644 --- a/Loop/Models/LoopSettings+Loop.swift +++ b/Loop/Models/LoopSettings+Loop.swift @@ -19,9 +19,20 @@ extension LoopSettings { } static let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) - - /// Creates an instance of the enabled retrospective correction implementation + + /// Creates an instance of the enabled retrospective correction implementation var enabledRetrospectiveCorrectionAlgorithm: RetrospectiveCorrection { - return StandardRetrospectiveCorrection(effectDuration: LoopSettings.retrospectiveCorrectionEffectDuration) + var enabledRetrospectiveCorrectionAlgorithm: RetrospectiveCorrection + + let isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + + if isIntegralRetrospectiveCorrectionEnabled { + enabledRetrospectiveCorrectionAlgorithm = IntegralRetrospectiveCorrection(effectDuration: LoopSettings.retrospectiveCorrectionEffectDuration) + } else { + enabledRetrospectiveCorrectionAlgorithm = StandardRetrospectiveCorrection(effectDuration: LoopSettings.retrospectiveCorrectionEffectDuration) + } + + return enabledRetrospectiveCorrectionAlgorithm } + } diff --git a/Loop/Models/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift b/Loop/Models/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift new file mode 100644 index 0000000000..e6ece1c7a5 --- /dev/null +++ b/Loop/Models/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift @@ -0,0 +1,226 @@ +// +// IntegralRetrospectiveCorrection.swift +// Loop +// +// Created by Dragan Maksimovic on 9/19/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopCore + +/** + Integral Retrospective Correction (IRC) calculates a correction effect in glucose prediction based on a timeline of past discrepancies between observed glucose movement and movement expected based on insulin and carb models. Integral retrospective correction acts as a proportional-integral-differential (PID) controller aimed at reducing modeling errors in glucose prediction. + + In the above summary, "discrepancy" is a difference between the actual glucose and the model predicted glucose over retrospective correction grouping interval (set to 30 min in LoopSettings), whereas "past discrepancies" refers to a timeline of discrepancies computed over retrospective correction integration interval (set to 180 min in Loop Settings). + + */ +class IntegralRetrospectiveCorrection: RetrospectiveCorrection { + let retrospectionInterval = TimeInterval(minutes: 180) + + /// RetrospectiveCorrection protocol variables + /// Standard effect duration + let effectDuration: TimeInterval + /// Overall retrospective correction effect + var totalGlucoseCorrectionEffect: HKQuantity? + + /** + Integral retrospective correction parameters: + - currentDiscrepancyGain: Standard retrospective correction gain + - persistentDiscrepancyGain: Gain for persistent long-term modeling errors, must be greater than or equal to currentDiscrepancyGain + - correctionTimeConstant: How fast integral effect accumulates in response to persistent errors + - differentialGain: Differential effect gain + - delta: Glucose sampling time interval (5 min) + - maximumCorrectionEffectDuration: Maximum duration of the correction effect in glucose prediction + - retrospectiveCorrectionIntegrationInterval: Maximum duration over which to integrate retrospective correction changes + */ + static let currentDiscrepancyGain: Double = 1.0 + static let persistentDiscrepancyGain: Double = 2.0 // was 5.0 + static let correctionTimeConstant: TimeInterval = TimeInterval(minutes: 60.0) // was 90.0 + static let differentialGain: Double = 2.0 + static let delta: TimeInterval = TimeInterval(minutes: 5.0) + static let maximumCorrectionEffectDuration: TimeInterval = TimeInterval(minutes: 180.0) // was 240.0 + + /// Initialize computed integral retrospective correction parameters + static let integralForget: Double = exp( -delta.minutes / correctionTimeConstant.minutes ) + static let integralGain: Double = ((1 - integralForget) / integralForget) * + (persistentDiscrepancyGain - currentDiscrepancyGain) + static let proportionalGain: Double = currentDiscrepancyGain - integralGain + + /// All math is performed with glucose expressed in mg/dL + private let unit = HKUnit.milligramsPerDeciliter + + /// State variables reported in diagnostic issue report + var recentDiscrepancyValues: [Double] = [] + var integralCorrectionEffectDuration: TimeInterval? + var proportionalCorrection: Double = 0.0 + var integralCorrection: Double = 0.0 + var differentialCorrection: Double = 0.0 + var currentDate: Date = Date() + var ircStatus: String = "-" + + /** + Initialize integral retrospective correction settings based on current values of user settings + + - Parameters: + - settings: User settings + - insulinSensitivity: User insulin sensitivity schedule + - basalRates: User basal rate schedule + + - Returns: Integral Retrospective Correction customized with controller parameters and user settings + */ + init(effectDuration: TimeInterval) { + self.effectDuration = effectDuration + } + + /** + Calculates overall correction effect based on timeline of discrepancies, and updates glucoseCorrectionEffect + + - Parameters: + - glucose: Most recent glucose + - retrospectiveGlucoseDiscrepanciesSummed: Timeline of past discepancies + + - Returns: + - totalRetrospectiveCorrection: Overall glucose effect + */ + func computeEffect( + startingAt startingGlucose: GlucoseValue, + retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, + recencyInterval: TimeInterval, + insulinSensitivitySchedule: InsulinSensitivitySchedule?, + basalRateSchedule: BasalRateSchedule?, + glucoseCorrectionRangeSchedule: GlucoseRangeSchedule?, + retrospectiveCorrectionGroupingInterval: TimeInterval + ) -> [GlucoseEffect] { + + // Loop settings relevant for calculation of effect limits + // let settings = UserDefaults.appGroup?.loopSettings ?? LoopSettings() + currentDate = Date() + + // Last discrepancy should be recent, otherwise clear the effect and return + let glucoseDate = startingGlucose.startDate + var glucoseCorrectionEffect: [GlucoseEffect] = [] + guard let currentDiscrepancy = retrospectiveGlucoseDiscrepanciesSummed?.last, + glucoseDate.timeIntervalSince(currentDiscrepancy.endDate) <= recencyInterval + else { + ircStatus = "discrepancy not available, effect not computed." + totalGlucoseCorrectionEffect = nil + return( [] ) + } + + // Default values if we are not able to calculate integral retrospective correction + ircStatus = "defaulted to standard RC, past discrepancies or user settings not available." + let currentDiscrepancyValue = currentDiscrepancy.quantity.doubleValue(for: unit) + var scaledCorrection = currentDiscrepancyValue + totalGlucoseCorrectionEffect = HKQuantity(unit: unit, doubleValue: currentDiscrepancyValue) + integralCorrectionEffectDuration = effectDuration + + // Calculate integral retrospective correction if past discrepancies over integration interval are available and if user settings are available + if let pastDiscrepancies = retrospectiveGlucoseDiscrepanciesSummed?.filterDateRange(glucoseDate.addingTimeInterval(-retrospectionInterval), glucoseDate), + let sensitivity = insulinSensitivitySchedule, + let basals = basalRateSchedule, + let glucoseCorrectionRangeSchedule = glucoseCorrectionRangeSchedule { + + ircStatus = "effect computed successfully." + + // To reduce response delay, integral retrospective correction is computed over an array of recent contiguous discrepancy values having the same sign as the latest discrepancy value + recentDiscrepancyValues = [] + var nextDiscrepancy = currentDiscrepancy + let currentDiscrepancySign = currentDiscrepancy.quantity.doubleValue(for: unit).sign + for pastDiscrepancy in pastDiscrepancies.reversed() { + let pastDiscrepancyValue = pastDiscrepancy.quantity.doubleValue(for: unit) + if (pastDiscrepancyValue.sign == currentDiscrepancySign && + nextDiscrepancy.endDate.timeIntervalSince(pastDiscrepancy.endDate) + <= recencyInterval && abs(pastDiscrepancyValue) >= 0.1) + { + recentDiscrepancyValues.append(pastDiscrepancyValue) + nextDiscrepancy = pastDiscrepancy + } else { + break + } + } + recentDiscrepancyValues = recentDiscrepancyValues.reversed() + + let currentSensitivity = sensitivity.quantity(at: glucoseDate).doubleValue(for: unit) + let currentBasalRate = basals.value(at: glucoseDate) + let correctionRangeMin = glucoseCorrectionRangeSchedule.minQuantity(at: glucoseDate).doubleValue(for: unit) + let correctionRangeMax = glucoseCorrectionRangeSchedule.maxQuantity(at: glucoseDate).doubleValue(for: unit) + let latestGlucoseValue = startingGlucose.quantity.doubleValue(for: unit) // most recent glucose + + // Safety limit for (+) integral effect. The limit is set to a larger value if the current blood glucose is further away from the correction range because we have more time available for corrections + let glucoseError = latestGlucoseValue - correctionRangeMax + let zeroTempEffect = abs(currentSensitivity * currentBasalRate) + let integralEffectPositiveLimit = min(max(glucoseError, 1.0 * zeroTempEffect), 4.0 * zeroTempEffect) + + // Limit for (-) integral effect: glucose prediction reduced by no more than 10 mg/dL below the correction range minimum + let integralEffectNegativeLimit = -max(10.0, latestGlucoseValue - correctionRangeMin) + + // Integral effect math + integralCorrection = 0.0 + var integralCorrectionEffectMinutes = effectDuration.minutes - 2.0 * IntegralRetrospectiveCorrection.delta.minutes + for discrepancy in recentDiscrepancyValues { + integralCorrection = + IntegralRetrospectiveCorrection.integralForget * integralCorrection + + IntegralRetrospectiveCorrection.integralGain * discrepancy + integralCorrectionEffectMinutes += 2.0 * IntegralRetrospectiveCorrection.delta.minutes + } + // Limits applied to integral correction effect and effect duration + integralCorrection = min(max(integralCorrection, integralEffectNegativeLimit), integralEffectPositiveLimit) + integralCorrectionEffectMinutes = min(integralCorrectionEffectMinutes, IntegralRetrospectiveCorrection.maximumCorrectionEffectDuration.minutes) + + // Differential effect math + var differentialDiscrepancy: Double = 0.0 + if recentDiscrepancyValues.count > 1 { + let previousDiscrepancyValue = recentDiscrepancyValues[recentDiscrepancyValues.count - 2] + differentialDiscrepancy = currentDiscrepancyValue - previousDiscrepancyValue + } + + // Overall glucose effect calculated as a sum of propotional, integral and differential effects + proportionalCorrection = IntegralRetrospectiveCorrection.proportionalGain * currentDiscrepancyValue + differentialCorrection = IntegralRetrospectiveCorrection.differentialGain * differentialDiscrepancy + let totalCorrection = proportionalCorrection + integralCorrection + differentialCorrection + totalGlucoseCorrectionEffect = HKQuantity(unit: unit, doubleValue: totalCorrection) + integralCorrectionEffectDuration = TimeInterval(minutes: integralCorrectionEffectMinutes) + + // correction value scaled to account for extended effect duration + scaledCorrection = totalCorrection * effectDuration.minutes / integralCorrectionEffectDuration!.minutes + } + + let retrospectionTimeInterval = currentDiscrepancy.endDate.timeIntervalSince(currentDiscrepancy.startDate) + let discrepancyTime = max(retrospectionTimeInterval, retrospectiveCorrectionGroupingInterval) + let velocity = HKQuantity(unit: unit.unitDivided(by: .second()), doubleValue: scaledCorrection / discrepancyTime) + + // Update array of glucose correction effects + glucoseCorrectionEffect = startingGlucose.decayEffect(atRate: velocity, for: integralCorrectionEffectDuration!) + + // Return glucose correction effects + return( glucoseCorrectionEffect ) + } + + var debugDescription: String { + let report: [String] = [ + "## IntegralRetrospectiveCorrection", + "", + "Last updated: \(currentDate)", + "Status: \(ircStatus)", + "currentDiscrepancyGain: \(IntegralRetrospectiveCorrection.currentDiscrepancyGain)", + "persistentDiscrepancyGain: \(IntegralRetrospectiveCorrection.persistentDiscrepancyGain)", + "correctionTimeConstant [min]: \(IntegralRetrospectiveCorrection.correctionTimeConstant.minutes)", + "proportionalGain: \(IntegralRetrospectiveCorrection.proportionalGain)", + "integralForget: \(IntegralRetrospectiveCorrection.integralForget)", + "integralGain: \(IntegralRetrospectiveCorrection.integralGain)", + "differentialGain: \(IntegralRetrospectiveCorrection.differentialGain)", + "Integration performed over \(recentDiscrepancyValues.count) most recent discrepancies having the same sign as the latest discrepancy value. Earliest-to-most-recent recentDiscrepancyValues [mg/dL]: \(recentDiscrepancyValues)", + "proportionalCorrection [mg/dL]: \(proportionalCorrection)", + "integralCorrection [mg/dL]: \(integralCorrection)", + "differentialCorrection [mg/dL]: \(differentialCorrection)", + "totalGlucoseCorrectionEffect: \(String(describing: totalGlucoseCorrectionEffect))", + "integralCorrectionEffectDuration [min]: \(String(describing: integralCorrectionEffectDuration?.minutes))" + ] + + return report.joined(separator: "\n") + } + +} diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index 35329d80b9..3c27213787 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -275,9 +275,26 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable format: NSLocalizedString("Predicted: %1$@\nActual: %2$@ (%3$@)", comment: "Format string describing retrospective glucose prediction comparison. (1: Predicted glucose)(2: Actual glucose)(3: difference)"), values[0], values[1], values[2] ) - - // Standard retrospective correction - subtitleText = String(format: "%@\n%@", subtitleText, retro) + let isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + + if isIntegralRetrospectiveCorrectionEnabled { + var integralEffectDisplay = "?" + var totalEffectDisplay = "?" + if let totalEffect = self.totalRetrospectiveCorrection { + let integralEffectValue = totalEffect.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit) + let integralEffect = HKQuantity(unit: glucoseChart.glucoseUnit, doubleValue: integralEffectValue) + integralEffectDisplay = formatter.string(from: integralEffect) ?? "?" + totalEffectDisplay = formatter.string(from: totalEffect) ?? "?" + } + let integralRetro = String( + format: NSLocalizedString("prediction-description-integral-retrospective-correction", comment: "Format string describing integral retrospective correction. (1: Integral glucose effect)(2: Total glucose effect)"), + integralEffectDisplay, totalEffectDisplay + ) + subtitleText = String(format: "%@\n%@", retro, integralRetro) + } else { + subtitleText = String(format: "%@\n%@", subtitleText, retro) + } + } cell.subtitleLabel?.text = subtitleText diff --git a/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift b/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift new file mode 100644 index 0000000000..09a68d58c1 --- /dev/null +++ b/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift @@ -0,0 +1,61 @@ +// +// GlucoseBasedApplicationFactorSelectionView.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-04. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +public struct GlucoseBasedApplicationFactorSelectionView: View { + @Binding var isGlucoseBasedApplicationFactorEnabled: Bool + var automaticDosingStrategy: AutomaticDosingStrategy + + public init(isGlucoseBasedApplicationFactorEnabled: Binding, automaticDosingStrategy: AutomaticDosingStrategy) { + self.automaticDosingStrategy = automaticDosingStrategy + self._isGlucoseBasedApplicationFactorEnabled = isGlucoseBasedApplicationFactorEnabled + } + + public var body: some View { + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Glucose Based Partial Application", comment: "Title for glucose based partial application experiment description")) + .font(.headline) + .padding(.bottom, 20) + + Divider() + + if automaticDosingStrategy == .automaticBolus { + Text(NSLocalizedString("Loop normally gives 40% of your predicted insulin needs each dosing cycle.\n\nWhen the Glucose Based Partial Application experiment is enabled, Loop will vary the percentage of recommended bolus delivered each cycle with glucose level.\n\nNear correction range, it will use 20% (similar to Temp Basal), and gradually increase to a maximum of 80% at high glucose (200 mg/dL, 11.1 mmol/L).\n\nPlease be aware that during fast rising glucose, such as after an unannounced meal, this feature, combined with velocity and retrospective correction effects, may result in a larger dose than your ISF would call for.", comment: "Description of Glucose Based Partial Application toggle.")) + .foregroundColor(.secondary) + Divider() + + HStack { + Toggle(NSLocalizedString("Enable Glucose Based Partial Application", comment: "Title for Glucose Based Partial Application toggle"), isOn: $isGlucoseBasedApplicationFactorEnabled) + Spacer() + } + .padding(.top, 20) + } else { + Text(NSLocalizedString("This option only applies when Loop's Dosing Strategy is set to Automatic Bolus.", comment: "String shown when glucose based partial application cannot be enabled because dosing strategy is not set to Automatic Bolus")) + } + } + .padding() + .onChange(of: isGlucoseBasedApplicationFactorEnabled) { newValue in + UserDefaults.standard.glucoseBasedApplicationFactorEnabled = newValue + } + } + .navigationBarTitleDisplayMode(.inline) + } +} + +struct GlucoseBasedApplicationFactorSelectionView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + GlucoseBasedApplicationFactorSelectionView(isGlucoseBasedApplicationFactorEnabled: .constant(true), automaticDosingStrategy: .automaticBolus) + } + } +} diff --git a/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift b/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift new file mode 100644 index 0000000000..dfeb37d563 --- /dev/null +++ b/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift @@ -0,0 +1,46 @@ +// +// IntegralRetrospectiveCorrectionSelectionView.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-04. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +public struct IntegralRetrospectiveCorrectionSelectionView: View { + @Binding var isIntegralRetrospectiveCorrectionEnabled: Bool + + public var body: some View { + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Integral Retrospective Correction", comment: "Title for integral retrospective correction experiment description")) + .font(.headline) + .padding(.bottom, 20) + + Divider() + + Text(NSLocalizedString("Integral Retrospective Correction (IRC) is an extension of the standard Retrospective Correction (RC) algorithm component in Loop, which adjusts the forecast based on the history of discrepancies between predicted and actual glucose levels.\n\nIn contrast to RC, which looks at discrepancies over the last 30 minute, with IRC, the history of discrepancies adds up over time. So continued positive discrepencies over time will result in increased dosing. If the discrepancies are negative over time, Loop will reduce dosing further.", comment: "Description of Integral Retrospective Correction toggle.")) + .foregroundColor(.secondary) + Divider() + + Toggle(NSLocalizedString("Enable Integral Retrospective Correction", comment: "Title for Integral Retrospective Correction toggle"), isOn: $isIntegralRetrospectiveCorrectionEnabled) + .onChange(of: isIntegralRetrospectiveCorrectionEnabled) { newValue in + UserDefaults.standard.integralRetrospectiveCorrectionEnabled = newValue + } + .padding(.top, 20) + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } + +} + +struct IntegralRetrospectiveCorrectionSelectionView_Previews: PreviewProvider { + static var previews: some View { + IntegralRetrospectiveCorrectionSelectionView(isIntegralRetrospectiveCorrectionEnabled: .constant(true)) + } +} diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift new file mode 100644 index 0000000000..a5b787e1d1 --- /dev/null +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -0,0 +1,104 @@ +// +// SettingsView+algorithmExperimentsSection.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-03. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +extension SettingsView { + internal var algorithmExperimentsSection: some View { + NavigationLink(NSLocalizedString("Algorithm Experiments", comment: "The title of the Algorithm Experiments section in settings")) { + ExperimentsSettingsView(automaticDosingStrategy: viewModel.automaticDosingStrategy) + } + } +} + +public struct ExperimentRow: View { + var name: String + var enabled: Bool + + public var body: some View { + HStack { + Text(name) + .foregroundColor(.primary) + Spacer() + Text(enabled ? "On" : "Off") + .foregroundColor(enabled ? .red : .secondary) + } + .padding() + .background(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .cornerRadius(10) + } +} + +public struct ExperimentsSettingsView: View { + @State private var isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + @State private var isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + var automaticDosingStrategy: AutomaticDosingStrategy + + public var body: some View { + VStack(alignment: .center, spacing: 12) { + Text(NSLocalizedString("Algorithm Experiments", comment: "Navigation title for algorithms experiments screen")) + .font(.headline) + VStack { + Text("⚠️").font(.largeTitle) + Text("Caution") + } + Divider() + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("Algorithm Experiments are optional modifications to the Loop Algorithm. These modifications are less tested than the standard Loop algorithm, so please use carefully.", comment: "Algorithm Experiments description.")) + Text(NSLocalizedString("In future versions of Loop these experiments may change, end up as standard parts of the Loop Algorithm, or be removed from Loop entirely. Please follow along in the Loop Zulip chat to stay informed of possible changes to these features.", comment: "Algorithm Experiments description second paragraph.")) + } + .foregroundColor(.secondary) + + Divider() + NavigationLink(destination: GlucoseBasedApplicationFactorSelectionView(isGlucoseBasedApplicationFactorEnabled: $isGlucoseBasedApplicationFactorEnabled, automaticDosingStrategy: automaticDosingStrategy)) { + ExperimentRow( + name: NSLocalizedString("Glucose Based Partial Application", comment: "Title of glucose based partial application experiment"), + enabled: isGlucoseBasedApplicationFactorEnabled && automaticDosingStrategy == .automaticBolus) + } + NavigationLink(destination: IntegralRetrospectiveCorrectionSelectionView(isIntegralRetrospectiveCorrectionEnabled: $isIntegralRetrospectiveCorrectionEnabled)) { + ExperimentRow( + name: NSLocalizedString("Integral Retrospective Correction", comment: "Title of integral retrospective correction experiment"), + enabled: isIntegralRetrospectiveCorrectionEnabled) + } + Spacer() + } + .padding() + .navigationBarTitleDisplayMode(.inline) + } +} + + +extension UserDefaults { + private enum Key: String { + case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" + case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" + } + + var glucoseBasedApplicationFactorEnabled: Bool { + get { + bool(forKey: Key.GlucoseBasedApplicationFactorEnabled.rawValue) as Bool + } + set { + set(newValue, forKey: Key.GlucoseBasedApplicationFactorEnabled.rawValue) + } + } + + var integralRetrospectiveCorrectionEnabled: Bool { + get { + bool(forKey: Key.IntegralRetrospectiveCorrectionEnabled.rawValue) as Bool + } + set { + set(newValue, forKey: Key.IntegralRetrospectiveCorrectionEnabled.rawValue) + } + } + +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index bd4e86e8de..afdc404fcc 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -30,7 +30,7 @@ public struct SettingsView: View { @State private var therapySettingsIsPresented: Bool = false @State private var deletePumpDataAlertIsPresented = false @State private var deleteCGMDataAlertIsPresented = false - + var localizedAppNameAndVersion: String public init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { @@ -224,6 +224,10 @@ extension SettingsView { ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view } + + if FeatureFlags.allowAlgorithmExperiments { + algorithmExperimentsSection + } } } diff --git a/Loop/en.lproj/Localizable.strings b/Loop/en.lproj/Localizable.strings index 78907ea3de..54d0ee5724 100644 --- a/Loop/en.lproj/Localizable.strings +++ b/Loop/en.lproj/Localizable.strings @@ -246,6 +246,12 @@ /* The title text for the issue report cell */ "Issue Report" = "Issue Report"; +/* Format string describing retrospective glucose prediction comparison. (1: Predicted glucose)(2: Actual glucose)(3: difference) */ +"prediction-description-retrospective-correction" = "Predicted: %1$@\nActual: %2$@ (%3$@)"; + +/* Format string describing integral retrospective correction. (1: Integral glucose effect)(2: Total glucose effect) */ +"prediction-description-integral-retrospective-correction" = "Integral effect: %1$@\nTotal glucose effect: %2$@"; + /* Glucose HUD accessibility hint */ "Launches CGM app" = "Launches CGM app";