From c1967df438118cdf1574ee771ce48e7109ef33b3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 24 Oct 2026 13:13:27 -0500 Subject: [PATCH 1/2] Use LoopAlgorithm's experiments, and add Guard + Runway LoopAlgorithm now implements the algorithm experiments (LoopKit/LoopAlgorithm#10). Pass the enabled set and the volatility baseline in the algorithm input instead of computing the glucose-based application factor and switching integral RC in Loop. The removed application factor strategies matched LoopAlgorithm's factor exactly. Guard + Runway gets a row in Algorithm Experiments. Its volatility baseline is the median sigma5 over four weeks of glucose, refreshed daily (hourly until there is a day of glucose). Dosing decisions record each experiment by its LoopAlgorithm name, with the baseline for guard + runway, and the diagnostic report lists both. LoopKit also declares an AlgorithmExperiment, so LoopAlgorithm's is referred to through the LoopAlgorithmExperiment typealias. --- Loop.xcodeproj/project.pbxproj | 20 ++--- Loop/Managers/LoopDataManager.swift | 79 ++++++++++++------- Loop/Models/ApplicationFactorStrategy.swift | 19 ----- .../ConstantApplicationFactorStrategy.swift | 22 ------ ...lucoseBasedApplicationFactorStrategy.swift | 40 ---------- Loop/Models/LoopAlgorithmExperiment.swift | 12 +++ Loop/Models/StoredDataAlgorithmInput.swift | 4 + Loop/Views/GuardAndRunwaySelectionView.swift | 39 +++++++++ ...ingsView+algorithmExperimentsSection.swift | 16 ++++ 9 files changed, 129 insertions(+), 122 deletions(-) delete mode 100644 Loop/Models/ApplicationFactorStrategy.swift delete mode 100644 Loop/Models/ConstantApplicationFactorStrategy.swift delete mode 100644 Loop/Models/GlucoseBasedApplicationFactorStrategy.swift create mode 100644 Loop/Models/LoopAlgorithmExperiment.swift create mode 100644 Loop/Views/GuardAndRunwaySelectionView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 05301b2c0b..08968b1ef8 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -486,6 +486,7 @@ C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16B983F26B4898800256B05 /* DoseEnactorTests.swift */; }; C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */; }; + A1B2C3D42E8F000100000004 /* LoopAlgorithmExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42E8F000100000003 /* LoopAlgorithmExperiment.swift */; }; 52ECDA0F09B4C8F23A4B450E /* InsulinDeliveryHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */; }; C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */; }; C16FC0B02A99392F0025E239 /* live_capture_input.json in Resources */ = {isa = PBXBuildFile; fileRef = C16FC0AF2A99392F0025E239 /* live_capture_input.json */; }; @@ -597,9 +598,7 @@ CB1AF8155B1761A30ACA5591 /* Loop/Resources/Sounds/synth.caf in Resources */ = {isa = PBXBuildFile; fileRef = 83081FFB0497A4596F597830 /* Loop/Resources/Sounds/synth.caf */; }; 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 */; }; - 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 */; }; + A1B2C3D42E8F000100000002 /* GuardAndRunwaySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.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 */; }; @@ -1322,6 +1321,7 @@ C16B983F26B4898800256B05 /* DoseEnactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseEnactorTests.swift; sourceTree = ""; }; C16DA84122E8E112008624C2 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDataAlgorithmInput.swift; sourceTree = ""; }; + A1B2C3D42E8F000100000003 /* LoopAlgorithmExperiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAlgorithmExperiment.swift; sourceTree = ""; }; C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryHistory.swift; sourceTree = ""; }; C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleInsulinDose.swift; sourceTree = ""; }; C16FC0AF2A99392F0025E239 /* live_capture_input.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_input.json; sourceTree = ""; }; @@ -1408,9 +1408,7 @@ C9SCENED0000000000000002 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 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 = ""; }; - 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 = ""; }; + A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuardAndRunwaySelectionView.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 = ""; }; @@ -1707,14 +1705,11 @@ 43757D131C06F26C00910CB9 /* Models */ = { isa = PBXGroup; children = ( - DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, FEEDF0000000000000000000 /* GlucoseStatistics.swift */, A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, 8378B7D1F532A5F47CEBB6AE /* CarbEntryChange.swift */, - DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */, C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */, 1455ACB12C667BEE004F44F2 /* Deeplink.swift */, - DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */, B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */, 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */, C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */, @@ -1729,6 +1724,7 @@ 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, A987CD4824A58A0100439ADC /* ZipArchive.swift */, C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, + A1B2C3D42E8F000100000003 /* LoopAlgorithmExperiment.swift */, C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */, C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, @@ -2069,6 +2065,7 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */, 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, 84D1F1A62D09053A00CB271F /* StatusTableView.swift */, B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */, @@ -3471,6 +3468,7 @@ A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */, + A1B2C3D42E8F000100000004 /* LoopAlgorithmExperiment.swift in Sources */, 1452F4AB2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift in Sources */, C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, @@ -3495,7 +3493,6 @@ 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */, C1B80D632AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift in Sources */, - DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */, C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, @@ -3532,6 +3529,7 @@ 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, FEEDB0000000000000000001 /* StatisticsViewModel.swift in Sources */, DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, + A1B2C3D42E8F000100000002 /* GuardAndRunwaySelectionView.swift in Sources */, 842E40A72F22F7E2000CCCE0 /* TintedContent.swift in Sources */, 842E40A92F22F7E2000CCCE0 /* EstimatedReadTime.swift in Sources */, 842E40AA2F22F7E2000CCCE0 /* PresetsTrainingView.swift in Sources */, @@ -3547,7 +3545,6 @@ 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */, A9C62D882331703100535612 /* Service.swift in Sources */, 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */, - DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */, 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */, 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */, 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */, @@ -3632,7 +3629,6 @@ A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */, - DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */, FEEDB0000000000000000000 /* GlucoseStatistics.swift in Sources */, 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */, 1455ACB22C667BEE004F44F2 /* Deeplink.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 472f4468b4..db5ea222fd 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -166,6 +166,8 @@ final class LoopDataManager: ObservableObject { private var lastManualBolusRecommendation: ManualBolusRecommendation? + private var volatilityBaseline: (value: Double?, date: Date)? + private(set) var dosingStrategySelectionEnabled: Bool var usePositiveMomentumAndRCForManualBoluses: Bool @@ -536,23 +538,8 @@ final class LoopDataManager: ObservableObject { } - // Create dosing strategy based on user setting - let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - ? GlucoseBasedApplicationFactorStrategy() - : ConstantApplicationFactorStrategy() - - let correctionRange = target.closestPrior(to: baseTime)?.value - - let effectiveBolusApplicationFactor: Double? - - if let latestGlucose = glucose.last { - effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( - for: latestGlucose.quantity, - correctionRange: correctionRange! - ) - } else { - effectiveBolusApplicationFactor = nil - } + let experiments = enabledAlgorithmExperiments + let volatilityBaseline = experiments.contains(.guardAndRunway) ? await currentVolatilityBaseline(at: baseTime) : nil return StoredDataAlgorithmInput( glucoseHistory: glucose, @@ -566,12 +553,28 @@ final class LoopDataManager: ObservableObject { suspendThreshold: suspendThreshold, maxBolus: maxBolus, maxBasalRate: maxBasalRate, - useIntegralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, + useIntegralRetrospectiveCorrection: false, includePositiveVelocityAndRC: true, carbAbsorptionModel: carbAbsorptionModel, recommendationInsulinModel: recommendationInsulinModel, recommendationType: .manualBolus, - automaticBolusApplicationFactor: effectiveBolusApplicationFactor) + automaticBolusApplicationFactor: nil, + experiments: experiments, + volatilityBaseline: volatilityBaseline) + } + + private var enabledAlgorithmExperiments: Set { + var experiments: Set = [] + if UserDefaults.standard.integralRetrospectiveCorrectionEnabled { + experiments.insert(.integralRetrospectiveCorrection) + } + if UserDefaults.standard.glucoseBasedApplicationFactorEnabled { + experiments.insert(.glucoseBasedApplicationFactor) + } + if UserDefaults.standard.guardAndRunwayEnabled { + experiments.insert(.guardAndRunway) + } + return experiments } func loopingReEnabled() async { @@ -1052,15 +1055,32 @@ 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) -> [AlgorithmExperiment] { - var experiments: [AlgorithmExperiment] = [] - if UserDefaults.standard.integralRetrospectiveCorrectionEnabled { - experiments.append(AlgorithmExperiment(identifier: "integralRetrospectiveCorrection")) + private func algorithmExperiments(for dosingStrategy: AutomaticDosingStrategy) -> [LoopKit.AlgorithmExperiment] { + enabledAlgorithmExperiments + .filter { $0 != .glucoseBasedApplicationFactor || dosingStrategy == .automaticBolus } + .sorted { $0.rawValue < $1.rawValue } + .map { experiment in + let parameters: JSONValue? = experiment == .guardAndRunway + ? volatilityBaseline?.value.map { .object(["volatilityBaseline": .number($0)]) } + : nil + return LoopKit.AlgorithmExperiment(identifier: experiment.rawValue, parameters: 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)) { + return volatilityBaseline.value } - if UserDefaults.standard.glucoseBasedApplicationFactorEnabled && dosingStrategy == .automaticBolus { - experiments.append(AlgorithmExperiment(identifier: "glucoseBasedApplicationFactor")) + do { + let glucose = try await glucoseStore.getGlucoseSamples(start: date.addingTimeInterval(.days(-28)), end: date) + let baseline = GlucoseVolatility.baseline(glucose) + volatilityBaseline = (baseline, date) + return baseline + } catch { + logger.error("Error fetching glucose for volatility baseline: %{public}@", error.localizedDescription) + return volatilityBaseline?.value } - return experiments } func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { @@ -1304,7 +1324,8 @@ extension StoredDataAlgorithmInput { algorithmEffectsOptions: effectsOptions, useIntegralRetrospectiveCorrection: self.useIntegralRetrospectiveCorrection, useMidAbsorptionISF: true, - carbAbsorptionModel: self.carbAbsorptionModel.model + carbAbsorptionModel: self.carbAbsorptionModel.model, + experiments: experiments ) return prediction.glucose } @@ -1786,8 +1807,8 @@ extension LoopDataManager: DiagnosticReportGenerator { "insulinOnBoard: \(String(describing: algoOutput?.activeInsulin))", "error: \(String(describing: loopError))", "overrideInUserDefaults: \(String(describing: UserDefaults.appGroup?.intentExtensionOverrideToSet))", - "glucoseBasedApplicationFactorEnabled: \(UserDefaults.standard.glucoseBasedApplicationFactorEnabled)", - "integralRetrospectiveCorrectionEanbled: \(String(describing: algoInput?.useIntegralRetrospectiveCorrection))", + "algorithmExperiments: \(algoInput.map { $0.experiments.map(\.rawValue).sorted() } ?? [])", + "volatilityBaseline: \(String(describing: algoInput?.volatilityBaseline))", "" ] return entries.joined(separator: "\n") diff --git a/Loop/Models/ApplicationFactorStrategy.swift b/Loop/Models/ApplicationFactorStrategy.swift deleted file mode 100644 index 57038f5cb0..0000000000 --- a/Loop/Models/ApplicationFactorStrategy.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// ApplicationFactorStrategy.swift -// Loop -// -// Created by Jonas Björkert on 2023-06-03. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopAlgorithm -import LoopKit -import LoopCore - -protocol ApplicationFactorStrategy { - func calculateDosingFactor( - for glucose: LoopQuantity, - correctionRange: ClosedRange - ) -> Double -} diff --git a/Loop/Models/ConstantApplicationFactorStrategy.swift b/Loop/Models/ConstantApplicationFactorStrategy.swift deleted file mode 100644 index 41b4b478e2..0000000000 --- a/Loop/Models/ConstantApplicationFactorStrategy.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// ConstantDosingStrategy.swift -// Loop -// -// Created by Jonas Björkert on 2023-06-03. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopKit -import LoopCore -import LoopAlgorithm - -struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy { - func calculateDosingFactor( - for glucose: LoopQuantity, - correctionRange: ClosedRange - ) -> Double { - // The original strategy uses a constant dosing factor. - return LoopAlgorithm.defaultBolusPartialApplicationFactor - } -} diff --git a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift deleted file mode 100644 index e339d4a881..0000000000 --- a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// GlucoseBasedApplicationFactorStrategy.swift -// Loop -// -// Created by Jonas Björkert on 2023-06-03. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopAlgorithm -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: LoopQuantity, - correctionRange: ClosedRange - ) -> Double { - // Calculate current glucose and lower bound target - let currentGlucose = glucose.doubleValue(for: .milligramsPerDeciliter) - 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/LoopAlgorithmExperiment.swift b/Loop/Models/LoopAlgorithmExperiment.swift new file mode 100644 index 0000000000..fe2eca2f84 --- /dev/null +++ b/Loop/Models/LoopAlgorithmExperiment.swift @@ -0,0 +1,12 @@ +// +// LoopAlgorithmExperiment.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm + +// LoopKit also declares an AlgorithmExperiment, the record stored with dosing decisions, +// and LoopAlgorithm.AlgorithmExperiment resolves to the LoopAlgorithm struct +typealias LoopAlgorithmExperiment = AlgorithmExperiment diff --git a/Loop/Models/StoredDataAlgorithmInput.swift b/Loop/Models/StoredDataAlgorithmInput.swift index ba3c169d88..34bc4cda34 100644 --- a/Loop/Models/StoredDataAlgorithmInput.swift +++ b/Loop/Models/StoredDataAlgorithmInput.swift @@ -56,4 +56,8 @@ struct StoredDataAlgorithmInput: AlgorithmInput { var maxActiveInsulinMultiplier: Double? = nil var gradualTransitionsThreshold: Double? = nil + + var experiments: Set = [] + + var volatilityBaseline: Double? = nil } diff --git a/Loop/Views/GuardAndRunwaySelectionView.swift b/Loop/Views/GuardAndRunwaySelectionView.swift new file mode 100644 index 0000000000..16d5696325 --- /dev/null +++ b/Loop/Views/GuardAndRunwaySelectionView.swift @@ -0,0 +1,39 @@ +// +// GuardAndRunwaySelectionView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +public struct GuardAndRunwaySelectionView: View { + @Binding var isGuardAndRunwayEnabled: Bool + + public var body: some View { + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Guard + Runway", comment: "Title for guard and runway experiment description")) + .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.")) + .foregroundColor(.secondary) + Divider() + + Toggle(NSLocalizedString("Enable Guard + Runway", comment: "Title for Guard + Runway toggle"), isOn: $isGuardAndRunwayEnabled) + .padding(.top, 20) + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } +} + +struct GuardAndRunwaySelectionView_Previews: PreviewProvider { + static var previews: some View { + GuardAndRunwaySelectionView(isGuardAndRunwayEnabled: .constant(true)) + } +} diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 795fbdd860..f956c63586 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -41,6 +41,7 @@ public struct ExperimentRow: View { 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 var automaticDosingStrategy: AutomaticDosingStrategy public var body: some View { @@ -70,6 +71,11 @@ public struct ExperimentsSettingsView: View { name: NSLocalizedString("Integral Retrospective Correction", comment: "Title of integral retrospective correction experiment"), enabled: isIntegralRetrospectiveCorrectionEnabled) } + NavigationLink(destination: GuardAndRunwaySelectionView(isGuardAndRunwayEnabled: $isGuardAndRunwayEnabled)) { + ExperimentRow( + name: NSLocalizedString("Guard + Runway", comment: "Title of guard and runway experiment"), + enabled: isGuardAndRunwayEnabled) + } Spacer() } .padding() @@ -83,6 +89,7 @@ extension UserDefaults { fileprivate enum Key: String { case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" + case GuardAndRunwayEnabled = "com.loopkit.algorithmExperiments.guardAndRunwayEnabled" } var glucoseBasedApplicationFactorEnabled: Bool { @@ -103,4 +110,13 @@ extension UserDefaults { } } + var guardAndRunwayEnabled: Bool { + get { + bool(forKey: Key.GuardAndRunwayEnabled.rawValue) as Bool + } + set { + set(newValue, forKey: Key.GuardAndRunwayEnabled.rawValue) + } + } + } From 1e18fe49dad8bcdfff03f9fc1470f1b4fb2da52d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 24 Oct 2026 13:18:41 -0500 Subject: [PATCH 2/2] Use LoopAlgorithm's ExperimentOption LoopAlgorithm renamed its experiment type, so the LoopAlgorithmExperiment typealias and the LoopKit.AlgorithmExperiment qualifications are no longer needed. --- Loop.xcodeproj/project.pbxproj | 4 ---- Loop/Managers/LoopDataManager.swift | 8 ++++---- Loop/Models/LoopAlgorithmExperiment.swift | 12 ------------ Loop/Models/StoredDataAlgorithmInput.swift | 2 +- 4 files changed, 5 insertions(+), 21 deletions(-) delete mode 100644 Loop/Models/LoopAlgorithmExperiment.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 08968b1ef8..4fa85e8f2e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -486,7 +486,6 @@ C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16B983F26B4898800256B05 /* DoseEnactorTests.swift */; }; C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */; }; - A1B2C3D42E8F000100000004 /* LoopAlgorithmExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42E8F000100000003 /* LoopAlgorithmExperiment.swift */; }; 52ECDA0F09B4C8F23A4B450E /* InsulinDeliveryHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */; }; C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */; }; C16FC0B02A99392F0025E239 /* live_capture_input.json in Resources */ = {isa = PBXBuildFile; fileRef = C16FC0AF2A99392F0025E239 /* live_capture_input.json */; }; @@ -1321,7 +1320,6 @@ C16B983F26B4898800256B05 /* DoseEnactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseEnactorTests.swift; sourceTree = ""; }; C16DA84122E8E112008624C2 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDataAlgorithmInput.swift; sourceTree = ""; }; - A1B2C3D42E8F000100000003 /* LoopAlgorithmExperiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAlgorithmExperiment.swift; sourceTree = ""; }; C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryHistory.swift; sourceTree = ""; }; C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleInsulinDose.swift; sourceTree = ""; }; C16FC0AF2A99392F0025E239 /* live_capture_input.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_input.json; sourceTree = ""; }; @@ -1724,7 +1722,6 @@ 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, A987CD4824A58A0100439ADC /* ZipArchive.swift */, C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, - A1B2C3D42E8F000100000003 /* LoopAlgorithmExperiment.swift */, C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */, C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, @@ -3468,7 +3465,6 @@ A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */, - A1B2C3D42E8F000100000004 /* LoopAlgorithmExperiment.swift in Sources */, 1452F4AB2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift in Sources */, C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index db5ea222fd..30ab8fb2a8 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -563,8 +563,8 @@ final class LoopDataManager: ObservableObject { volatilityBaseline: volatilityBaseline) } - private var enabledAlgorithmExperiments: Set { - var experiments: Set = [] + private var enabledAlgorithmExperiments: Set { + var experiments: Set = [] if UserDefaults.standard.integralRetrospectiveCorrectionEnabled { experiments.insert(.integralRetrospectiveCorrection) } @@ -1055,7 +1055,7 @@ 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) -> [LoopKit.AlgorithmExperiment] { + private func algorithmExperiments(for dosingStrategy: AutomaticDosingStrategy) -> [AlgorithmExperiment] { enabledAlgorithmExperiments .filter { $0 != .glucoseBasedApplicationFactor || dosingStrategy == .automaticBolus } .sorted { $0.rawValue < $1.rawValue } @@ -1063,7 +1063,7 @@ extension LoopDataManager { let parameters: JSONValue? = experiment == .guardAndRunway ? volatilityBaseline?.value.map { .object(["volatilityBaseline": .number($0)]) } : nil - return LoopKit.AlgorithmExperiment(identifier: experiment.rawValue, parameters: parameters) + return AlgorithmExperiment(identifier: experiment.rawValue, parameters: parameters) } } diff --git a/Loop/Models/LoopAlgorithmExperiment.swift b/Loop/Models/LoopAlgorithmExperiment.swift deleted file mode 100644 index fe2eca2f84..0000000000 --- a/Loop/Models/LoopAlgorithmExperiment.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// LoopAlgorithmExperiment.swift -// Loop -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import LoopAlgorithm - -// LoopKit also declares an AlgorithmExperiment, the record stored with dosing decisions, -// and LoopAlgorithm.AlgorithmExperiment resolves to the LoopAlgorithm struct -typealias LoopAlgorithmExperiment = AlgorithmExperiment diff --git a/Loop/Models/StoredDataAlgorithmInput.swift b/Loop/Models/StoredDataAlgorithmInput.swift index 34bc4cda34..8d8441c45a 100644 --- a/Loop/Models/StoredDataAlgorithmInput.swift +++ b/Loop/Models/StoredDataAlgorithmInput.swift @@ -57,7 +57,7 @@ struct StoredDataAlgorithmInput: AlgorithmInput { var gradualTransitionsThreshold: Double? = nil - var experiments: Set = [] + var experiments: Set = [] var volatilityBaseline: Double? = nil }