Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 4 additions & 12 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -597,9 +597,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 */; };
Expand Down Expand Up @@ -1408,9 +1406,7 @@
C9SCENED0000000000000002 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
CE102E23D8C1C1291EF66387 /* PluginPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = "<group>"; };
DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrectionSelectionView.swift; sourceTree = "<group>"; };
DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationFactorStrategy.swift; sourceTree = "<group>"; };
DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorStrategy.swift; sourceTree = "<group>"; };
DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstantApplicationFactorStrategy.swift; sourceTree = "<group>"; };
A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuardAndRunwaySelectionView.swift; sourceTree = "<group>"; };
DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+algorithmExperimentsSection.swift"; sourceTree = "<group>"; };
DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorSelectionView.swift; sourceTree = "<group>"; };
E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAccessView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1707,14 +1703,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 */,
Expand Down Expand Up @@ -2069,6 +2062,7 @@
C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */,
DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */,
DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */,
A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */,
84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */,
84D1F1A62D09053A00CB271F /* StatusTableView.swift */,
B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */,
Expand Down Expand Up @@ -3495,7 +3489,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 */,
Expand Down Expand Up @@ -3532,6 +3525,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 */,
Expand All @@ -3547,7 +3541,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 */,
Expand Down Expand Up @@ -3632,7 +3625,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 */,
Expand Down
77 changes: 49 additions & 28 deletions Loop/Managers/LoopDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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<ExperimentOption> {
var experiments: Set<ExperimentOption> = []
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 {
Expand Down Expand Up @@ -1053,14 +1056,31 @@ 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"))
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 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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
Expand Down
19 changes: 0 additions & 19 deletions Loop/Models/ApplicationFactorStrategy.swift

This file was deleted.

22 changes: 0 additions & 22 deletions Loop/Models/ConstantApplicationFactorStrategy.swift

This file was deleted.

40 changes: 0 additions & 40 deletions Loop/Models/GlucoseBasedApplicationFactorStrategy.swift

This file was deleted.

4 changes: 4 additions & 0 deletions Loop/Models/StoredDataAlgorithmInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,8 @@ struct StoredDataAlgorithmInput: AlgorithmInput {
var maxActiveInsulinMultiplier: Double? = nil

var gradualTransitionsThreshold: Double? = nil

var experiments: Set<ExperimentOption> = []

var volatilityBaseline: Double? = nil
}
39 changes: 39 additions & 0 deletions Loop/Views/GuardAndRunwaySelectionView.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading