From e5f73da42fd728ba40f6bc070b36f2ebd4438885 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 18 Oct 2026 01:00:07 -0500 Subject: [PATCH 1/3] Optional algorithm experiment (off by default): guard + runway Adds AlgorithmExperiment, an optional input on AlgorithmInput (protocol-extension default nil, so existing conformers are unchanged). With nil every experiment code path is skipped and the forecast and dose are standard LoopAlgorithm. There is one experiment, with no tunable parameters, so that people who choose to try it pool into one group. It is LoopEval's current best candidate for time in range with fewer meal entries, paired with its lows guard: - Post-low rise-cut (C20): for 12 h after a reading below 70 mg/dL, and while glucose is below 180, the rising side of retrospective correction is weighted 0.3x. - Volatility band (C29): with no carbs on board, the first two hours of the forecast are lowered by recent glucose volatility (sigma5), so the low guard sees it. Depth is keyed to the person's median sigma5 (volatilityBaseline, or computed from a day or more of glucose history; without it the band is off). - Runway reserve (C40): at 150 mg/dL or above with no carbs on board, find the most extra insulin that suspending scheduled basal could still take back if glucose fell along the forecast less one sigma5 band, without reaching the suspend threshold within 3 h. 70% of it (at most 60 mg/dL's worth) is added to automatic dosing, as an internal lowering of the correction aim. The user's target, the manual bolus recommendation and the suspend threshold are unchanged. Gaps in a sparse forecast are filled every 5 min before the reserve is scanned. GlucoseVolatility.sigma5 is the causal volatility estimate (glucose thinned to 5-minute spacing so 1-minute CGM works); GlucoseVolatility.baseline the person's median. Tests: existing fixtures decode with no experiment; closed gates reproduce the standard output; unit tests per mechanism, including runway gates, its volatility response and cap, sparse forecasts, and that the manual recommendation is unaffected. Implementation cross-check against LoopEval's engine: on one donor-week (1792 decisions, each recomputed from its recorded inputs with and without the experiment) the per-decision dose differences agree, summing to +32.8 U vs +33.0 U with correlation 0.95. Decisions are replayed independently, so this checks the implementations match; it is not an estimate of extra insulin delivered. --- Sources/LoopAlgorithm/AlgorithmInput.swift | 10 + .../LoopAlgorithm/AlgorithmInputFixture.swift | 9 + .../Experiments/AlgorithmExperiments.swift | 88 ++++++ .../ExperimentalForecastAdjustments.swift | 116 ++++++++ .../Experiments/GlucoseVolatility.swift | 73 +++++ Sources/LoopAlgorithm/LoopAlgorithm.swift | 56 +++- .../IntegralRetrospectiveCorrection.swift | 11 +- .../StandardRetrospectiveCorrection.swift | 12 +- .../AlgorithmExperimentsTests.swift | 269 ++++++++++++++++++ 9 files changed, 636 insertions(+), 8 deletions(-) create mode 100644 Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift create mode 100644 Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift create mode 100644 Sources/LoopAlgorithm/Experiments/GlucoseVolatility.swift create mode 100644 Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift diff --git a/Sources/LoopAlgorithm/AlgorithmInput.swift b/Sources/LoopAlgorithm/AlgorithmInput.swift index 9c2ef60..29096fc 100644 --- a/Sources/LoopAlgorithm/AlgorithmInput.swift +++ b/Sources/LoopAlgorithm/AlgorithmInput.swift @@ -35,8 +35,18 @@ public protocol AlgorithmInput { var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4 var gradualTransitionsThreshold: Double? { get } var emulation: AlgorithmEmulationOptions? { get } // Defaults to nil (no emulation) + /// Optional algorithm experiment; nil is standard LoopAlgorithm. Conforming types that do not + /// provide it get nil. + var experiment: AlgorithmExperiment? { get } + /// The person's volatility baseline for the `guardAndRunway` experiment's volatility band: the median + /// σ5 (mg/dL per 5 min) over their last four weeks of glucose, from `GlucoseVolatility.baseline(_:)`. + /// When nil it is computed from `glucoseHistory` if that holds at least a day of readings; otherwise + /// the experiment runs without the band. + var volatilityBaseline: Double? { get } } public extension AlgorithmInput { var emulation: AlgorithmEmulationOptions? { nil } + var experiment: AlgorithmExperiment? { nil } + var volatilityBaseline: Double? { nil } } diff --git a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift index de97e58..fb33cb8 100644 --- a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift +++ b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift @@ -36,6 +36,9 @@ public struct AlgorithmInputFixture: AlgorithmInput { public var automaticBolusApplicationFactor: Double? public var gradualTransitionsThreshold: Double? public var emulation: AlgorithmEmulationOptions? + /// Optional experiment; absent in existing fixtures, which therefore run standard LoopAlgorithm. + public var experiment: AlgorithmExperiment? + public var volatilityBaseline: Double? public var recommendationInsulinModel: InsulinModel { recommendationInsulinType.insulinModel @@ -152,6 +155,8 @@ extension AlgorithmInputFixture: Codable { self.automaticBolusApplicationFactor = try container.decodeIfPresent(Double.self, forKey: .automaticBolusApplicationFactor) self.gradualTransitionsThreshold = try container.decodeIfPresent(Double.self, forKey: .gradualTransitionsThreshold) ?? 40.0 self.emulation = try container.decodeIfPresent(AlgorithmEmulationOptions.self, forKey: .emulation) + self.experiment = try container.decodeIfPresent(AlgorithmExperiment.self, forKey: .experiment) + self.volatilityBaseline = try container.decodeIfPresent(Double.self, forKey: .volatilityBaseline) } public func encode(to encoder: Encoder) throws { @@ -190,6 +195,8 @@ extension AlgorithmInputFixture: Codable { if let emulation { try container.encode(emulation, forKey: .emulation) } + try container.encodeIfPresent(experiment, forKey: .experiment) + try container.encodeIfPresent(volatilityBaseline, forKey: .volatilityBaseline) } private enum CodingKeys: String, CodingKey { @@ -213,6 +220,8 @@ extension AlgorithmInputFixture: Codable { case automaticBolusApplicationFactor case gradualTransitionsThreshold case emulation + case experiment + case volatilityBaseline } } diff --git a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift new file mode 100644 index 0000000..d5a46c1 --- /dev/null +++ b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift @@ -0,0 +1,88 @@ +// +// AlgorithmExperiments.swift +// LoopAlgorithm +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// An optional algorithm experiment. `nil` (the default everywhere) is standard LoopAlgorithm. +/// +/// There is deliberately one, with no tunable parameters, so that people who choose to try it pool into +/// one large group whose results can be compared. It is a fixed configuration evaluated in LoopEval +/// (closed-loop counterfactual replay of real CGM and insulin data, and a simulated cohort, scored against +/// the standard algorithm swept over insulin needs). +public enum AlgorithmExperiment: String, Codable, CaseIterable, Sendable { + + /// LoopEval guard + runway (C20 + C29 + C40). A lows guard, plus a little more correction when there + /// is room for it: + /// - **Post-low rise-cut** (C20). For 12 h after a reading below 70 mg/dL, and while glucose is + /// below 180, the rising side of retrospective correction is weighted 0.3×: the rebound after a + /// low (rescue carbs, counter-regulation) is not treated as a lasting need for insulin. + /// - **Volatility band** (C29). While no carbs are on board, the first two hours of the forecast are + /// lowered in proportion to recent glucose volatility, so the low guard sees it. Eventual glucose + /// is unchanged. Its depth is keyed to the person's median volatility + /// (`AlgorithmInput.volatilityBaseline`). + /// - **Runway reserve** (C40). While glucose is 150 or above and no carbs are on board: how much + /// extra insulin could still be taken back by suspending scheduled basal, if glucose then fell + /// along the forecast less one σ of recent volatility, without reaching the suspend threshold + /// within 3 h? 70 % of that amount (at most 60 mg/dL's worth) is added to the automatic + /// correction. Calm glucose leaves room; volatile glucose leaves little or none. It is applied as + /// an internal lowering of the correction aim for automatic dosing only: the user's target, the + /// manual bolus recommendation and the suspend threshold are unchanged. + case guardAndRunway +} + +/// The internal configuration the experiment expands to. Not public: the experiment is fixed. +struct ExperimentParameters: Equatable { + var postLowRiseCut: PostLowRiseCut? = nil + var volatilityBand: VolatilityBand? = nil + var runwayReserve: RunwayReserve? = nil + + static let none = ExperimentParameters() + + var needsVolatility: Bool { volatilityBand != nil || runwayReserve != nil } + + /// `volatilityBaseline` is the person's median σ5 (mg/dL per 5 min); only the band uses it. + init(experiment: AlgorithmExperiment?, volatilityBaseline: Double?) { + switch experiment { + case nil: + break + case .guardAndRunway: + postLowRiseCut = PostLowRiseCut() + runwayReserve = RunwayReserve() + if let baseline = volatilityBaseline, baseline.isFinite, baseline > 0 { + volatilityBand = VolatilityBand(gain: Swift.min(1.0, 6.7 / baseline)) + } + } + } + + init() {} + + struct PostLowRiseCut: Equatable { + var riseScale = 0.3 + var window: TimeInterval = .hours(12) + var lowThreshold = 70.0 // mg/dL + var releaseGlucose = 180.0 // mg/dL + } + + struct VolatilityBand: Equatable { + var gain: Double + var horizon: TimeInterval = .minutes(60) + var taperEnd: TimeInterval = .minutes(120) + var exponent = 0.71 + var carbGate = true + } + + struct RunwayReserve: Equatable { + var fraction = 0.7 // share of the reserve that is spent + var bandWidth = 1.0 // σ5 multiples below the forecast for the plausible fall + var exponent = 0.71 // band growth with horizon, as the volatility band + var horizon: TimeInterval = .minutes(180) + var maxAimShift = 60.0 // mg/dL + var minGlucose = 150.0 // mg/dL + var minEffectFraction = 0.05 // horizons where less of a dose has acted are skipped + var carbGate = true + } +} diff --git a/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift b/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift new file mode 100644 index 0000000..8af1219 --- /dev/null +++ b/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift @@ -0,0 +1,116 @@ +// +// ExperimentalForecastAdjustments.swift +// LoopAlgorithm +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +extension ExperimentParameters.PostLowRiseCut { + /// True when current glucose is below `releaseGlucose` and some reading within `window` before it + /// was below `lowThreshold`. + func isActive(glucoseHistory: [GlucoseType]) -> Bool { + let unit = LoopUnit.milligramsPerDeciliter + guard let latest = glucoseHistory.last, + latest.quantity.doubleValue(for: unit) < releaseGlucose else { return false } + for sample in glucoseHistory.reversed() { + if latest.startDate.timeIntervalSince(sample.startDate) > window { break } + if sample.quantity.doubleValue(for: unit) < lowThreshold { return true } + } + return false + } +} + +extension LoopAlgorithm { + + /// The volatility band: each predicted point τ minutes ahead is lowered by + /// `gain × σ5 × (τ/5)^exponent` up to `horizon`, tapering to zero by `taperEnd`. + static func volatilityBanded(_ glucose: [PredictedGlucoseValue], band: ExperimentParameters.VolatilityBand, + volatility: Double) -> [PredictedGlucoseValue] { + guard band.gain > 0, let t0 = glucose.first?.startDate else { return glucose } + let unit = LoopUnit.milligramsPerDeciliter + let horizon = band.horizon.minutes + let taperEnd = Swift.max(band.taperEnd.minutes, horizon + 1) + let atHorizon = pow(Swift.max(horizon, 5.0) / 5.0, band.exponent) + return glucose.map { point in + let tau = point.startDate.timeIntervalSince(t0).minutes + var shape = 0.0 + if tau <= 0 { shape = 0 } + else if tau <= horizon { shape = pow(Swift.max(tau, 5.0) / 5.0, band.exponent) } + else if tau < taperEnd { shape = atHorizon * (taperEnd - tau) / (taperEnd - horizon) } + let offset = -band.gain * volatility * shape + return PredictedGlucoseValue(startDate: point.startDate, + quantity: LoopQuantity(unit: unit, doubleValue: point.quantity.doubleValue(for: unit) + offset)) + } + } + /// The runway reserve's aim shift, mg/dL (0 when the gate is closed or there is no room). + /// + /// Suppose Loop makes the correction it already plans (the excess of eventual glucose over the target + /// midpoint) plus an extra E units. The plausible path τ minutes ahead is the forecast, less that + /// planned correction's effect so far, less `bandWidth × σ5 × (τ/5)^exponent`. If glucose then followed + /// it, suspending scheduled basal from now would take back `basal × ∫ acted` of insulin. E_max is the + /// largest E for which that path, with the basal taken back, stays above the suspend threshold at every + /// horizon out to `horizon` (a forecast shorter than `horizon` gives no runway). The shift spends `fraction` of it, expressed in mg/dL through the ISF and + /// capped at `maxAimShift`. + static func runwayAimShift(prediction: [PredictedGlucoseValue], reserve: ExperimentParameters.RunwayReserve, + currentGlucose: Double, activeCarbs: Double, volatility: Double, + sensitivity: Double, basalRate: Double, targetMidpoint: Double, + suspendThreshold: Double, insulinModel: InsulinModel) -> Double { + let unit = LoopUnit.milligramsPerDeciliter + guard volatility.isFinite, sensitivity > 0, currentGlucose >= reserve.minGlucose, + !(reserve.carbGate && activeCarbs > 0), + let t0 = prediction.first?.startDate, let last = prediction.last, + // A forecast that stops short of the horizon cannot show a fall beyond its end: no runway. + last.startDate.timeIntervalSince(t0) >= reserve.horizon else { return 0 } + let eventual = last.quantity.doubleValue(for: unit) + // (τ, glucose) on the forecast's own points, with any gap longer than 5 min (Loop drops points + // once nothing is changing) filled every 5 min by linear interpolation. + var samples: [(tau: Double, value: Double)] = [] + for point in prediction { + let tau = point.startDate.timeIntervalSince(t0).minutes + let value = point.quantity.doubleValue(for: unit) + if let previous = samples.last, tau - previous.tau > 5.0 + 1e-9 { + var fill = previous.tau + 5.0 + while fill < tau - 1e-9 { + samples.append((fill, previous.value + (value - previous.value) * (fill - previous.tau) / (tau - previous.tau))) + fill += 5.0 + } + } + samples.append((tau, value)) + } + let excess = Swift.max(0, eventual - targetMidpoint) + let horizon = reserve.horizon.minutes + func acted(_ minutes: Double) -> Double { + minutes <= 0 ? 0 : Swift.max(0, Swift.min(1, 1 - insulinModel.percentEffectRemaining(at: .minutes(minutes)))) + } + var eMax = Double.infinity // units of extra insulin + var withheld = 0.0 // units of withheld basal that have acted by τ + var lastTau = 0.0 + for (tau, value) in samples { + if tau <= 0 { continue } + if tau > horizon { break } + withheld += basalRate / 60.0 * acted(0.5 * (tau + lastTau)) * (tau - lastTau) // midpoint rule + lastTau = tau + let d = acted(tau) + guard d > reserve.minEffectFraction else { continue } + let path = value - excess * d + - reserve.bandWidth * volatility * pow(Swift.max(tau, 5) / 5, reserve.exponent) + eMax = Swift.min(eMax, (path + sensitivity * withheld - suspendThreshold) / (sensitivity * d)) + } + guard eMax.isFinite else { return 0 } + return Swift.min(reserve.maxAimShift, reserve.fraction * sensitivity * Swift.max(0, eMax)) + } + + /// The target range lowered by `shift` mg/dL, floored at 70–80. + static func targetLowered(_ target: GlucoseRangeTimeline, by shift: Double) -> GlucoseRangeTimeline { + let unit = LoopUnit.milligramsPerDeciliter + return target.map { + AbsoluteScheduleValue( + startDate: $0.startDate, endDate: $0.endDate, + value: ClosedRange(uncheckedBounds: ( + lower: LoopQuantity(unit: unit, doubleValue: Swift.max(70.0, $0.value.lowerBound.doubleValue(for: unit) - shift)), + upper: LoopQuantity(unit: unit, doubleValue: Swift.max(80.0, $0.value.upperBound.doubleValue(for: unit) - shift))))) + } + } +} diff --git a/Sources/LoopAlgorithm/Experiments/GlucoseVolatility.swift b/Sources/LoopAlgorithm/Experiments/GlucoseVolatility.swift new file mode 100644 index 0000000..34ab2c4 --- /dev/null +++ b/Sources/LoopAlgorithm/Experiments/GlucoseVolatility.swift @@ -0,0 +1,73 @@ +// +// GlucoseVolatility.swift +// LoopAlgorithm +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Causal short-term glucose volatility, used by the `guardAndRunway` experiment. +public enum GlucoseVolatility { + + /// EWMA weight on the previous variance. + public static let lambda = 0.875 + /// CGM noise level; the variance is floored at 2 × noise². + public static let noise = 1.4 + + /// σ5 now: the exponentially weighted RMS of the 5-minute glucose increment, in mg/dL per 5 min, + /// over the last ~5 h of readings. + public static func sigma5(_ glucoseHistory: [GlucoseType]) -> Double { + let series = sigma5Series(fiveMinute(glucoseHistory).suffix(60)) + return series.last ?? (2.0 * noise * noise).squareRoot() + } + + /// The person's volatility baseline for the `guardAndRunway` experiment's band: the median of σ5 over a glucose + /// history. LoopEval used each person's own median; four weeks of history gave a stable value + /// (about 9 % drift between successive four-week windows). Returns nil with less than a day of readings. + public static func baseline(_ glucoseHistory: [GlucoseType]) -> Double? { + let series = sigma5Series(fiveMinute(glucoseHistory)) + guard series.count >= 288 else { return nil } + let sorted = series.sorted() + let mid = sorted.count / 2 + return sorted.count % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] + } + + /// Thin to 5-minute spacing: keep a reading only when it is at least 4 minutes after the last one + /// kept. With 5-minute CGM every reading is kept; with 1-minute CGM the estimator would otherwise + /// see only 1-minute increments, which it ignores, and never leave its floor. + static func fiveMinute(_ glucoseHistory: [GlucoseType]) -> [(date: Date, value: Double)] { + let unit = LoopUnit.milligramsPerDeciliter + var kept: [(date: Date, value: Double)] = [] + for sample in glucoseHistory { + if let last = kept.last, sample.startDate.timeIntervalSince(last.date) < .minutes(4) { continue } + kept.append((sample.startDate, sample.quantity.doubleValue(for: unit))) + } + return kept + } + + /// σ5 after each reading. Readings 2–7 min apart contribute (scaled to 5 min); a gap of more than + /// 7 min restarts the estimate, which reads as the floor until the next usable pair. + static func sigma5Series(_ samples: S) -> [Double] where S.Element == (date: Date, value: Double) { + let floorVariance = 2.0 * noise * noise + var variance = Double.nan + var previous: (date: Date, value: Double)? = nil + var out: [Double] = [] + for sample in samples { + if let previous { + let minutes = sample.date.timeIntervalSince(previous.date) / 60.0 + if minutes > 2.0 && minutes <= 7.0 { + let d5 = (sample.value - previous.value) * (5.0 / minutes) + variance = variance.isNaN + ? Swift.max(d5 * d5, floorVariance) + : Swift.max(lambda * variance + (1 - lambda) * d5 * d5, floorVariance) + } else if minutes > 7.0 { + variance = .nan + } + } + previous = sample + out.append(variance.isNaN ? floorVariance.squareRoot() : variance.squareRoot()) + } + return out + } +} diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 50974c8..e19e66c 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -184,7 +184,8 @@ public struct LoopAlgorithm { carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), gradualTransitionsThreshold: Double? = 40.0, momentumVelocityMaximum: LoopQuantity? = nil, - emulation: AlgorithmEmulationOptions = AlgorithmEmulationOptions() + emulation: AlgorithmEmulationOptions = AlgorithmEmulationOptions(), + experiment: AlgorithmExperiment? = nil ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { var prediction: [PredictedGlucoseValue] = [] @@ -259,14 +260,26 @@ public struct LoopAlgorithm { let rc: RetrospectiveCorrection + // Experiment (no-op when `experiment` is nil): the post-low rise-cut weights the rising side of + // retrospective correction while its gate is open. + var rcRiseScale = 1.0 + if let cut = ExperimentParameters(experiment: experiment, volatilityBaseline: nil).postLowRiseCut, + cut.isActive(glucoseHistory: glucoseHistory) { + rcRiseScale = cut.riseScale + } + if useIntegralRetrospectiveCorrection { rc = IntegralRetrospectiveCorrection( effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, maxCorrectionVelocity: emulation.disableIRCVelocityCeiling ? nil : IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity, - useLegacyDecay: emulation.legacyRCDecay + useLegacyDecay: emulation.legacyRCDecay, + riseScale: rcRiseScale ) } else { - rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, useLegacyDecay: emulation.legacyRCDecay) + rc = StandardRetrospectiveCorrection( + effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, + useLegacyDecay: emulation.legacyRCDecay, + riseScale: rcRiseScale) } if let latestGlucose = glucoseHistory.last { @@ -787,9 +800,38 @@ public struct LoopAlgorithm { useMidAbsorptionISF: input.useMidAbsorptionISF, carbAbsorptionModel: input.carbAbsorptionModel.model, gradualTransitionsThreshold: input.gradualTransitionsThreshold, - emulation: input.emulation ?? AlgorithmEmulationOptions() + emulation: input.emulation ?? AlgorithmEmulationOptions(), + experiment: input.experiment ) + // Experiment adjustments before dosing (no-ops when `experiment` is nil). The runway reserve + // reads the forecast before the volatility band lowers it; its band term is its own. + let experimentParameters = ExperimentParameters( + experiment: input.experiment, + volatilityBaseline: input.experiment != nil + ? (input.volatilityBaseline ?? GlucoseVolatility.baseline(input.glucoseHistory)) : nil) + let volatility: Double? = experimentParameters.needsVolatility ? GlucoseVolatility.sigma5(input.glucoseHistory) : nil + var runwayShift = 0.0 + if let reserve = experimentParameters.runwayReserve, let volatility, + let sensitivity = input.sensitivity.closestPrior(to: input.predictionStart)?.value, + let target = input.target.closestPrior(to: input.predictionStart)?.value + { + let unit = LoopUnit.milligramsPerDeciliter + runwayShift = runwayAimShift( + prediction: prediction.glucose, reserve: reserve, + currentGlucose: latestGlucose.quantity.doubleValue(for: unit), + activeCarbs: prediction.activeCarbs ?? 0, volatility: volatility, + sensitivity: sensitivity.doubleValue(for: unit), basalRate: scheduledBasalRate, + targetMidpoint: 0.5 * (target.lowerBound.doubleValue(for: unit) + target.upperBound.doubleValue(for: unit)), + suspendThreshold: suspendThreshold.doubleValue(for: unit), + insulinModel: input.recommendationInsulinModel) + } + if let band = experimentParameters.volatilityBand, let volatility, + !(band.carbGate && (prediction.activeCarbs ?? 0) > 0) + { + prediction.glucose = volatilityBanded(prediction.glucose, band: band, volatility: volatility) + } + let sensitivityForDosing: [AbsoluteScheduleValue] if input.useMidAbsorptionISF { sensitivityForDosing = input.sensitivity @@ -805,10 +847,14 @@ public struct LoopAlgorithm { sensitivityForDosing = [sensitivityOverPrediction] } + // The runway shift lowers the aim of automatic dosing only; the manual bolus recommendation + // keeps the user's target. + let correctionTarget = runwayShift > 0 && input.recommendationType != .manualBolus + ? targetLowered(input.target, by: runwayShift) : input.target let correction = insulinCorrection( prediction: prediction.glucose, at: input.predictionStart, - target: input.target, + target: correctionTarget, suspendThreshold: suspendThreshold, sensitivity: sensitivityForDosing, insulinModel: input.recommendationInsulinModel) diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift index aa5088c..a132bb2 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift @@ -74,12 +74,18 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { var correctionVelocity: LoopQuantity? var currentDate: Date = Date() + /// Experimental weight on the correction while the current discrepancy is positive (rising), set by + /// the post-low rise-cut (see `AlgorithmExperiment`). 1.0 is standard IRC. + let riseScale: Double + public init(effectDuration: TimeInterval, maxCorrectionVelocity: LoopQuantity? = IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity, - useLegacyDecay: Bool = false) { + useLegacyDecay: Bool = false, + riseScale: Double = 1.0) { self.effectDuration = effectDuration self.maxCorrectionVelocity = maxCorrectionVelocity self.useLegacyDecay = useLegacyDecay + self.riseScale = riseScale } /** @@ -199,6 +205,9 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { let retrospectionTimeInterval = currentDiscrepancy.endDate.timeIntervalSince(currentDiscrepancy.startDate) let discrepancyTime = max(retrospectionTimeInterval, retrospectiveCorrectionGroupingInterval) + if riseScale != 1.0 && currentDiscrepancyValue >= 0 { + scaledCorrection *= riseScale + } var velocityValue = scaledCorrection / discrepancyTime // Bound the correction rate to a settings-free physiological ceiling (see diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift index 135abbf..2060c82 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift @@ -25,9 +25,14 @@ public class StandardRetrospectiveCorrection: RetrospectiveCorrection { /// All math is performed with glucose expressed in mg/dL private let unit = LoopUnit.milligramsPerDeciliter - public init(effectDuration: TimeInterval, useLegacyDecay: Bool = false) { + /// Experimental weight on a positive (rising) discrepancy, set by the post-low rise-cut (see + /// `AlgorithmExperiment`). 1.0 is standard RC. + let riseScale: Double + + public init(effectDuration: TimeInterval, useLegacyDecay: Bool = false, riseScale: Double = 1.0) { self.effectDuration = effectDuration self.useLegacyDecay = useLegacyDecay + self.riseScale = riseScale } public func computeEffect( @@ -47,7 +52,10 @@ public class StandardRetrospectiveCorrection: RetrospectiveCorrection { } // Standard retrospective correction math - let currentDiscrepancyValue = currentDiscrepancy.quantity.doubleValue(for: unit) + var currentDiscrepancyValue = currentDiscrepancy.quantity.doubleValue(for: unit) + if riseScale != 1.0 && currentDiscrepancyValue >= 0 { + currentDiscrepancyValue *= riseScale + } totalGlucoseCorrectionEffect = LoopQuantity(unit: unit, doubleValue: currentDiscrepancyValue) let retrospectionTimeInterval = currentDiscrepancy.endDate.timeIntervalSince(currentDiscrepancy.startDate) diff --git a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift new file mode 100644 index 0000000..bfdd958 --- /dev/null +++ b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift @@ -0,0 +1,269 @@ +// +// AlgorithmExperimentsTests.swift +// LoopAlgorithmTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import LoopAlgorithm + +final class AlgorithmExperimentsTests: XCTestCase { + + private let unit = LoopUnit.milligramsPerDeciliter + + private func loadScenario(_ name: String) -> AlgorithmInputFixture { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = Bundle.module.url(forResource: name + "_scenario", withExtension: "json", subdirectory: "Fixtures")! + return try! decoder.decode(AlgorithmInputFixture.self, from: try! Data(contentsOf: url)) + } + + private func glucoseSeries(_ values: [Double], endingAt end: Date, spacing: TimeInterval = .minutes(5)) -> [FixtureGlucoseSample] { + values.enumerated().map { i, v in + FixtureGlucoseSample(startDate: end.addingTimeInterval(-Double(values.count - 1 - i) * spacing), quantity: .glucose(v)) + } + } + + private func assertSameOutput(_ a: AlgorithmOutput, _ b: AlgorithmOutput, + file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(a.recommendation, b.recommendation, file: file, line: line) + XCTAssertEqual(a.predictedGlucose.map { $0.quantity.doubleValue(for: unit) }, + b.predictedGlucose.map { $0.quantity.doubleValue(for: unit) }, file: file, line: line) + } + + // MARK: - Default is standard LoopAlgorithm + + func testExistingFixturesDecodeWithoutExperiments() { + XCTAssertNil(loadScenario("suspend").experiment) + XCTAssertNil(loadScenario("carbs_with_isf_change").experiment) + } + + func testGatedExperimentsAreInertWhenTheirGateIsClosed() { + // Mock: glucose rising 100 -> 160, no low. With carbs on board the band and the runway reserve are + // gated off, and there is no low for the post-low cut: the experiment must change nothing. + let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = AlgorithmInputFixture.mock(for: now) + input.recommendationType = .automaticBolus + input.carbEntries = [FixtureCarbEntry(absorptionTime: .hours(3), startDate: now.addingTimeInterval(.minutes(-30)), quantity: .carbs(30))] + let standard = LoopAlgorithm.run(input: input) + input.experiment = .guardAndRunway + input.volatilityBaseline = 5 + assertSameOutput(standard, LoopAlgorithm.run(input: input)) + } + + func testWithoutABaselineThereIsNoBand() { + // No baseline and under a day of glucose: no band. The mock rises 20 mg/dL per 5 min to 160, so the + // runway reserve (σ5 = 20) has no room, and there is no low: nothing changes. + let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = AlgorithmInputFixture.mock(for: now) + let standard = LoopAlgorithm.run(input: input) + input.experiment = .guardAndRunway + assertSameOutput(standard, LoopAlgorithm.run(input: input)) + XCTAssertNil(ExperimentParameters(experiment: .guardAndRunway, volatilityBaseline: nil).volatilityBand) + XCTAssertNotNil(ExperimentParameters(experiment: .guardAndRunway, volatilityBaseline: nil).postLowRiseCut) + XCTAssertNotNil(ExperimentParameters(experiment: .guardAndRunway, volatilityBaseline: nil).runwayReserve) + } + + func testRunwayFillsTheGapsInASparseForecast() { + // Loop drops forecast points once nothing changes: 0–55 min then a final point at 370. Filling the + // gap must give the same reserve as the dense flat forecast. + let now = Date() + let dense = flatForecast(200, from: now) + let sparse = Array(dense.prefix(12)) + [dense.last!] + func reserve(_ p: [PredictedGlucoseValue], _ volatility: Double) -> Double { + LoopAlgorithm.runwayAimShift(prediction: p, reserve: ExperimentParameters.RunwayReserve(), currentGlucose: 200, + activeCarbs: 0, volatility: volatility, sensitivity: 50, basalRate: 1.0, + targetMidpoint: 105, suspendThreshold: 70, + insulinModel: ExponentialInsulinModelPreset.rapidActingAdult) + } + XCTAssertEqual(reserve(sparse, 6), reserve(dense, 6), accuracy: 1e-9) + XCTAssertEqual(reserve(sparse, 20), 0) + } + + func testParameters() { + let p = ExperimentParameters(experiment: .guardAndRunway, volatilityBaseline: 5) + XCTAssertEqual(p.volatilityBand?.gain, 1.0) // min(1, 6.7 / 5) + XCTAssertEqual(ExperimentParameters(experiment: .guardAndRunway, volatilityBaseline: 10).volatilityBand!.gain, 0.67, accuracy: 1e-12) + XCTAssertEqual(p.postLowRiseCut, ExperimentParameters.PostLowRiseCut()) + XCTAssertEqual(p.runwayReserve, ExperimentParameters.RunwayReserve()) + XCTAssertEqual(p.runwayReserve?.fraction, 0.7) + XCTAssertEqual(p.runwayReserve?.minGlucose, 150) + XCTAssertEqual(ExperimentParameters(experiment: nil, volatilityBaseline: 5), ExperimentParameters.none) + } + + // MARK: - Retrospective correction gains + + func testStandardRCRiseScale() { + let now = Date() + let latest = SimpleGlucoseValue(startDate: now, quantity: .glucose(150)) + func total(_ discrepancy: Double, rise: Double) -> Double { + let rc = StandardRetrospectiveCorrection(effectDuration: .minutes(60), riseScale: rise) + let change = GlucoseChange(startDate: now.addingTimeInterval(.minutes(-30)), endDate: now, quantity: .glucose(discrepancy)) + _ = rc.computeEffect(startingAt: latest, retrospectiveGlucoseDiscrepanciesSummed: [change], + recencyInterval: .minutes(15), retrospectiveCorrectionGroupingInterval: .minutes(30)) + return rc.totalGlucoseCorrectionEffect!.doubleValue(for: unit) + } + XCTAssertEqual(total(10, rise: 0.3), 3, accuracy: 1e-9) + XCTAssertEqual(total(-10, rise: 0.3), -10, accuracy: 1e-9) // the falling side is untouched + XCTAssertEqual(total(10, rise: 1), 10, accuracy: 1e-9) + } + + // MARK: - Post-low rise-cut gate + + func testPostLowRiseCutGate() { + let now = Date() + let cut = ExperimentParameters.PostLowRiseCut() + XCTAssertTrue(cut.isActive(glucoseHistory: glucoseSeries([90, 65, 80, 120], endingAt: now))) + XCTAssertFalse(cut.isActive(glucoseHistory: glucoseSeries([90, 65, 120, 190], endingAt: now)), "released above 180") + XCTAssertFalse(cut.isActive(glucoseHistory: glucoseSeries([90, 100, 110, 120], endingAt: now)), "no low") + let old = [FixtureGlucoseSample(startDate: now.addingTimeInterval(.hours(-13)), quantity: .glucose(60))] + + glucoseSeries([100, 110, 120], endingAt: now) + XCTAssertFalse(cut.isActive(glucoseHistory: old), "low outside the 12 h window") + } + + func testPostLowRiseCutLowersTheForecastAfterALow() { + // Rebound after a low, with insulin on board: the unexplained rise is cut, so the forecast is lower. + let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = AlgorithmInputFixture.mock(for: now) + input.glucoseHistory = glucoseSeries([62, 66, 75, 90, 110, 130], endingAt: now.addingTimeInterval(.minutes(-1))) + let standard = LoopAlgorithm.run(input: input) + input.experiment = .guardAndRunway + let cut = LoopAlgorithm.run(input: input) + XCTAssertLessThan(cut.predictedGlucose.last!.quantity.doubleValue(for: unit), + standard.predictedGlucose.last!.quantity.doubleValue(for: unit)) + } + + // MARK: - Volatility + + func testSigma5() { + let now = Date() + XCTAssertEqual(GlucoseVolatility.sigma5(glucoseSeries([120, 120, 120, 120], endingAt: now)), (2 * 1.4 * 1.4).squareRoot(), accuracy: 1e-9) + // Constant 10 mg/dL steps: the EWMA of 100 is 100. + XCTAssertEqual(GlucoseVolatility.sigma5(glucoseSeries([100, 110, 120, 130, 140], endingAt: now)), 10, accuracy: 1e-9) + // A gap > 7 min restarts the estimate: only the steps after the gap count. + let gapped = glucoseSeries([100, 140, 180], endingAt: now.addingTimeInterval(.minutes(-30))) + + glucoseSeries([100, 102], endingAt: now) + XCTAssertEqual(GlucoseVolatility.sigma5(gapped), 2.0, accuracy: 1e-9) // one 2 mg/dL step; 4 > floor 3.92 + } + + func testSigma5OnOneMinuteCGM() { + // The same 10 mg/dL per 5 min trend sampled every minute must read like 5-minute data, not the floor. + let now = Date() + let oneMinute = (0...40).map { FixtureGlucoseSample(startDate: now.addingTimeInterval(.minutes(Double($0 - 40))), quantity: .glucose(100 + 2 * Double($0))) } + XCTAssertEqual(GlucoseVolatility.sigma5(oneMinute), 10, accuracy: 1e-9) + } + + func testBaselineIsTheMedianOfSigma5() { + let now = Date() + XCTAssertNil(GlucoseVolatility.baseline(glucoseSeries(Array(repeating: 120, count: 100), endingAt: now)), "under a day") + // A calm stretch then a longer +/-10 stretch: the median is volatile, above the floor and at most 10. + let flat = Array(repeating: 120.0, count: 100) + let zig = (0..<200).map { $0 % 2 == 0 ? 120.0 : 130.0 } + let b = GlucoseVolatility.baseline(glucoseSeries(flat + zig, endingAt: now))! + XCTAssertGreaterThan(b, (2 * 1.4 * 1.4).squareRoot()) + XCTAssertLessThanOrEqual(b, 10) + } + + func testVolatilityBandShape() { + let now = Date() + let glucose = (0...48).map { PredictedGlucoseValue(startDate: now.addingTimeInterval(.minutes(5 * Double($0))), quantity: .glucose(120)) } + let band = ExperimentParameters.VolatilityBand(gain: 1.0) + let banded = LoopAlgorithm.volatilityBanded(glucose, band: band, volatility: 4.0) + let offset: (Int) -> Double = { banded[$0].quantity.doubleValue(for: self.unit) - 120 } + XCTAssertEqual(offset(0), 0, accuracy: 1e-9) + XCTAssertEqual(offset(12), -4.0 * pow(12.0, 0.71), accuracy: 1e-9) // 60 min: full depth + XCTAssertEqual(offset(18), -4.0 * pow(12.0, 0.71) / 2, accuracy: 1e-9) // 90 min: half way down the taper + XCTAssertEqual(offset(24), 0, accuracy: 1e-9) // 120 min: gone + XCTAssertEqual(offset(48), 0, accuracy: 1e-9) // eventual untouched + } + + // MARK: - Runway reserve + + private func flatForecast(_ value: Double, from now: Date) -> [PredictedGlucoseValue] { + (0...72).map { PredictedGlucoseValue(startDate: now.addingTimeInterval(.minutes(5 * Double($0))), quantity: .glucose(value)) } + } + + private func shift(_ forecast: Double, current: Double? = nil, carbs: Double = 0, volatility: Double) -> Double { + let now = Date() + return LoopAlgorithm.runwayAimShift( + prediction: flatForecast(forecast, from: now), reserve: ExperimentParameters.RunwayReserve(), + currentGlucose: current ?? forecast, activeCarbs: carbs, volatility: volatility, + sensitivity: 50, basalRate: 1.0, targetMidpoint: 105, suspendThreshold: 70, + insulinModel: ExponentialInsulinModelPreset.rapidActingAdult) + } + + func testRunwayGates() { + XCTAssertEqual(shift(140, volatility: 2), 0, "below 150") + XCTAssertEqual(shift(200, carbs: 5, volatility: 2), 0, "carbs on board") + XCTAssertGreaterThan(shift(200, volatility: 2), 0) + let now = Date() + let short = Array(flatForecast(200, from: now).prefix(30)) // 145 min, short of 180 + XCTAssertEqual(LoopAlgorithm.runwayAimShift( + prediction: short, reserve: ExperimentParameters.RunwayReserve(), currentGlucose: 200, activeCarbs: 0, + volatility: 2, sensitivity: 50, basalRate: 1.0, targetMidpoint: 105, suspendThreshold: 70, + insulinModel: ExponentialInsulinModelPreset.rapidActingAdult), 0, "forecast shorter than the horizon") + } + + func testRunwayShrinksWithVolatilityAndIsCapped() { + let calm = shift(200, volatility: 2), mid = shift(200, volatility: 6), volatile = shift(200, volatility: 30) + XCTAssertGreaterThan(calm, mid) + XCTAssertGreaterThan(mid, 0) + XCTAssertEqual(volatile, 0, "a plausible fall reaches the suspend threshold: no room") + XCTAssertEqual(shift(400, volatility: 2), 60, "capped at 60 mg/dL") + XCTAssertLessThanOrEqual(calm, 60) + } + + func testRunwayAddsAutomaticInsulinOnACalmHighButNotToTheManualRecommendation() { + let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = AlgorithmInputFixture.mock(for: now) + input.glucoseHistory = glucoseSeries(Array(repeating: 220, count: 12), endingAt: now.addingTimeInterval(.minutes(-1))) + // No baseline and under a day of history: no band, so only the runway can act. No low: no post-low cut. + for type in [DoseRecommendationType.tempBasal, .automaticBolus] { + input.recommendationType = type + input.experiment = nil + let standard = LoopAlgorithm.run(input: input) + input.experiment = .guardAndRunway + let runway = LoopAlgorithm.run(input: input) + XCTAssertEqual(runway.predictedGlucose.map { $0.quantity.doubleValue(for: unit) }, + standard.predictedGlucose.map { $0.quantity.doubleValue(for: unit) }, "the forecast is unchanged") + if type == .tempBasal { + XCTAssertGreaterThan(runway.recommendation!.automatic!.basalAdjustment.unitsPerHour, + standard.recommendation!.automatic!.basalAdjustment.unitsPerHour) + } else { + XCTAssertGreaterThan(runway.recommendation!.automatic!.bolusUnits!, standard.recommendation!.automatic!.bolusUnits!) + } + } + input.recommendationType = .manualBolus + input.experiment = nil + let standard = LoopAlgorithm.run(input: input) + input.experiment = .guardAndRunway + assertSameOutput(standard, LoopAlgorithm.run(input: input)) + } + + func testTargetLoweredFloors() { + let now = Date() + let target: GlucoseRangeTimeline = [AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(.hours(1)), + value: ClosedRange(uncheckedBounds: (lower: .glucose(100), upper: .glucose(110))))] + let lowered = LoopAlgorithm.targetLowered(target, by: 25).first!.value + XCTAssertEqual(lowered.lowerBound.doubleValue(for: unit), 75) + XCTAssertEqual(lowered.upperBound.doubleValue(for: unit), 85) + let floored = LoopAlgorithm.targetLowered(target, by: 60).first!.value + XCTAssertEqual(floored.lowerBound.doubleValue(for: unit), 70) + XCTAssertEqual(floored.upperBound.doubleValue(for: unit), 80) + } + + // MARK: - Codable + + func testFixtureRoundTripCarriesTheExperiment() throws { + var input = loadScenario("suspend") + input.experiment = .guardAndRunway + input.volatilityBaseline = 4.2 + let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + let back = try decoder.decode(AlgorithmInputFixture.self, from: encoder.encode(input)) + XCTAssertEqual(back.experiment, .guardAndRunway) + XCTAssertEqual(back.volatilityBaseline, 4.2) + XCTAssertEqual(AlgorithmExperiment.allCases, [.guardAndRunway]) + } +} From 30e2c2e24f3bd8485e3b1beec7e94b604d2691fa Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 23 Oct 2026 12:30:39 -0500 Subject: [PATCH 2/3] Algorithm experiments: a combinable set, adding integral RC and glucose-based application factor AlgorithmInput.experiment (one optional value) becomes experiments, a Set that defaults to empty, so experiments combine: each acts on its own part of the algorithm, and each combination is its own group when results are pooled. Fixtures encode it as a sorted array of names. Two experiments join guardAndRunway, both as on/off switches with no parameters: - integralRetrospectiveCorrection: the same as useIntegralRetrospectiveCorrection; either one enables integral RC. With guardAndRunway, the post-low rise-cut applies to integral RC's rise side as before. - glucoseBasedApplicationFactor: Loop's glucose-based partial application for automatic-bolus dosing. The factor rises with current glucose from 0.2 at the lower correction target + 10 mg/dL to 0.8 at 200 mg/dL (Loop's GlucoseBasedApplicationFactorStrategy, keyed on the target in effect now), and replaces automaticBolusApplicationFactor and, through it, the automatic-bolus cap. Temp-basal and manual recommendations are unchanged. Tests: the IRC experiment reproduces the input flag on two fixtures; the factor's ramp; the factor replaces the constant for automatic bolus only; a combination round-trips through a fixture. --- Sources/LoopAlgorithm/AlgorithmInput.swift | 8 +- .../LoopAlgorithm/AlgorithmInputFixture.swift | 12 ++- .../Experiments/AlgorithmExperiments.swift | 43 ++++++-- Sources/LoopAlgorithm/LoopAlgorithm.swift | 31 ++++-- .../AlgorithmExperimentsTests.swift | 100 ++++++++++++++---- 5 files changed, 148 insertions(+), 46 deletions(-) diff --git a/Sources/LoopAlgorithm/AlgorithmInput.swift b/Sources/LoopAlgorithm/AlgorithmInput.swift index 29096fc..5885903 100644 --- a/Sources/LoopAlgorithm/AlgorithmInput.swift +++ b/Sources/LoopAlgorithm/AlgorithmInput.swift @@ -35,9 +35,9 @@ public protocol AlgorithmInput { var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4 var gradualTransitionsThreshold: Double? { get } var emulation: AlgorithmEmulationOptions? { get } // Defaults to nil (no emulation) - /// Optional algorithm experiment; nil is standard LoopAlgorithm. Conforming types that do not - /// provide it get nil. - var experiment: AlgorithmExperiment? { get } + /// Optional algorithm experiments; the empty set is standard LoopAlgorithm. Conforming types that do + /// not provide it get the empty set. + var experiments: Set { get } /// The person's volatility baseline for the `guardAndRunway` experiment's volatility band: the median /// σ5 (mg/dL per 5 min) over their last four weeks of glucose, from `GlucoseVolatility.baseline(_:)`. /// When nil it is computed from `glucoseHistory` if that holds at least a day of readings; otherwise @@ -47,6 +47,6 @@ public protocol AlgorithmInput { public extension AlgorithmInput { var emulation: AlgorithmEmulationOptions? { nil } - var experiment: AlgorithmExperiment? { nil } + var experiments: Set { [] } var volatilityBaseline: Double? { nil } } diff --git a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift index fb33cb8..9493f06 100644 --- a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift +++ b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift @@ -36,8 +36,8 @@ public struct AlgorithmInputFixture: AlgorithmInput { public var automaticBolusApplicationFactor: Double? public var gradualTransitionsThreshold: Double? public var emulation: AlgorithmEmulationOptions? - /// Optional experiment; absent in existing fixtures, which therefore run standard LoopAlgorithm. - public var experiment: AlgorithmExperiment? + /// Optional experiments; absent in existing fixtures, which therefore run standard LoopAlgorithm. + public var experiments: Set = [] public var volatilityBaseline: Double? public var recommendationInsulinModel: InsulinModel { @@ -155,7 +155,7 @@ extension AlgorithmInputFixture: Codable { self.automaticBolusApplicationFactor = try container.decodeIfPresent(Double.self, forKey: .automaticBolusApplicationFactor) self.gradualTransitionsThreshold = try container.decodeIfPresent(Double.self, forKey: .gradualTransitionsThreshold) ?? 40.0 self.emulation = try container.decodeIfPresent(AlgorithmEmulationOptions.self, forKey: .emulation) - self.experiment = try container.decodeIfPresent(AlgorithmExperiment.self, forKey: .experiment) + self.experiments = Set(try container.decodeIfPresent([AlgorithmExperiment].self, forKey: .experiments) ?? []) self.volatilityBaseline = try container.decodeIfPresent(Double.self, forKey: .volatilityBaseline) } @@ -195,7 +195,9 @@ extension AlgorithmInputFixture: Codable { if let emulation { try container.encode(emulation, forKey: .emulation) } - try container.encodeIfPresent(experiment, forKey: .experiment) + if !experiments.isEmpty { + try container.encode(experiments.map(\.rawValue).sorted(), forKey: .experiments) + } try container.encodeIfPresent(volatilityBaseline, forKey: .volatilityBaseline) } @@ -220,7 +222,7 @@ extension AlgorithmInputFixture: Codable { case automaticBolusApplicationFactor case gradualTransitionsThreshold case emulation - case experiment + case experiments case volatilityBaseline } } diff --git a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift index d5a46c1..77ee459 100644 --- a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift +++ b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift @@ -7,12 +7,11 @@ import Foundation -/// An optional algorithm experiment. `nil` (the default everywhere) is standard LoopAlgorithm. +/// Optional algorithm experiments. The empty set (the default everywhere) is standard LoopAlgorithm. /// -/// There is deliberately one, with no tunable parameters, so that people who choose to try it pool into -/// one large group whose results can be compared. It is a fixed configuration evaluated in LoopEval -/// (closed-loop counterfactual replay of real CGM and insulin data, and a simulated cohort, scored against -/// the standard algorithm swept over insulin needs). +/// Each experiment is an on/off switch with no tunable parameters, so that people who choose the same +/// switches pool into one group whose results can be compared. Experiments combine: each acts on its own +/// part of the algorithm, and a combination is its own group. public enum AlgorithmExperiment: String, Codable, CaseIterable, Sendable { /// LoopEval guard + runway (C20 + C29 + C40). A lows guard, plus a little more correction when there @@ -32,6 +31,32 @@ public enum AlgorithmExperiment: String, Codable, CaseIterable, Sendable { /// an internal lowering of the correction aim for automatic dosing only: the user's target, the /// manual bolus recommendation and the suspend threshold are unchanged. case guardAndRunway + + /// Integral retrospective correction: retrospective correction integrates the recent unexplained + /// glucose change instead of projecting only the last 30 minutes. The same as + /// `AlgorithmInput.useIntegralRetrospectiveCorrection`; either one enables it. + case integralRetrospectiveCorrection + + /// Glucose-based application factor, for automatic-bolus dosing: the share of the correction given + /// now rises with current glucose, from 0.2 at the lower correction target + 10 mg/dL to 0.8 at + /// 200 mg/dL, instead of a constant. It replaces `AlgorithmInput.automaticBolusApplicationFactor` + /// and, through it, the automatic-bolus cap. Temp-basal dosing is unaffected. + case glucoseBasedApplicationFactor +} + +/// Loop's glucose-based application factor (`glucoseBasedApplicationFactor`). +enum GlucoseBasedApplicationFactor { + static let minFactor = 0.2 + static let maxFactor = 0.8 + static let lowerTargetDelta = 10.0 // mg/dL above the lower correction target where the ramp starts + static let rampTop = 200.0 // mg/dL where the factor reaches maxFactor + + /// `glucose` and `lowerTarget` in mg/dL. + static func factor(glucose: Double, lowerTarget: Double) -> Double { + let rampStart = lowerTarget + lowerTargetDelta + let slope = (maxFactor - minFactor) / (rampTop - rampStart) + return min(minFactor + max(glucose - rampStart, 0) * slope, maxFactor) + } } /// The internal configuration the experiment expands to. Not public: the experiment is fixed. @@ -45,11 +70,9 @@ struct ExperimentParameters: Equatable { var needsVolatility: Bool { volatilityBand != nil || runwayReserve != nil } /// `volatilityBaseline` is the person's median σ5 (mg/dL per 5 min); only the band uses it. - init(experiment: AlgorithmExperiment?, volatilityBaseline: Double?) { - switch experiment { - case nil: - break - case .guardAndRunway: + /// Integral RC and the glucose-based application factor are switched where they act, not here. + init(experiments: Set, volatilityBaseline: Double?) { + if experiments.contains(.guardAndRunway) { postLowRiseCut = PostLowRiseCut() runwayReserve = RunwayReserve() if let baseline = volatilityBaseline, baseline.isFinite, baseline > 0 { diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index e19e66c..cbf3b07 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -185,7 +185,7 @@ public struct LoopAlgorithm { gradualTransitionsThreshold: Double? = 40.0, momentumVelocityMaximum: LoopQuantity? = nil, emulation: AlgorithmEmulationOptions = AlgorithmEmulationOptions(), - experiment: AlgorithmExperiment? = nil + experiments: Set = [] ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { var prediction: [PredictedGlucoseValue] = [] @@ -260,15 +260,15 @@ public struct LoopAlgorithm { let rc: RetrospectiveCorrection - // Experiment (no-op when `experiment` is nil): the post-low rise-cut weights the rising side of + // Experiments (no-ops when `experiments` is empty): the post-low rise-cut weights the rising side of // retrospective correction while its gate is open. var rcRiseScale = 1.0 - if let cut = ExperimentParameters(experiment: experiment, volatilityBaseline: nil).postLowRiseCut, + if let cut = ExperimentParameters(experiments: experiments, volatilityBaseline: nil).postLowRiseCut, cut.isActive(glucoseHistory: glucoseHistory) { rcRiseScale = cut.riseScale } - if useIntegralRetrospectiveCorrection { + if useIntegralRetrospectiveCorrection || experiments.contains(.integralRetrospectiveCorrection) { rc = IntegralRetrospectiveCorrection( effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, maxCorrectionVelocity: emulation.disableIRCVelocityCeiling ? nil : IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity, @@ -618,6 +618,19 @@ public struct LoopAlgorithm { ) } + /// The automatic-bolus application factor: the glucose-based factor when that experiment is on (keyed on + /// current glucose and the lower correction target in effect now), else the input's constant. + static func automaticBolusApplicationFactor(input: any AlgorithmInput, currentGlucose: some GlucoseSampleValue) -> Double { + if input.experiments.contains(.glucoseBasedApplicationFactor), + let correctionRange = input.target.closestPrior(to: input.predictionStart)?.value + { + return GlucoseBasedApplicationFactor.factor( + glucose: currentGlucose.quantity.doubleValue(for: .milligramsPerDeciliter), + lowerTarget: correctionRange.lowerBound.doubleValue(for: .milligramsPerDeciliter)) + } + return input.automaticBolusApplicationFactor ?? defaultBolusPartialApplicationFactor + } + // Computes an amount of insulin to correct the given prediction public static func insulinCorrection( prediction: [PredictedGlucoseValue], @@ -801,14 +814,14 @@ public struct LoopAlgorithm { carbAbsorptionModel: input.carbAbsorptionModel.model, gradualTransitionsThreshold: input.gradualTransitionsThreshold, emulation: input.emulation ?? AlgorithmEmulationOptions(), - experiment: input.experiment + experiments: input.experiments ) - // Experiment adjustments before dosing (no-ops when `experiment` is nil). The runway reserve + // Experiment adjustments before dosing (no-ops when `experiments` is empty). The runway reserve // reads the forecast before the volatility band lowers it; its band term is its own. let experimentParameters = ExperimentParameters( - experiment: input.experiment, - volatilityBaseline: input.experiment != nil + experiments: input.experiments, + volatilityBaseline: input.experiments.contains(.guardAndRunway) ? (input.volatilityBaseline ?? GlucoseVolatility.baseline(input.glucoseHistory)) : nil) let volatility: Double? = experimentParameters.needsVolatility ? GlucoseVolatility.sigma5(input.glucoseHistory) : nil var runwayShift = 0.0 @@ -872,7 +885,7 @@ public struct LoopAlgorithm { case .automaticBolus: let recommendation = recommendAutomaticDose( for: correction, - applicationFactor: input.automaticBolusApplicationFactor ?? defaultBolusPartialApplicationFactor, + applicationFactor: automaticBolusApplicationFactor(input: input, currentGlucose: latestGlucose), neutralBasalRate: scheduledBasalRate, activeInsulin: prediction.activeInsulin!, maxBolus: input.maxBolus, diff --git a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift index bfdd958..b09224e 100644 --- a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift +++ b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift @@ -35,8 +35,8 @@ final class AlgorithmExperimentsTests: XCTestCase { // MARK: - Default is standard LoopAlgorithm func testExistingFixturesDecodeWithoutExperiments() { - XCTAssertNil(loadScenario("suspend").experiment) - XCTAssertNil(loadScenario("carbs_with_isf_change").experiment) + XCTAssertTrue(loadScenario("suspend").experiments.isEmpty) + XCTAssertTrue(loadScenario("carbs_with_isf_change").experiments.isEmpty) } func testGatedExperimentsAreInertWhenTheirGateIsClosed() { @@ -47,7 +47,7 @@ final class AlgorithmExperimentsTests: XCTestCase { input.recommendationType = .automaticBolus input.carbEntries = [FixtureCarbEntry(absorptionTime: .hours(3), startDate: now.addingTimeInterval(.minutes(-30)), quantity: .carbs(30))] let standard = LoopAlgorithm.run(input: input) - input.experiment = .guardAndRunway + input.experiments = [.guardAndRunway] input.volatilityBaseline = 5 assertSameOutput(standard, LoopAlgorithm.run(input: input)) } @@ -58,11 +58,11 @@ final class AlgorithmExperimentsTests: XCTestCase { let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! var input = AlgorithmInputFixture.mock(for: now) let standard = LoopAlgorithm.run(input: input) - input.experiment = .guardAndRunway + input.experiments = [.guardAndRunway] assertSameOutput(standard, LoopAlgorithm.run(input: input)) - XCTAssertNil(ExperimentParameters(experiment: .guardAndRunway, volatilityBaseline: nil).volatilityBand) - XCTAssertNotNil(ExperimentParameters(experiment: .guardAndRunway, volatilityBaseline: nil).postLowRiseCut) - XCTAssertNotNil(ExperimentParameters(experiment: .guardAndRunway, volatilityBaseline: nil).runwayReserve) + XCTAssertNil(ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: nil).volatilityBand) + XCTAssertNotNil(ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: nil).postLowRiseCut) + XCTAssertNotNil(ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: nil).runwayReserve) } func testRunwayFillsTheGapsInASparseForecast() { @@ -82,14 +82,17 @@ final class AlgorithmExperimentsTests: XCTestCase { } func testParameters() { - let p = ExperimentParameters(experiment: .guardAndRunway, volatilityBaseline: 5) + let p = ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: 5) XCTAssertEqual(p.volatilityBand?.gain, 1.0) // min(1, 6.7 / 5) - XCTAssertEqual(ExperimentParameters(experiment: .guardAndRunway, volatilityBaseline: 10).volatilityBand!.gain, 0.67, accuracy: 1e-12) + XCTAssertEqual(ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: 10).volatilityBand!.gain, 0.67, accuracy: 1e-12) XCTAssertEqual(p.postLowRiseCut, ExperimentParameters.PostLowRiseCut()) XCTAssertEqual(p.runwayReserve, ExperimentParameters.RunwayReserve()) XCTAssertEqual(p.runwayReserve?.fraction, 0.7) XCTAssertEqual(p.runwayReserve?.minGlucose, 150) - XCTAssertEqual(ExperimentParameters(experiment: nil, volatilityBaseline: 5), ExperimentParameters.none) + XCTAssertEqual(ExperimentParameters(experiments: [], volatilityBaseline: 5), ExperimentParameters.none) + // Integral RC and the glucose-based factor are switched where they act; they add no parameters here. + XCTAssertEqual(ExperimentParameters(experiments: [.integralRetrospectiveCorrection, .glucoseBasedApplicationFactor], + volatilityBaseline: 5), ExperimentParameters.none) } // MARK: - Retrospective correction gains @@ -128,7 +131,7 @@ final class AlgorithmExperimentsTests: XCTestCase { var input = AlgorithmInputFixture.mock(for: now) input.glucoseHistory = glucoseSeries([62, 66, 75, 90, 110, 130], endingAt: now.addingTimeInterval(.minutes(-1))) let standard = LoopAlgorithm.run(input: input) - input.experiment = .guardAndRunway + input.experiments = [.guardAndRunway] let cut = LoopAlgorithm.run(input: input) XCTAssertLessThan(cut.predictedGlucose.last!.quantity.doubleValue(for: unit), standard.predictedGlucose.last!.quantity.doubleValue(for: unit)) @@ -221,9 +224,9 @@ final class AlgorithmExperimentsTests: XCTestCase { // No baseline and under a day of history: no band, so only the runway can act. No low: no post-low cut. for type in [DoseRecommendationType.tempBasal, .automaticBolus] { input.recommendationType = type - input.experiment = nil + input.experiments = [] let standard = LoopAlgorithm.run(input: input) - input.experiment = .guardAndRunway + input.experiments = [.guardAndRunway] let runway = LoopAlgorithm.run(input: input) XCTAssertEqual(runway.predictedGlucose.map { $0.quantity.doubleValue(for: unit) }, standard.predictedGlucose.map { $0.quantity.doubleValue(for: unit) }, "the forecast is unchanged") @@ -235,9 +238,9 @@ final class AlgorithmExperimentsTests: XCTestCase { } } input.recommendationType = .manualBolus - input.experiment = nil + input.experiments = [] let standard = LoopAlgorithm.run(input: input) - input.experiment = .guardAndRunway + input.experiments = [.guardAndRunway] assertSameOutput(standard, LoopAlgorithm.run(input: input)) } @@ -257,13 +260,74 @@ final class AlgorithmExperimentsTests: XCTestCase { func testFixtureRoundTripCarriesTheExperiment() throws { var input = loadScenario("suspend") - input.experiment = .guardAndRunway + input.experiments = [.guardAndRunway] input.volatilityBaseline = 4.2 let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601 let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 let back = try decoder.decode(AlgorithmInputFixture.self, from: encoder.encode(input)) - XCTAssertEqual(back.experiment, .guardAndRunway) + XCTAssertEqual(back.experiments, [.guardAndRunway]) XCTAssertEqual(back.volatilityBaseline, 4.2) - XCTAssertEqual(AlgorithmExperiment.allCases, [.guardAndRunway]) + XCTAssertEqual(AlgorithmExperiment.allCases, [.guardAndRunway, .integralRetrospectiveCorrection, .glucoseBasedApplicationFactor]) + } + + func testFixtureRoundTripCarriesACombination() throws { + var input = loadScenario("suspend") + input.experiments = [.glucoseBasedApplicationFactor, .guardAndRunway, .integralRetrospectiveCorrection] + let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601; encoder.outputFormatting = .sortedKeys + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + let data = try encoder.encode(input) + XCTAssertEqual(try decoder.decode(AlgorithmInputFixture.self, from: data).experiments, input.experiments) + XCTAssertTrue(String(decoding: data, as: UTF8.self) + .contains(#""experiments":["glucoseBasedApplicationFactor","guardAndRunway","integralRetrospectiveCorrection"]"#)) + } + + // MARK: - Integral retrospective correction + + func testIntegralRCExperimentIsTheInputFlag() { + for name in ["suspend", "carbs_with_isf_change"] { + var flag = loadScenario(name) + flag.useIntegralRetrospectiveCorrection = true + var experiment = loadScenario(name) + experiment.experiments = [.integralRetrospectiveCorrection] + assertSameOutput(LoopAlgorithm.run(input: flag), LoopAlgorithm.run(input: experiment)) + flag.experiments = [.integralRetrospectiveCorrection] // both: still integral RC, once + assertSameOutput(LoopAlgorithm.run(input: flag), LoopAlgorithm.run(input: experiment)) + } + } + + // MARK: - Glucose-based application factor + + func testGlucoseBasedApplicationFactorRamp() { + // Lower target 100: the ramp runs 0.2 at 110 to 0.8 at 200. + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 90, lowerTarget: 100), 0.2, accuracy: 1e-12) + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 110, lowerTarget: 100), 0.2, accuracy: 1e-12) + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 155, lowerTarget: 100), 0.5, accuracy: 1e-12) + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 200, lowerTarget: 100), 0.8, accuracy: 1e-12) + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 260, lowerTarget: 100), 0.8, accuracy: 1e-12) + // Lower target 90: 0.2 at 100, 0.5 at 150. + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 150, lowerTarget: 90), 0.5, accuracy: 1e-12) + } + + func testGlucoseBasedApplicationFactorReplacesTheConstantForAutomaticBolusOnly() { + let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = AlgorithmInputFixture.mock(for: now) + input.glucoseHistory = glucoseSeries(Array(repeating: 155, count: 12), endingAt: now.addingTimeInterval(.minutes(-1))) + // Mock lower target 100, glucose 155: the factor is 0.5, against the default 0.4. + input.recommendationType = .automaticBolus + var constant = input + constant.automaticBolusApplicationFactor = 0.5 + input.automaticBolusApplicationFactor = 0.4 + input.experiments = [.glucoseBasedApplicationFactor] + let gbaf = LoopAlgorithm.run(input: input) + assertSameOutput(gbaf, LoopAlgorithm.run(input: constant)) + input.experiments = [] + XCTAssertGreaterThan(gbaf.recommendation!.automatic!.bolusUnits!, LoopAlgorithm.run(input: input).recommendation!.automatic!.bolusUnits!) + for type in [DoseRecommendationType.tempBasal, .manualBolus] { + input.recommendationType = type + input.experiments = [] + let standard = LoopAlgorithm.run(input: input) + input.experiments = [.glucoseBasedApplicationFactor] + assertSameOutput(standard, LoopAlgorithm.run(input: input)) + } } } From 10c83c004c6db388856559498d6d773e648732b7 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 24 Oct 2026 07:17:13 -0500 Subject: [PATCH 3/3] guardAndRunway: full-depth volatility band when no baseline is available A new user has under a day of glucose, so there is no baseline and the band was off: the experiment added time in range without its lows reduction. The band now runs at full depth (gain 1) until a baseline exists; the baseline only ever lowers it, for people whose glucose is volatile. Evaluated in LoopEval as full depth for everyone over four weeks (the worst case): lift +0.82 vs +0.86 on the simulated cohort and +0.98 vs +0.99 on donor replay, with lows no higher, against +0.55 / +0.99 with the band off. --- Sources/LoopAlgorithm/AlgorithmInput.swift | 2 +- .../Experiments/AlgorithmExperiments.swift | 7 +++- .../AlgorithmExperimentsTests.swift | 35 +++++++++++-------- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/Sources/LoopAlgorithm/AlgorithmInput.swift b/Sources/LoopAlgorithm/AlgorithmInput.swift index 5885903..1cc1e28 100644 --- a/Sources/LoopAlgorithm/AlgorithmInput.swift +++ b/Sources/LoopAlgorithm/AlgorithmInput.swift @@ -41,7 +41,7 @@ public protocol AlgorithmInput { /// The person's volatility baseline for the `guardAndRunway` experiment's volatility band: the median /// σ5 (mg/dL per 5 min) over their last four weeks of glucose, from `GlucoseVolatility.baseline(_:)`. /// When nil it is computed from `glucoseHistory` if that holds at least a day of readings; otherwise - /// the experiment runs without the band. + /// the band runs at full depth. var volatilityBaseline: Double? { get } } diff --git a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift index 77ee459..3367092 100644 --- a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift +++ b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift @@ -22,7 +22,7 @@ public enum AlgorithmExperiment: String, Codable, CaseIterable, Sendable { /// - **Volatility band** (C29). While no carbs are on board, the first two hours of the forecast are /// lowered in proportion to recent glucose volatility, so the low guard sees it. Eventual glucose /// is unchanged. Its depth is keyed to the person's median volatility - /// (`AlgorithmInput.volatilityBaseline`). + /// (`AlgorithmInput.volatilityBaseline`), and is full depth until a baseline exists. /// - **Runway reserve** (C40). While glucose is 150 or above and no carbs are on board: how much /// extra insulin could still be taken back by suspending scheduled basal, if glucose then fell /// along the forecast less one σ of recent volatility, without reaching the suspend threshold @@ -75,8 +75,13 @@ struct ExperimentParameters: Equatable { if experiments.contains(.guardAndRunway) { postLowRiseCut = PostLowRiseCut() runwayReserve = RunwayReserve() + // Without a baseline (a new user, under a day of glucose) the band runs at full depth: the + // baseline only ever lowers it, for people whose glucose is volatile. Full depth is the safer + // side, fewer lows for a little time in range. if let baseline = volatilityBaseline, baseline.isFinite, baseline > 0 { volatilityBand = VolatilityBand(gain: Swift.min(1.0, 6.7 / baseline)) + } else { + volatilityBand = VolatilityBand(gain: 1.0) } } } diff --git a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift index b09224e..861954f 100644 --- a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift +++ b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift @@ -52,17 +52,16 @@ final class AlgorithmExperimentsTests: XCTestCase { assertSameOutput(standard, LoopAlgorithm.run(input: input)) } - func testWithoutABaselineThereIsNoBand() { - // No baseline and under a day of glucose: no band. The mock rises 20 mg/dL per 5 min to 160, so the - // runway reserve (σ5 = 20) has no room, and there is no low: nothing changes. - let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! - var input = AlgorithmInputFixture.mock(for: now) - let standard = LoopAlgorithm.run(input: input) - input.experiments = [.guardAndRunway] - assertSameOutput(standard, LoopAlgorithm.run(input: input)) - XCTAssertNil(ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: nil).volatilityBand) - XCTAssertNotNil(ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: nil).postLowRiseCut) - XCTAssertNotNil(ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: nil).runwayReserve) + func testWithoutABaselineTheBandIsFullDepth() { + // No baseline and under a day of glucose (a new user): the band runs at full depth, and the + // baseline only lowers it. + let p = ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: nil) + XCTAssertEqual(p.volatilityBand?.gain, 1.0) + XCTAssertEqual(p.volatilityBand, ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: 5).volatilityBand) + XCTAssertNotNil(p.postLowRiseCut) + XCTAssertNotNil(p.runwayReserve) + XCTAssertEqual(ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: .nan).volatilityBand?.gain, 1.0) + XCTAssertNil(ExperimentParameters(experiments: [], volatilityBaseline: nil).volatilityBand) } func testRunwayFillsTheGapsInASparseForecast() { @@ -221,15 +220,19 @@ final class AlgorithmExperimentsTests: XCTestCase { let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! var input = AlgorithmInputFixture.mock(for: now) input.glucoseHistory = glucoseSeries(Array(repeating: 220, count: 12), endingAt: now.addingTimeInterval(.minutes(-1))) - // No baseline and under a day of history: no band, so only the runway can act. No low: no post-low cut. + // No baseline: the band runs at full depth and only lowers the forecast, so a rise in automatic dosing + // is the runway's. No low: no post-low cut. for type in [DoseRecommendationType.tempBasal, .automaticBolus] { input.recommendationType = type input.experiments = [] let standard = LoopAlgorithm.run(input: input) input.experiments = [.guardAndRunway] let runway = LoopAlgorithm.run(input: input) - XCTAssertEqual(runway.predictedGlucose.map { $0.quantity.doubleValue(for: unit) }, - standard.predictedGlucose.map { $0.quantity.doubleValue(for: unit) }, "the forecast is unchanged") + let banded = runway.predictedGlucose.map { $0.quantity.doubleValue(for: unit) } + let plain = standard.predictedGlucose.map { $0.quantity.doubleValue(for: unit) } + XCTAssertEqual(banded.count, plain.count) + XCTAssertEqual(banded.last, plain.last, "eventual glucose is unchanged") + XCTAssertTrue(zip(banded, plain).allSatisfy { $0 <= $1 }, "the band only lowers the forecast") if type == .tempBasal { XCTAssertGreaterThan(runway.recommendation!.automatic!.basalAdjustment.unitsPerHour, standard.recommendation!.automatic!.basalAdjustment.unitsPerHour) @@ -237,11 +240,13 @@ final class AlgorithmExperimentsTests: XCTestCase { XCTAssertGreaterThan(runway.recommendation!.automatic!.bolusUnits!, standard.recommendation!.automatic!.bolusUnits!) } } + // The runway does not add to the manual recommendation; the band can only lower it. input.recommendationType = .manualBolus input.experiments = [] let standard = LoopAlgorithm.run(input: input) input.experiments = [.guardAndRunway] - assertSameOutput(standard, LoopAlgorithm.run(input: input)) + let manual = LoopAlgorithm.run(input: input) + XCTAssertLessThanOrEqual(manual.recommendation?.manual?.amount ?? 0, standard.recommendation?.manual?.amount ?? 0) } func testTargetLoweredFloors() {