diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index b147fc93e2..299c19910d 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -660,6 +660,7 @@ D6E28676C2C7751C77C7F2FB /* Loop/Resources/Sounds/spring.caf in Resources */ = {isa = PBXBuildFile; fileRef = 9471E156FC10D6D0FF219C77 /* Loop/Resources/Sounds/spring.caf */; }; DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */; }; A1B2C3D42E8F000100000002 /* GuardAndRunwaySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */; }; + 883675B93130EED04D819D0A /* SlowRetrospectiveCorrectionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1622757183D5C361AFB3FB5C /* SlowRetrospectiveCorrectionSelectionView.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 */; }; DE965737FE2DAE9FE692769E /* Loop/Resources/Sounds/alarm.caf in Resources */ = {isa = PBXBuildFile; fileRef = 0FC34B523FE5A50C920506D5 /* Loop/Resources/Sounds/alarm.caf */; }; @@ -1538,6 +1539,7 @@ CE102E23D8C1C1291EF66387 /* PluginPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = ""; }; DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrectionSelectionView.swift; sourceTree = ""; }; A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuardAndRunwaySelectionView.swift; sourceTree = ""; }; + 1622757183D5C361AFB3FB5C /* SlowRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlowRetrospectiveCorrectionSelectionView.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 = ""; }; E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAccessView.swift; sourceTree = ""; }; @@ -2211,6 +2213,7 @@ DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */, + 1622757183D5C361AFB3FB5C /* SlowRetrospectiveCorrectionSelectionView.swift */, 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, 84D1F1A62D09053A00CB271F /* StatusTableView.swift */, B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */, @@ -3774,6 +3777,7 @@ 414E7082E6A6B9DA8CD11EF3 /* CurrentSetupViewModel.swift in Sources */, DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, A1B2C3D42E8F000100000002 /* GuardAndRunwaySelectionView.swift in Sources */, + 883675B93130EED04D819D0A /* SlowRetrospectiveCorrectionSelectionView.swift in Sources */, 842E40A72F22F7E2000CCCE0 /* TintedContent.swift in Sources */, 842E40A92F22F7E2000CCCE0 /* EstimatedReadTime.swift in Sources */, 842E40AA2F22F7E2000CCCE0 /* PresetsTrainingView.swift in Sources */, diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 627aa167c7..24eca6bf1e 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -311,6 +311,7 @@ final class AnalyticsServicesManager { identify("Integral Retrospective Correction", value: state.integralRetrospectiveCorrection ? "On" : "Off") identify("Glucose Based Partial Application", value: state.glucoseBasedPartialApplication ? "On" : "Off") identify("Guard + Runway", value: state.guardAndRunway ? "On" : "Off") + identify("Slow Retrospective Correction", value: state.slowRetrospectiveCorrection ? "On" : "Off") lastState = state } @@ -353,6 +354,9 @@ final class AnalyticsServicesManager { if newState.guardAndRunway != oldState.guardAndRunway { logEvent("Algorithm experiment change", withProperties: ["experiment": "Guard + Runway", "enabled": newState.guardAndRunway]) } + if newState.slowRetrospectiveCorrection != oldState.slowRetrospectiveCorrection { + logEvent("Algorithm experiment change", withProperties: ["experiment": "Slow Retrospective Correction", "enabled": newState.slowRetrospectiveCorrection]) + } } func didDisplayAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel, scheduled: Bool) { @@ -415,16 +419,18 @@ struct AnalyticsState: Codable, Equatable { var integralRetrospectiveCorrection: Bool var glucoseBasedPartialApplication: Bool var guardAndRunway: Bool + var slowRetrospectiveCorrection: Bool - init(closedLoop: Bool, dosingStrategy: String, integralRetrospectiveCorrection: Bool, glucoseBasedPartialApplication: Bool, guardAndRunway: Bool) { + init(closedLoop: Bool, dosingStrategy: String, integralRetrospectiveCorrection: Bool, glucoseBasedPartialApplication: Bool, guardAndRunway: Bool, slowRetrospectiveCorrection: Bool) { self.closedLoop = closedLoop self.dosingStrategy = dosingStrategy self.integralRetrospectiveCorrection = integralRetrospectiveCorrection self.glucoseBasedPartialApplication = glucoseBasedPartialApplication self.guardAndRunway = guardAndRunway + self.slowRetrospectiveCorrection = slowRetrospectiveCorrection } - // Summaries stored before guardAndRunway was added lack it + // Summaries stored before guardAndRunway or slowRetrospectiveCorrection was added lack them init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) closedLoop = try container.decode(Bool.self, forKey: .closedLoop) @@ -432,6 +438,7 @@ struct AnalyticsState: Codable, Equatable { integralRetrospectiveCorrection = try container.decode(Bool.self, forKey: .integralRetrospectiveCorrection) glucoseBasedPartialApplication = try container.decode(Bool.self, forKey: .glucoseBasedPartialApplication) guardAndRunway = try container.decodeIfPresent(Bool.self, forKey: .guardAndRunway) ?? false + slowRetrospectiveCorrection = try container.decodeIfPresent(Bool.self, forKey: .slowRetrospectiveCorrection) ?? false } } @@ -481,6 +488,7 @@ struct AnalyticsDailySummary: Codable, Equatable { properties["integralRetrospectiveCorrection"] = state.integralRetrospectiveCorrection properties["glucoseBasedPartialApplication"] = state.glucoseBasedPartialApplication properties["guardAndRunway"] = state.guardAndRunway + properties["slowRetrospectiveCorrection"] = state.slowRetrospectiveCorrection } properties.merge(usage.properties) { current, _ in current } return properties diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 10d4fcfc12..ea718249d7 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -490,7 +490,8 @@ class LoopAppManager: NSObject { dosingStrategy: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue, integralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, glucoseBasedPartialApplication: UserDefaults.standard.glucoseBasedApplicationFactorEnabled, - guardAndRunway: UserDefaults.standard.guardAndRunwayEnabled + guardAndRunway: UserDefaults.standard.guardAndRunwayEnabled, + slowRetrospectiveCorrection: UserDefaults.standard.slowRetrospectiveCorrectionEnabled ) } analyticsServicesManager.identifyState() diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 115360aa36..8a60a1e113 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -19,6 +19,11 @@ struct AlgorithmDisplayState { var input: StoredDataAlgorithmInput? var output: AlgorithmOutput? + /// The ISF multiplier slow retrospective correction applied on the last run, 1 when it is off. + var sensitivityMultiplier: Double { + output?.slowRetrospectiveCorrection?.multiplier ?? 1 + } + var activeInsulin: InsulinValue? { guard let input, let value = output?.activeInsulin else { return nil @@ -366,8 +371,17 @@ final class LoopDataManager: ObservableObject { ensureCarbCoverageStart: Date? = nil, projectOngoingDoses: Bool = false ) async throws -> StoredDataAlgorithmInput { + let experiments = enabledAlgorithmExperiments + + // Slow retrospective correction keeps no state, so it reads its whole window of glucose on every run, + // with the carbs and doses that explain it. + let slowRetrospectiveCorrectionHistory: TimeInterval? = experiments.contains(.slowRetrospectiveCorrection) ? .hours(72) : nil + // Need to fetch doses back as far as t - (DIA + DCA) for Dynamic carbs - let dosesInputHistory = CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration + var dosesInputHistory = CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration + if let slowRetrospectiveCorrectionHistory { + dosesInputHistory = max(dosesInputHistory, slowRetrospectiveCorrectionHistory + InsulinMath.defaultInsulinActivityDuration) + } let baseTime = baseTime ?? now @@ -417,7 +431,12 @@ final class LoopDataManager: ObservableObject { let forecastEndTime = baseTime.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(GlucoseMath.defaultDelta) - let carbsStart = baseTime.addingTimeInterval(LoopConstants.maxCarbEntryPastTime + .minutes(-1)) // additional minute to handle difference in seconds between carb entry and carb ratio + var glucoseStart = baseTime.addingTimeInterval(LoopConstants.maxCarbEntryPastTime + .minutes(-1)) // additional minute to handle difference in seconds between carb entry and carb ratio + var carbsStart = glucoseStart + if let slowRetrospectiveCorrectionHistory { + glucoseStart = baseTime.addingTimeInterval(-slowRetrospectiveCorrectionHistory) + carbsStart = glucoseStart.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval + .minutes(-1)) + } // A pending carb entry can outlive this rolling window while the bolus screen is open. // Cover it with schedule history without expanding the stored-carb or glucose queries. let carbScheduleStart = min(carbsStart, ensureCarbCoverageStart ?? carbsStart) @@ -439,7 +458,7 @@ final class LoopDataManager: ObservableObject { throw LoopError.configurationError(.carbRatioSchedule) } - let glucose = try await glucoseStore.getGlucoseSamples(start: carbsStart, end: baseTime) + let glucose = try await glucoseStore.getGlucoseSamples(start: glucoseStart, end: baseTime) let dosesWithModel = doses.map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) } @@ -542,7 +561,6 @@ final class LoopDataManager: ObservableObject { } - let experiments = enabledAlgorithmExperiments let volatilityBaseline = experiments.contains(.guardAndRunway) ? await currentVolatilityBaseline(at: baseTime) : nil return StoredDataAlgorithmInput( @@ -578,6 +596,9 @@ final class LoopDataManager: ObservableObject { if UserDefaults.standard.guardAndRunwayEnabled { experiments.insert(.guardAndRunway) } + if UserDefaults.standard.slowRetrospectiveCorrectionEnabled { + experiments.insert(.slowRetrospectiveCorrection) + } return experiments } @@ -719,7 +740,7 @@ final class LoopDataManager: ObservableObject { } var output = Algorithm.run(input: input) - dosingDecision.algorithmExperiments = algorithmExperiments(for: dosingStrategy, guardAndRunway: output.guardAndRunway) + dosingDecision.algorithmExperiments = algorithmExperiments(for: dosingStrategy, output: output) switch output.recommendationResult { case .success(let recommendation): @@ -924,7 +945,7 @@ final class LoopDataManager: ObservableObject { if let output = displayState.output { var dosingDecision = StoredDosingDecision(date: now, reason: "updateRemoteRecommendation") - dosingDecision.algorithmExperiments = algorithmExperiments(for: currentDosingStrategy, guardAndRunway: output.guardAndRunway) + dosingDecision.algorithmExperiments = algorithmExperiments(for: currentDosingStrategy, output: output) dosingDecision.predictedGlucose = output.predictedGlucose.map(\.loopKit) dosingDecision.insulinOnBoard = displayState.activeInsulin dosingDecision.carbsOnBoard = displayState.activeCarbs @@ -1065,30 +1086,43 @@ extension LoopDataManager { } /// Glucose based partial application only affects automatic boluses, so it is in effect only with that strategy - private func algorithmExperiments(for dosingStrategy: AutomaticDosingStrategy, guardAndRunway: GuardAndRunwayOutput? = nil) -> [AlgorithmExperiment] { + private func algorithmExperiments(for dosingStrategy: AutomaticDosingStrategy, output: AlgorithmOutput? = nil) -> [AlgorithmExperiment] { enabledAlgorithmExperiments .filter { $0 != .glucoseBasedApplicationFactor || dosingStrategy == .automaticBolus } .sorted { $0.rawValue < $1.rawValue } .map { experiment in - guard experiment == .guardAndRunway else { + switch experiment { + case .guardAndRunway: + return guardAndRunwayExperiment(output?.guardAndRunway) + case .slowRetrospectiveCorrection: + var parameters: [String: JSONValue] = [:] + if let slow = output?.slowRetrospectiveCorrection { + parameters["level"] = .number(slow.level) + parameters["multiplier"] = .number(slow.multiplier) + } + return AlgorithmExperiment(identifier: experiment.rawValue, parameters: parameters.isEmpty ? nil : .object(parameters)) + default: return AlgorithmExperiment(identifier: experiment.rawValue) } - var parameters: [String: JSONValue] = [:] - if let baseline = volatilityBaseline?.value { - parameters["volatilityBaseline"] = .number(baseline) - } - if let guardAndRunway { - parameters["volatility"] = .number(guardAndRunway.volatility) - parameters["bandApplied"] = .bool(guardAndRunway.bandApplied) - parameters["bandGain"] = .number(guardAndRunway.bandGain) - parameters["bandDepth"] = .number(guardAndRunway.bandDepth) - parameters["runwayShift"] = .number(guardAndRunway.runwayShift) - parameters["postLowRiseCutActive"] = .bool(guardAndRunway.postLowRiseCutActive) - } - return AlgorithmExperiment(identifier: experiment.rawValue, parameters: parameters.isEmpty ? nil : .object(parameters)) } } + private func guardAndRunwayExperiment(_ guardAndRunway: GuardAndRunwayOutput?) -> AlgorithmExperiment { + var parameters: [String: JSONValue] = ["version": .number(2)] + if let baseline = volatilityBaseline?.value { + parameters["volatilityBaseline"] = .number(baseline) + } + if let guardAndRunway { + parameters["volatility"] = .number(guardAndRunway.volatility) + parameters["bandApplied"] = .bool(guardAndRunway.bandApplied) + parameters["bandGain"] = .number(guardAndRunway.bandGain) + parameters["bandDepth"] = .number(guardAndRunway.bandDepth) + parameters["runwayShift"] = .number(guardAndRunway.runwayShift) + parameters["postLowRiseCutActive"] = .bool(guardAndRunway.postLowRiseCutActive) + } + return AlgorithmExperiment(identifier: ExperimentOption.guardAndRunway.rawValue, parameters: .object(parameters)) + } + /// The median σ5 over the last four weeks, for guard + runway's volatility band. Nil until there is a day of glucose. private func currentVolatilityBaseline(at date: Date) async -> Double? { if let volatilityBaseline, abs(date.timeIntervalSince(volatilityBaseline.date)) < (volatilityBaseline.value == nil ? .hours(1) : .days(1)) { @@ -1347,7 +1381,8 @@ extension StoredDataAlgorithmInput { } } - func predictGlucose(effectsOptions: AlgorithmEffectsOptions = .all) throws -> [LoopAlgorithm.PredictedGlucoseValue] { + /// `sensitivityMultiplier` is slow retrospective correction's, from the algorithm's last run. + func predictGlucose(effectsOptions: AlgorithmEffectsOptions = .all, sensitivityMultiplier: Double = 1) throws -> [LoopAlgorithm.PredictedGlucoseValue] { try validateCarbScheduleCoverage() let prediction = Algorithm.generatePrediction( start: predictionStart, @@ -1355,7 +1390,9 @@ extension StoredDataAlgorithmInput { doses: doses, carbEntries: carbEntries, basal: basal, - sensitivityMgdlPerUnit: sensitivityMgdlPerUnit, + sensitivityMgdlPerUnit: sensitivityMgdlPerUnit.map { + LoopAlgorithm.AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: $0.value * sensitivityMultiplier) + }, carbRatio: carbRatio, algorithmEffectsOptions: effectsOptions, useIntegralRetrospectiveCorrection: self.useIntegralRetrospectiveCorrection, @@ -1589,7 +1626,7 @@ extension LoopDataManager: BolusEntryViewModelDelegate { .removingCarbEntry(carbEntry: carbEntryChange?.originalEntry) .addingCarbEntry(carbEntry: carbEntryChange?.asStoredCarbEntry) - let prediction = try input.predictGlucose() + let prediction = try input.predictGlucose(sensitivityMultiplier: displayState.sensitivityMultiplier) return (historicGlucose: input.glucoseHistory, predictedGlucose: prediction.map(\.loopKit)) } diff --git a/Loop/Models/PredictionInputEffect.swift b/Loop/Models/PredictionInputEffect.swift index cf918321ac..bb703e566b 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -19,8 +19,9 @@ struct PredictionInputEffect: OptionSet { static let retrospection = PredictionInputEffect(rawValue: 1 << 3) static let suspend = PredictionInputEffect(rawValue: 1 << 4) static let guardAndRunway = PredictionInputEffect(rawValue: 1 << 5) + static let slowRetrospectiveCorrection = PredictionInputEffect(rawValue: 1 << 6) - static let all: PredictionInputEffect = [.carbs, .insulin, .momentum, .retrospection, .guardAndRunway] + static let all: PredictionInputEffect = [.carbs, .insulin, .momentum, .retrospection, .guardAndRunway, .slowRetrospectiveCorrection] var localizedTitle: String? { switch self { @@ -35,7 +36,9 @@ struct PredictionInputEffect: OptionSet { case [.suspend]: return NSLocalizedString("Suspension of Insulin Delivery", comment: "Title of the prediction input effect for suspension of insulin delivery") case [.guardAndRunway]: - return NSLocalizedString("Guard + Runway", comment: "Title of the prediction input effect for the guard and runway experiment") + return NSLocalizedString("Guard + Runway v2", comment: "Title of the prediction input effect for the guard and runway experiment, second version") + case [.slowRetrospectiveCorrection]: + return NSLocalizedString("Slow Retrospective Correction", comment: "Title of the prediction input effect for the slow retrospective correction experiment") default: return nil } @@ -54,7 +57,9 @@ struct PredictionInputEffect: OptionSet { case [.suspend]: return NSLocalizedString("Glucose effect of suspending insulin delivery", comment: "Description of the prediction input effect for suspension of insulin delivery") case [.guardAndRunway]: - return NSLocalizedString("Forecast lowered by recent glucose volatility over the next 2 hours, and the automatic dosing target lowered when there is room above the suspend threshold", comment: "Description of the prediction input effect for the guard and runway experiment") + return NSLocalizedString("The low guard checks the forecast lowered by recent glucose volatility over the next 2 hours (dotted line), and the automatic dosing target is lowered when there is room above the suspend threshold", comment: "Description of the prediction input effect for the guard and runway experiment, second version") + case [.slowRetrospectiveCorrection]: + return NSLocalizedString("Insulin sensitivity adjusted for glucose change your settings did not explain over the last 3 days", comment: "Description of the prediction input effect for the slow retrospective correction experiment") default: return nil } diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index 134a33c863..77a0cae183 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -79,6 +79,8 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable private var guardAndRunway: GuardAndRunwayOutput? + private var slowRetrospectiveCorrection: SlowRetrospectiveCorrectionOutput? + private var refreshContext = RefreshContext.all private var chartStartDate: Date { @@ -129,14 +131,20 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable self.retrospectiveGlucoseDiscrepancies = algoOutput?.effects.retrospectiveGlucoseDiscrepancies.map(\.loopKit) totalRetrospectiveCorrection = algoOutput?.effects.totalRetrospectiveCorrectionEffect.map { LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0) } self.guardAndRunway = algoOutput?.guardAndRunway + self.slowRetrospectiveCorrection = algoOutput?.slowRetrospectiveCorrection - self.glucoseChart.setPredictedGlucoseValues((algoOutput?.predictedGlucose ?? []).map(\.loopKit)) + let predictedGlucose = algoOutput?.predictedGlucose ?? [] + self.glucoseChart.setPredictedGlucoseValues(predictedGlucose.map(\.loopKit)) + + if self.selectedInputs.contains(.guardAndRunway), let guardAndRunway = self.guardAndRunway, guardAndRunway.bandApplied { + self.glucoseChart.setGuardPredictedGlucoseValues(guardAndRunway.guarded(predictedGlucose).map(\.loopKit)) + } else { + self.glucoseChart.setGuardPredictedGlucoseValues([]) + } do { - var glucose = try algoInput?.predictGlucose(effectsOptions: self.selectedInputs.algorithmEffectOptions) ?? [] - if self.selectedInputs.contains(.guardAndRunway), let guardAndRunway = self.guardAndRunway { - glucose = guardAndRunway.guarded(glucose) - } + let sensitivityMultiplier = self.selectedInputs.contains(.slowRetrospectiveCorrection) ? self.slowRetrospectiveCorrection?.multiplier ?? 1 : 1 + let glucose = try algoInput?.predictGlucose(effectsOptions: self.selectedInputs.algorithmEffectOptions, sensitivityMultiplier: sensitivityMultiplier) ?? [] self.glucoseChart.setAlternatePredictedGlucoseValues(glucose.map(\.loopKit)) } catch { self.refreshContext.update(with: .status) @@ -207,6 +215,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable // for better ways to support desired use cases. https://github.com/LoopKit/Loop/pull/2026 private lazy var availableInputs: [PredictionInputEffect] = [.carbs, .insulin, .momentum, .retrospection] + (UserDefaults.standard.guardAndRunwayEnabled ? [.guardAndRunway] : []) + + (UserDefaults.standard.slowRetrospectiveCorrectionEnabled ? [.slowRetrospectiveCorrection] : []) private var selectedInputs = PredictionInputEffect.all @@ -320,9 +329,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable formatter.string(from: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: milligramsPerDeciliter)) ?? "?" } let guardText = guardAndRunway.bandApplied - ? String(format: NSLocalizedString("Forecast lowered up to %1$@ (recent volatility %2$@)", comment: "Guard and runway band description. (1: The most the forecast is lowered)(2: Recent glucose volatility)"), + ? String(format: NSLocalizedString("Low guard checks the forecast lowered up to %1$@ (dotted line, recent volatility %2$@)", comment: "Guard and runway band description. (1: The most the forecast the low guard checks is lowered)(2: Recent glucose volatility)"), display(guardAndRunway.bandDepth), display(guardAndRunway.volatility)) - : NSLocalizedString("Forecast not lowered while carbs are active", comment: "Guard and runway band description when active carbs keep it off") + : NSLocalizedString("Low guard checks the forecast unchanged while carbs are active", comment: "Guard and runway band description when active carbs keep it off") let runwayText = guardAndRunway.runwayShift > 0 ? String(format: NSLocalizedString("Automatic dosing target lowered %1$@", comment: "Guard and runway runway description. (1: How far the automatic dosing target is lowered)"), display(guardAndRunway.runwayShift)) @@ -330,6 +339,19 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable subtitleText = String(format: "%@\n%@", guardText, runwayText) } + if input == .slowRetrospectiveCorrection, let slowRetrospectiveCorrection { + let formatter = QuantityFormatter(for: glucoseChart.glucoseUnit) + formatter.numberFormatter.positivePrefix = formatter.numberFormatter.plusSign + let fractionDigits = glucoseChart.glucoseUnit == .milligramsPerDeciliter ? 1 : 2 + formatter.numberFormatter.minimumFractionDigits = fractionDigits + formatter.numberFormatter.maximumFractionDigits = fractionDigits + let level = formatter.string(from: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: slowRetrospectiveCorrection.level)) ?? "?" + let multiplier = slowRetrospectiveCorrection.multiplier.formatted(.number.precision(.fractionLength(2))) + subtitleText = String( + format: NSLocalizedString("Insulin sensitivity × %1$@\nUnexplained change %2$@ per 5 min, weighted over about a day", comment: "Slow retrospective correction description. (1: The multiplier applied to insulin sensitivity)(2: The unexplained glucose change per 5 minutes)"), + multiplier, level) + } + cell.subtitleLabel?.text = subtitleText } diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift index d66b40b5bd..8f4350cd20 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -249,7 +249,7 @@ final class ManualEntryDoseViewModel: ObservableObject { do { predictedGlucoseValues = try input .addingDose(dose: enteredBolusDose) - .predictGlucose() + .predictGlucose(sensitivityMultiplier: state.sensitivityMultiplier) .map(\.loopKit) } catch { predictedGlucoseValues = [] diff --git a/Loop/Views/GuardAndRunwaySelectionView.swift b/Loop/Views/GuardAndRunwaySelectionView.swift index 16d5696325..e412e3a008 100644 --- a/Loop/Views/GuardAndRunwaySelectionView.swift +++ b/Loop/Views/GuardAndRunwaySelectionView.swift @@ -13,17 +13,17 @@ public struct GuardAndRunwaySelectionView: View { public var body: some View { ScrollView { VStack(spacing: 10) { - Text(NSLocalizedString("Guard + Runway", comment: "Title for guard and runway experiment description")) + Text(NSLocalizedString("Guard + Runway v2", comment: "Title for guard and runway experiment description, second version")) .font(.headline) .padding(.bottom, 20) Divider() - Text(NSLocalizedString("Guard + Runway pairs a guard against lows with a little more automatic correction when there is room for it.\n\nAfter a low: for 12 hours after a reading below 70 mg/dL (3.9 mmol/L), while glucose is below 180 mg/dL (10 mmol/L), rises count less toward retrospective correction, so the rebound after treating a low is not taken as a lasting need for more insulin.\n\nVolatility: with no carbs on board, the first two hours of the forecast are lowered according to how much glucose has been varying recently, so Loop is more cautious when glucose is unsettled.\n\nRunway: at 150 mg/dL (8.3 mmol/L) or above with no carbs on board, Loop gives some extra automatic insulin when glucose is calm enough that suspending basal could still take it back before reaching your glucose safety limit.\n\nYour correction range, manual bolus recommendations and glucose safety limit are unchanged. This experiment has been evaluated in simulation and in replays of real data, not yet by people using it.", comment: "Description of Guard + Runway experiment.")) + Text(NSLocalizedString("Guard + Runway pairs a guard against lows with a little more automatic correction when there is room for it.\n\nAfter a low: for 12 hours after a reading below 70 mg/dL (3.9 mmol/L), while glucose is below 180 mg/dL (10 mmol/L), rises count less toward retrospective correction, so the rebound after treating a low is not taken as a lasting need for more insulin.\n\nVolatility: with no carbs on board, the low guard checks the first two hours of the forecast lowered according to how much glucose has been varying recently, so Loop holds back insulin sooner when glucose is unsettled. The forecast you see and the size of corrections are not lowered.\n\nRunway: at 150 mg/dL (8.3 mmol/L) or above, Loop gives some extra automatic insulin when glucose is calm enough that suspending basal could still take it back before reaching your glucose safety limit. The extra shrinks as carbs on board rise, and stops at 20 g.\n\nNew in version 2: the volatility band only affects the low guard, and the runway fades out with carbs instead of stopping at any carbs.\n\nYour correction range, manual bolus recommendations and glucose safety limit are unchanged. This experiment has been evaluated in simulation and in replays of real data, not yet by people using it.", comment: "Description of Guard + Runway experiment, second version.")) .foregroundColor(.secondary) Divider() - Toggle(NSLocalizedString("Enable Guard + Runway", comment: "Title for Guard + Runway toggle"), isOn: $isGuardAndRunwayEnabled) + Toggle(NSLocalizedString("Enable Guard + Runway v2", comment: "Title for Guard + Runway toggle, second version"), isOn: $isGuardAndRunwayEnabled) .padding(.top, 20) } .padding() diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 770e703aa5..e1674474e8 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -42,6 +42,7 @@ public struct ExperimentsSettingsView: View { @AppStorage(UserDefaults.Key.GlucoseBasedApplicationFactorEnabled.rawValue) private var isGlucoseBasedApplicationFactorEnabled = false @AppStorage(UserDefaults.Key.IntegralRetrospectiveCorrectionEnabled.rawValue) private var isIntegralRetrospectiveCorrectionEnabled = false @AppStorage(UserDefaults.Key.GuardAndRunwayEnabled.rawValue) private var isGuardAndRunwayEnabled = false + @AppStorage(UserDefaults.Key.SlowRetrospectiveCorrectionEnabled.rawValue) private var isSlowRetrospectiveCorrectionEnabled = false var automaticDosingStrategy: AutomaticDosingStrategy public var body: some View { @@ -63,9 +64,14 @@ public struct ExperimentsSettingsView: View { Divider() NavigationLink(destination: GuardAndRunwaySelectionView(isGuardAndRunwayEnabled: $isGuardAndRunwayEnabled)) { ExperimentRow( - name: NSLocalizedString("Guard + Runway", comment: "Title of guard and runway experiment"), + name: NSLocalizedString("Guard + Runway v2", comment: "Title of guard and runway experiment, second version"), enabled: isGuardAndRunwayEnabled) } + NavigationLink(destination: SlowRetrospectiveCorrectionSelectionView(isSlowRetrospectiveCorrectionEnabled: $isSlowRetrospectiveCorrectionEnabled)) { + ExperimentRow( + name: NSLocalizedString("Slow Retrospective Correction", comment: "Title of slow retrospective correction experiment"), + enabled: isSlowRetrospectiveCorrectionEnabled) + } NavigationLink(destination: GlucoseBasedApplicationFactorSelectionView(isGlucoseBasedApplicationFactorEnabled: $isGlucoseBasedApplicationFactorEnabled, automaticDosingStrategy: automaticDosingStrategy)) { ExperimentRow( name: NSLocalizedString("Glucose Based Partial Application", comment: "Title of glucose based partial application experiment"), @@ -94,6 +100,7 @@ extension UserDefaults { case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" case GuardAndRunwayEnabled = "com.loopkit.algorithmExperiments.guardAndRunwayEnabled" + case SlowRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.slowRetrospectiveCorrectionEnabled" } var glucoseBasedApplicationFactorEnabled: Bool { @@ -123,4 +130,13 @@ extension UserDefaults { } } + var slowRetrospectiveCorrectionEnabled: Bool { + get { + bool(forKey: Key.SlowRetrospectiveCorrectionEnabled.rawValue) as Bool + } + set { + set(newValue, forKey: Key.SlowRetrospectiveCorrectionEnabled.rawValue) + } + } + } diff --git a/Loop/Views/SlowRetrospectiveCorrectionSelectionView.swift b/Loop/Views/SlowRetrospectiveCorrectionSelectionView.swift new file mode 100644 index 0000000000..bc67e98d33 --- /dev/null +++ b/Loop/Views/SlowRetrospectiveCorrectionSelectionView.swift @@ -0,0 +1,39 @@ +// +// SlowRetrospectiveCorrectionSelectionView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +public struct SlowRetrospectiveCorrectionSelectionView: View { + @Binding var isSlowRetrospectiveCorrectionEnabled: Bool + + public var body: some View { + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Slow Retrospective Correction", comment: "Title for slow retrospective correction experiment description")) + .font(.headline) + .padding(.bottom, 20) + + Divider() + + Text(NSLocalizedString("Slow Retrospective Correction looks back over the last 3 days for a lasting difference between what your settings expect and what your glucose did.\n\nWhen glucose has kept rising more than Loop's forecasts explained, Loop doses as if your insulin sensitivity were lower, giving more insulin. When glucose has kept falling more than expected, it gives less. The adjustment builds over about a day and stays between 0.7 and 1.3 times your insulin sensitivity.\n\nIt applies to the forecast, automatic dosing and bolus recommendations. Meals eaten without a carb entry in those 3 days look like unexplained rises and lead to more insulin. With less than 3 days of data it adjusts less.\n\nThis experiment has been evaluated in simulation and in replays of real data, not yet by people using it.", comment: "Description of Slow Retrospective Correction experiment.")) + .foregroundColor(.secondary) + Divider() + + Toggle(NSLocalizedString("Enable Slow Retrospective Correction", comment: "Title for Slow Retrospective Correction toggle"), isOn: $isSlowRetrospectiveCorrectionEnabled) + .padding(.top, 20) + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } +} + +struct SlowRetrospectiveCorrectionSelectionView_Previews: PreviewProvider { + static var previews: some View { + SlowRetrospectiveCorrectionSelectionView(isSlowRetrospectiveCorrectionEnabled: .constant(true)) + } +} diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index fab964e866..cc81b3cff1 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -602,6 +602,53 @@ class LoopDataManagerTests: XCTestCase { XCTAssertEqual(normal.sensitivityMgdlPerUnit, recent.sensitivityMgdlPerUnit) } + func testSlowRetrospectiveCorrectionReadsThreeDaysOfHistory() async throws { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.hours(-71)), quantity: .glucose(value: 120)), + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)) + ] + carbStore.carbHistory = [ + StoredCarbEntry(startDate: d(.hours(-80)), quantity: .carbs(value: 20)) + ] + + let normal = try await loopDataManager.fetchData(for: now) + XCTAssertEqual(normal.glucoseHistory.count, 1) + XCTAssertTrue(normal.carbEntries.isEmpty) + XCTAssertFalse(normal.experiments.contains(.slowRetrospectiveCorrection)) + + UserDefaults.standard.slowRetrospectiveCorrectionEnabled = true + defer { UserDefaults.standard.slowRetrospectiveCorrectionEnabled = false } + + let slow = try await loopDataManager.fetchData(for: now) + XCTAssertEqual(slow.glucoseHistory.count, 2) + XCTAssertEqual(slow.carbEntries.count, 1) + XCTAssertTrue(slow.experiments.contains(.slowRetrospectiveCorrection)) + // The algorithm refuses carb entries older than the carb ratio and ISF timelines. + XCTAssertLessThanOrEqual(try XCTUnwrap(slow.carbRatio.first).startDate, d(.hours(-80))) + XCTAssertLessThanOrEqual(try XCTUnwrap(slow.sensitivityMgdlPerUnit.first).startDate, d(.hours(-80))) + } + + func testSlowRetrospectiveCorrectionIsRecordedOnTheDosingDecision() async throws { + // Three days of a slow rise that no carbs or insulin explain + glucoseStore.storedGlucose = stride(from: 864, through: 1, by: -1).map { step in + StoredGlucoseSample(startDate: d(.minutes(Double(-5 * step) + 4)), quantity: .glucose(value: 250 - 0.15 * Double(step))) + } + UserDefaults.standard.slowRetrospectiveCorrectionEnabled = true + defer { UserDefaults.standard.slowRetrospectiveCorrectionEnabled = false } + + await loopDataManager.loop() + + let slow = try XCTUnwrap(loopDataManager.displayState.output?.slowRetrospectiveCorrection) + XCTAssertGreaterThan(slow.level, 0) + XCTAssertLessThan(slow.multiplier, 1) + + let experiment = try XCTUnwrap(dosingDecisionStore.dosingDecisions.first?.algorithmExperiments?.first { $0.identifier == "slowRetrospectiveCorrection" }) + guard case .object(let parameters)? = experiment.parameters, case .number(let multiplier)? = parameters["multiplier"] else { + return XCTFail("The multiplier is not recorded") + } + XCTAssertEqual(multiplier, slow.multiplier, accuracy: 0.01) + } + private func assertMissingCarbScheduleThrows(_ detail: ConfigurationErrorDetail, preview: Bool) async throws { glucoseStore.storedGlucose = [ StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150))