From 9b67b1dcbc56bba99c501db964cf8f799ff107b3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 31 Oct 2026 20:12:26 -0500 Subject: [PATCH 1/3] guardAndRunway: band for the low guard only, runway fades with carbs; slow retrospective correction experiment guardAndRunway: the volatility band now lowers only the forecast the low guard checks (a banded point below the suspend threshold suspends; the banded minimum is the guard's minimum). The correction is sized, and the forecast shown, from the unbanded forecast. The runway reserve fades out linearly from no carbs on board to 20 g, instead of closing at any carbs. slowRetrospectiveCorrection (new, off by default): ISF x (1 - 0.05 x level), within 0.7-1.3, where level is the 24 h EWMA of the unexplained glucose change (insulin counteraction less carb effect, per 5 min, at the scheduled ISF) over the glucose history passed in, up to 72 h. Stateless: it needs 72 h of glucose, carbs from 72 h plus the longest absorption time, and doses from 72 h plus the insulin activity duration. --- Sources/LoopAlgorithm/AlgorithmOutput.swift | 6 +- .../Experiments/AlgorithmExperiments.swift | 41 ++++++-- .../ExperimentalForecastAdjustments.swift | 65 ++++++++++++- .../Experiments/GuardAndRunwayOutput.swift | 12 ++- Sources/LoopAlgorithm/LoopAlgorithm.swift | 90 ++++++++++++----- .../AlgorithmExperimentsTests.swift | 96 +++++++++++++++++-- 6 files changed, 267 insertions(+), 43 deletions(-) diff --git a/Sources/LoopAlgorithm/AlgorithmOutput.swift b/Sources/LoopAlgorithm/AlgorithmOutput.swift index a820988..da40a36 100644 --- a/Sources/LoopAlgorithm/AlgorithmOutput.swift +++ b/Sources/LoopAlgorithm/AlgorithmOutput.swift @@ -16,6 +16,8 @@ public struct AlgorithmOutput { public var activeCarbs: Double? /// Set when the guardAndRunway experiment ran and dosing was recommended. public var guardAndRunway: GuardAndRunwayOutput? + /// Set when the slowRetrospectiveCorrection experiment ran and dosing was recommended. + public var slowRetrospectiveCorrection: SlowRetrospectiveCorrectionOutput? public init( recommendationResult: Result, @@ -24,7 +26,8 @@ public struct AlgorithmOutput { dosesRelativeToBasal: [BasalRelativeDose], activeInsulin: Double? = nil, activeCarbs: Double? = nil, - guardAndRunway: GuardAndRunwayOutput? = nil + guardAndRunway: GuardAndRunwayOutput? = nil, + slowRetrospectiveCorrection: SlowRetrospectiveCorrectionOutput? = nil ) { self.recommendationResult = recommendationResult self.predictedGlucose = predictedGlucose @@ -33,6 +36,7 @@ public struct AlgorithmOutput { self.activeInsulin = activeInsulin self.activeCarbs = activeCarbs self.guardAndRunway = guardAndRunway + self.slowRetrospectiveCorrection = slowRetrospectiveCorrection } public var recommendation: LoopAlgorithmDoseRecommendation? { diff --git a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift index c21076c..9140f8c 100644 --- a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift +++ b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift @@ -19,15 +19,18 @@ public enum ExperimentOption: String, Codable, CaseIterable, Sendable { /// - **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`), 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 + /// - **Volatility band** (C29). While no carbs are on board, the low guard sees the first two hours of + /// the forecast lowered in proportion to recent glucose volatility: a lowered point below the suspend + /// threshold suspends, and the lowered minimum is the one the guard checks. The correction is sized, + /// and the forecast shown, from the forecast itself, so the band never lowers a correction it does not + /// stop. Its depth is keyed to the person's median volatility (`AlgorithmInput.volatilityBaseline`), + /// and is full depth until a baseline exists. + /// - **Runway reserve** (C40). While glucose is 150 or above and carbs on board are under 20 g: 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 + /// correction, scaled down linearly from no carbs on board to none at 20 g. 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 @@ -42,6 +45,18 @@ public enum ExperimentOption: String, Codable, CaseIterable, Sendable { /// 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 + + /// Slow retrospective correction: a day-scale estimate of how much more or less insulin the person needs + /// than their settings say. Its level is the exponentially weighted mean (time constant 24 h) of the + /// glucose change the forecast model did not explain (insulin counteraction less carb effect, per + /// 5 minutes, at the scheduled ISF) over the glucose history passed in, up to 72 h, from zero at its start. + /// ISF for the whole run is multiplied by `1 − 0.05 × level`, within 0.7–1.3: glucose that keeps rising + /// more than the model expects lowers ISF, and glucose that keeps falling raises it. + /// + /// It is computed from the inputs on every run, with no stored state, so it needs a long history: 72 h of + /// glucose, carb entries from 72 h plus the longest absorption time, and doses from 72 h plus the insulin + /// activity duration. With 12 h of history the level reaches only about 40 % of its working value. + case slowRetrospectiveCorrection } /// Loop's glucose-based application factor (`glucoseBasedApplicationFactor`). @@ -64,6 +79,7 @@ struct ExperimentParameters: Equatable { var postLowRiseCut: PostLowRiseCut? = nil var volatilityBand: VolatilityBand? = nil var runwayReserve: RunwayReserve? = nil + var slowRetrospectiveCorrection: SlowRetrospectiveCorrection? = nil static let none = ExperimentParameters() @@ -84,6 +100,9 @@ struct ExperimentParameters: Equatable { volatilityBand = VolatilityBand(gain: 1.0) } } + if experiments.contains(.slowRetrospectiveCorrection) { + slowRetrospectiveCorrection = SlowRetrospectiveCorrection() + } } init() {} @@ -111,6 +130,14 @@ struct ExperimentParameters: Equatable { 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 + var carbFade = 20.0 // g of carbs on board over which the reserve fades out (0: any carbs close it) + } + + struct SlowRetrospectiveCorrection: Equatable { + var timeConstant: TimeInterval = .hours(24) + var gain = 0.05 // ISF multiplier change per mg/dL per 5 min of level + var minMultiplier = 0.7 + var maxMultiplier = 1.3 + var window: TimeInterval = .hours(72) } } diff --git a/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift b/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift index 8af1219..98e244c 100644 --- a/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift +++ b/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift @@ -59,7 +59,7 @@ extension LoopAlgorithm { suspendThreshold: Double, insulinModel: InsulinModel) -> Double { let unit = LoopUnit.milligramsPerDeciliter guard volatility.isFinite, sensitivity > 0, currentGlucose >= reserve.minGlucose, - !(reserve.carbGate && activeCarbs > 0), + reserve.carbFade > 0 ? activeCarbs < reserve.carbFade : 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 } @@ -99,7 +99,68 @@ extension LoopAlgorithm { 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)) + let carbScale = reserve.carbFade > 0 ? Swift.max(0, 1 - Swift.max(0, activeCarbs) / reserve.carbFade) : 1 + return carbScale * Swift.min(reserve.maxAimShift, reserve.fraction * sensitivity * Swift.max(0, eMax)) + } + + /// The slow retrospective correction's level, mg/dL per 5 min: the exponentially weighted mean of the + /// unexplained glucose change (insulin counteraction less carb effect) over the counteraction effects + /// that end within `window` before `now`, from zero at the window's start. Each interval's change is + /// scaled to 5 minutes and weighted by its duration. `carbEffects` must cover the window. + static func slowRetrospectiveCorrectionLevel(counteraction: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], + now: Date, parameters: ExperimentParameters.SlowRetrospectiveCorrection) -> Double { + let unit = LoopUnit.milligramsPerDeciliter + var level = 0.0 + var k = carbEffects.startIndex + for ice in counteraction where now.timeIntervalSince(ice.endDate) < parameters.window && ice.endDate <= now { + let duration = ice.endDate.timeIntervalSince(ice.startDate) + guard duration > 0 else { continue } + // Carb effect over the 5-min carb grid step ending at or before this interval's end. + while k + 1 < carbEffects.endIndex && carbEffects[k + 1].startDate <= ice.endDate.addingTimeInterval(1) { k += 1 } + var carb = 0.0 + if k > carbEffects.startIndex && carbEffects[k].startDate <= ice.endDate.addingTimeInterval(1) { + carb = carbEffects[k].quantity.doubleValue(for: unit) - carbEffects[k - 1].quantity.doubleValue(for: unit) + } + let iceChange = ice.effect.quantity.doubleValue(for: unit) + guard iceChange.isFinite, carb.isFinite else { continue } + let residual = (iceChange - carb) * .minutes(5) / duration + let decay = exp(-duration / parameters.timeConstant) + level = decay * level + (1 - decay) * residual + } + return level + } + + /// The ISF multiplier for a slow retrospective correction level. + static func slowRetrospectiveCorrectionMultiplier(level: Double, parameters: ExperimentParameters.SlowRetrospectiveCorrection) -> Double { + Swift.max(parameters.minMultiplier, Swift.min(parameters.maxMultiplier, 1 - parameters.gain * level)) + } + + /// `sensitivity` with every value multiplied by `multiplier`. + static func sensitivityScaled(_ sensitivity: [AbsoluteScheduleValue], by multiplier: Double) -> [AbsoluteScheduleValue] { + let unit = LoopUnit.milligramsPerDeciliter + return sensitivity.map { + AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, + value: LoopQuantity(unit: unit, doubleValue: $0.value.doubleValue(for: unit) * multiplier)) + } + } + + /// The low guard's decision on the volatility-banded forecast applied to the correction sized on the + /// forecast itself: a banded point below the suspend threshold suspends; otherwise the banded minimum + /// replaces the forecast's minimum, and the units and the point being corrected stay the forecast's. + static func guarded(_ correction: InsulinCorrection, by guardCorrection: InsulinCorrection) -> InsulinCorrection { + if case .suspend = guardCorrection { return guardCorrection } + let guardMinimum: GlucoseValue? + switch guardCorrection { + case .aboveRange(min: let m, correcting: _, minTarget: _, units: _): guardMinimum = m + case .entirelyBelowRange(min: let m, minTarget: _, units: _): guardMinimum = m + case .suspend(min: let m): guardMinimum = m + case .inRange: guardMinimum = nil + } + if case .aboveRange(min: _, correcting: let correcting, minTarget: let minTarget, units: let units) = correction, + let guardMinimum { + return .aboveRange(min: guardMinimum, correcting: correcting, minTarget: minTarget, units: units) + } + return correction } /// The target range lowered by `shift` mg/dL, floored at 70–80. diff --git a/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift b/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift index 2c80b00..77bcad8 100644 --- a/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift +++ b/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift @@ -26,8 +26,18 @@ public struct GuardAndRunwayOutput: Equatable { /// The band's depth multiplier, from the volatility baseline. public var bandGain: Double { band.gain } - /// `glucose` lowered by the band as this run applied it; unchanged when the band was off. + /// `glucose` lowered by the band as the low guard saw it this run; unchanged when the band was off. + /// `AlgorithmOutput.predictedGlucose` is the forecast without the band. public func guarded(_ glucose: [PredictedGlucoseValue]) -> [PredictedGlucoseValue] { bandApplied ? LoopAlgorithm.volatilityBanded(glucose, band: band, volatility: volatility) : glucose } } + +/// What the slow retrospective correction did on one run, for display. +public struct SlowRetrospectiveCorrectionOutput: Equatable { + /// The day-scale unexplained glucose change, mg/dL per 5 min: positive when glucose has been rising more + /// than the model expected. + public var level: Double + /// The multiplier applied to ISF on this run, 0.7–1.3. + public var multiplier: Double +} diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 378f0e5..fd8e9d8 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -753,6 +753,7 @@ public struct LoopAlgorithm { // Now validate/recommend let result: Result var guardAndRunway: GuardAndRunwayOutput? + var slowRetrospectiveCorrection: SlowRetrospectiveCorrectionOutput? do { guard let latestGlucose = input.glucoseHistory.last else { @@ -799,35 +800,60 @@ public struct LoopAlgorithm { throw AlgorithmError.missingSuspendThreshold } - prediction = generatePrediction( - start: input.predictionStart, - glucoseHistory: input.glucoseHistory, - doses: input.doses, - carbEntries: input.carbEntries, - basal: input.basal, - sensitivity: input.sensitivity, - carbRatio: input.carbRatio, - target: input.target, - algorithmEffectsOptions: .all, - useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, - includingPositiveVelocityAndRC: input.includePositiveVelocityAndRC, - useMidAbsorptionISF: input.useMidAbsorptionISF, - carbAbsorptionModel: input.carbAbsorptionModel.model, - gradualTransitionsThreshold: input.gradualTransitionsThreshold, - emulation: input.emulation ?? AlgorithmEmulationOptions(), - experiments: input.experiments - ) + func predict(sensitivity: [AbsoluteScheduleValue]) -> LoopPrediction { + generatePrediction( + start: input.predictionStart, + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: sensitivity, + carbRatio: input.carbRatio, + target: input.target, + algorithmEffectsOptions: .all, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, + includingPositiveVelocityAndRC: input.includePositiveVelocityAndRC, + useMidAbsorptionISF: input.useMidAbsorptionISF, + carbAbsorptionModel: input.carbAbsorptionModel.model, + gradualTransitionsThreshold: input.gradualTransitionsThreshold, + emulation: input.emulation ?? AlgorithmEmulationOptions(), + experiments: input.experiments + ) + } + prediction = predict(sensitivity: input.sensitivity) - // 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. + // Experiment adjustments before dosing (no-ops when `experiments` is empty). let experimentParameters = ExperimentParameters( experiments: input.experiments, volatilityBaseline: input.experiments.contains(.guardAndRunway) ? (input.volatilityBaseline ?? GlucoseVolatility.baseline(input.glucoseHistory)) : nil) + + // Slow retrospective correction: the level comes from the forecast at the scheduled ISF; the run + // then uses ISF × multiplier throughout. + var sensitivity = input.sensitivity + if let slow = experimentParameters.slowRetrospectiveCorrection { + let windowStart = input.predictionStart.addingTimeInterval(-slow.window) + let carbEffects = prediction.effects.carbStatus.dynamicGlucoseEffects( + from: windowStart.addingTimeInterval(-GlucoseMath.defaultDelta), + carbRatios: input.carbRatio, + insulinSensitivities: input.sensitivity, + absorptionModel: input.carbAbsorptionModel.model) + let level = slowRetrospectiveCorrectionLevel( + counteraction: prediction.effects.insulinCounteraction, carbEffects: carbEffects, + now: input.predictionStart, parameters: slow) + let multiplier = slowRetrospectiveCorrectionMultiplier(level: level, parameters: slow) + slowRetrospectiveCorrection = SlowRetrospectiveCorrectionOutput(level: level, multiplier: multiplier) + if multiplier != 1 { + sensitivity = sensitivityScaled(input.sensitivity, by: multiplier) + prediction = predict(sensitivity: sensitivity) + } + } + + // The runway reserve reads the forecast without the volatility band; its band term is its own. 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 sensitivity = sensitivity.closestPrior(to: input.predictionStart)?.value, let target = input.target.closestPrior(to: input.predictionStart)?.value { let unit = LoopUnit.milligramsPerDeciliter @@ -840,6 +866,8 @@ public struct LoopAlgorithm { suspendThreshold: suspendThreshold.doubleValue(for: unit), insulinModel: input.recommendationInsulinModel) } + // The volatility band lowers the forecast the low guard sees; the forecast itself is unchanged. + var guardForecast: [PredictedGlucoseValue]? = nil if let band = experimentParameters.volatilityBand, let volatility { let bandApplied = !(band.carbGate && (prediction.activeCarbs ?? 0) > 0) var bandDepth = 0.0 @@ -848,7 +876,7 @@ public struct LoopAlgorithm { bandDepth = zip(prediction.glucose, banded).map { $0.quantity.doubleValue(for: .milligramsPerDeciliter) - $1.quantity.doubleValue(for: .milligramsPerDeciliter) }.max() ?? 0 - prediction.glucose = banded + guardForecast = banded } guardAndRunway = GuardAndRunwayOutput( volatility: volatility, bandApplied: bandApplied, bandDepth: bandDepth, runwayShift: runwayShift, @@ -858,11 +886,11 @@ public struct LoopAlgorithm { let sensitivityForDosing: [AbsoluteScheduleValue] if input.useMidAbsorptionISF { - sensitivityForDosing = input.sensitivity + sensitivityForDosing = sensitivity } else { // This sets a single ISF value for the duration of the dose. let sensitivityEnd = max(forecastEnd, prediction.effects.insulin.last?.startDate ?? .distantPast) - let sensitivityAtPredictionStart = input.sensitivity.first { $0.startDate <= input.predictionStart && $0.endDate >= input.predictionStart }! + let sensitivityAtPredictionStart = sensitivity.first { $0.startDate <= input.predictionStart && $0.endDate >= input.predictionStart }! let sensitivityOverPrediction = AbsoluteScheduleValue( startDate: sensitivityAtPredictionStart.startDate, endDate: sensitivityEnd, @@ -875,13 +903,22 @@ public struct LoopAlgorithm { // keeps the user's target. let correctionTarget = runwayShift > 0 && input.recommendationType != .manualBolus ? targetLowered(input.target, by: runwayShift) : input.target - let correction = insulinCorrection( + var correction = insulinCorrection( prediction: prediction.glucose, at: input.predictionStart, target: correctionTarget, suspendThreshold: suspendThreshold, sensitivity: sensitivityForDosing, insulinModel: input.recommendationInsulinModel) + if let guardForecast { + correction = guarded(correction, by: insulinCorrection( + prediction: guardForecast, + at: input.predictionStart, + target: correctionTarget, + suspendThreshold: suspendThreshold, + sensitivity: sensitivityForDosing, + insulinModel: input.recommendationInsulinModel)) + } let maxActiveInsulin = input.maxBolus * (input.maxActiveInsulinMultiplier ?? 2) @@ -924,7 +961,8 @@ public struct LoopAlgorithm { dosesRelativeToBasal: prediction.dosesRelativeToBasal, activeInsulin: prediction.activeInsulin, activeCarbs: prediction.activeCarbs, - guardAndRunway: guardAndRunway + guardAndRunway: guardAndRunway, + slowRetrospectiveCorrection: slowRetrospectiveCorrection ) } } diff --git a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift index 45ab34b..b5bc2bc 100644 --- a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift +++ b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift @@ -206,7 +206,8 @@ final class AlgorithmExperimentsTests: XCTestCase { func testRunwayGates() { XCTAssertEqual(shift(140, volatility: 2), 0, "below 150") - XCTAssertEqual(shift(200, carbs: 5, volatility: 2), 0, "carbs on board") + XCTAssertEqual(shift(200, carbs: 20, volatility: 2), 0, "20 g of carbs on board") + XCTAssertEqual(shift(200, carbs: 10, volatility: 2), 0.5 * shift(200, volatility: 2), accuracy: 1e-9, "fades with carbs") XCTAssertGreaterThan(shift(200, volatility: 2), 0) let now = Date() let short = Array(flatForecast(200, from: now).prefix(30)) // 145 min, short of 180 @@ -229,7 +230,7 @@ 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: the band runs at full depth and only lowers the forecast, so a rise in automatic dosing + // No baseline: the band runs at full depth, and only the guard sees it, 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 @@ -237,16 +238,16 @@ final class AlgorithmExperimentsTests: XCTestCase { let standard = LoopAlgorithm.run(input: input) input.experiments = [.guardAndRunway] let runway = LoopAlgorithm.run(input: input) - let banded = runway.predictedGlucose.map { $0.quantity.doubleValue(for: unit) } + XCTAssertEqual(runway.predictedGlucose, standard.predictedGlucose, "the forecast shown is not banded") + let output = runway.guardAndRunway! + let banded = output.guarded(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") - let output = runway.guardAndRunway! XCTAssertTrue(output.bandApplied) XCTAssertGreaterThan(output.runwayShift, 0) XCTAssertFalse(output.postLowRiseCutActive) - XCTAssertEqual(output.guarded(standard.predictedGlucose), runway.predictedGlucose) let deepest = zip(plain, banded).map { $0 - $1 }.max()! XCTAssertEqual(output.bandDepth, deepest, accuracy: 1e-9) if type == .tempBasal { @@ -265,6 +266,88 @@ final class AlgorithmExperimentsTests: XCTestCase { XCTAssertLessThanOrEqual(manual.recommendation?.manual?.amount ?? 0, standard.recommendation?.manual?.amount ?? 0) } + func testGuardOnlyBand() { + let now = Date() + func g(_ v: Double) -> GlucoseValue { SimpleGlucoseValue(startDate: now, quantity: .glucose(v)) } + func d(_ c: InsulinCorrection) -> String { + let v = { (x: GlucoseValue) in Int(x.quantity.doubleValue(for: self.unit)) } + switch c { + case .aboveRange(min: let m, correcting: let c, minTarget: _, units: let u): return "above \(v(m)) \(v(c)) \(u)" + case .entirelyBelowRange(min: let m, minTarget: _, units: let u): return "below \(v(m)) \(u)" + case .suspend(min: let m): return "suspend \(v(m))" + case .inRange: return "in range" + } + } + let point = InsulinCorrection.aboveRange(min: g(120), correcting: g(200), minTarget: .glucose(100), units: 2) + // A banded point below the suspend threshold suspends. + XCTAssertEqual(d(LoopAlgorithm.guarded(point, by: .suspend(min: g(65)))), "suspend 65") + // Otherwise the banded minimum is the one the guard checks; the units stay the forecast's. + XCTAssertEqual(d(LoopAlgorithm.guarded(point, by: .aboveRange(min: g(90), correcting: g(170), minTarget: .glucose(100), units: 1))), + "above 90 200 2.0") + XCTAssertEqual(d(LoopAlgorithm.guarded(point, by: .entirelyBelowRange(min: g(85), minTarget: .glucose(100), units: -1))), + "above 85 200 2.0") + // A forecast that needs no correction is left as it is. + XCTAssertEqual(d(LoopAlgorithm.guarded(.inRange, by: .entirelyBelowRange(min: g(85), minTarget: .glucose(100), units: -1))), "in range") + } + + // MARK: - Slow retrospective correction + + private func counteraction(_ perFiveMinutes: Double, hours: Double, endingAt end: Date) -> [GlucoseEffectVelocity] { + let n = Int(hours * 12) + return (0.. GlucoseEffect in + let date = now.addingTimeInterval(-Double(steps - i) * 300) + return GlucoseEffect(startDate: date, quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 2 * Double(i))) + } + let explained = LoopAlgorithm.slowRetrospectiveCorrectionLevel( + counteraction: counteraction(2, hours: 100, endingAt: now), carbEffects: carbs, now: now, parameters: p) + XCTAssertEqual(explained, 0, accuracy: 1e-9) + } + + func testSlowRCMultiplierClamps() { + let p = ExperimentParameters.SlowRetrospectiveCorrection() + XCTAssertEqual(LoopAlgorithm.slowRetrospectiveCorrectionMultiplier(level: 0, parameters: p), 1) + XCTAssertEqual(LoopAlgorithm.slowRetrospectiveCorrectionMultiplier(level: 2, parameters: p), 0.9, accuracy: 1e-12) + XCTAssertEqual(LoopAlgorithm.slowRetrospectiveCorrectionMultiplier(level: 20, parameters: p), 0.7) + XCTAssertEqual(LoopAlgorithm.slowRetrospectiveCorrectionMultiplier(level: -20, parameters: p), 1.3) + } + + func testSlowRCDosesMoreWhenGlucoseKeepsRisingUnexplained() { + let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = AlgorithmInputFixture.mock(for: now) + input.recommendationType = .automaticBolus + // Steady unexplained rise over 72 h, no carbs: the model keeps under-predicting. + let values = (0..<(72 * 12)).map { 100 + 0.15 * Double($0) } + input.glucoseHistory = glucoseSeries(values, endingAt: now.addingTimeInterval(.minutes(-1))) + input.doses = [] + input.carbEntries = [] + input.sensitivity = [AbsoluteScheduleValue(startDate: now.addingTimeInterval(.hours(-96)), endDate: now.addingTimeInterval(.hours(12)), value: .glucose(50))] + input.basal = [AbsoluteScheduleValue(startDate: now.addingTimeInterval(.hours(-96)), endDate: now.addingTimeInterval(.hours(12)), value: 1.0)] + input.carbRatio = [AbsoluteScheduleValue(startDate: now.addingTimeInterval(.hours(-96)), endDate: now.addingTimeInterval(.hours(12)), value: 10)] + let standard = LoopAlgorithm.run(input: input) + input.experiments = [.slowRetrospectiveCorrection] + let slow = LoopAlgorithm.run(input: input) + XCTAssertNil(standard.slowRetrospectiveCorrection) + let output = slow.slowRetrospectiveCorrection! + XCTAssertGreaterThan(output.level, 0) + XCTAssertLessThan(output.multiplier, 1) + XCTAssertGreaterThan(slow.recommendation!.automatic!.bolusUnits!, standard.recommendation!.automatic!.bolusUnits!) + } + func testTargetLoweredFloors() { let now = Date() let target: GlucoseRangeTimeline = [AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(.hours(1)), @@ -288,7 +371,8 @@ final class AlgorithmExperimentsTests: XCTestCase { let back = try decoder.decode(AlgorithmInputFixture.self, from: encoder.encode(input)) XCTAssertEqual(back.experiments, [.guardAndRunway]) XCTAssertEqual(back.volatilityBaseline, 4.2) - XCTAssertEqual(ExperimentOption.allCases, [.guardAndRunway, .integralRetrospectiveCorrection, .glucoseBasedApplicationFactor]) + XCTAssertEqual(ExperimentOption.allCases, [.guardAndRunway, .integralRetrospectiveCorrection, .glucoseBasedApplicationFactor, + .slowRetrospectiveCorrection]) } func testFixtureRoundTripCarriesACombination() throws { From 3ad69d42dd6c48c084fd6782f122b0cf4a6954d5 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 31 Oct 2026 20:29:18 -0500 Subject: [PATCH 2/3] slowRetrospectiveCorrection: state the carb ratio / ISF coverage and full carb history requirements --- Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift index 9140f8c..d1344d8 100644 --- a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift +++ b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift @@ -55,7 +55,10 @@ public enum ExperimentOption: String, Codable, CaseIterable, Sendable { /// /// It is computed from the inputs on every run, with no stored state, so it needs a long history: 72 h of /// glucose, carb entries from 72 h plus the longest absorption time, and doses from 72 h plus the insulin - /// activity duration. With 12 h of history the level reaches only about 40 % of its working value. + /// activity duration. The carb ratio and ISF timelines must start before the oldest carb entry (carb + /// absorption traps otherwise). A meal whose entry is dropped while its absorption is still inside the + /// glucose history reads as unexplained rise and pushes the level up: pass carbs for the whole history. + /// With 12 h of history the level reaches only about 40 % of its working value. case slowRetrospectiveCorrection } From f8426353a05bcbf1142da853c4e81e263a3c6518 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 31 Oct 2026 22:24:47 -0500 Subject: [PATCH 3/3] Return AlgorithmError.carbRatioOrSensitivityStartsAfterCarbEntry instead of crashing run() now checks that the carb ratio and ISF timelines start at or before the oldest carb entry, before any forecast is built. Previously CarbMath hit preconditionFailure. The slow retrospective correction experiment's 72 h carb history makes this case easier to reach. --- .../Experiments/AlgorithmExperiments.swift | 4 +-- Sources/LoopAlgorithm/LoopAlgorithm.swift | 12 +++++++ .../LoopAlgorithmTests.swift | 35 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift index d1344d8..1893bc6 100644 --- a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift +++ b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift @@ -55,8 +55,8 @@ public enum ExperimentOption: String, Codable, CaseIterable, Sendable { /// /// It is computed from the inputs on every run, with no stored state, so it needs a long history: 72 h of /// glucose, carb entries from 72 h plus the longest absorption time, and doses from 72 h plus the insulin - /// activity duration. The carb ratio and ISF timelines must start before the oldest carb entry (carb - /// absorption traps otherwise). A meal whose entry is dropped while its absorption is still inside the + /// activity duration. The carb ratio and ISF timelines must start at or before the oldest carb entry + /// (otherwise `run` fails with `AlgorithmError.carbRatioOrSensitivityStartsAfterCarbEntry`). A meal whose entry is dropped while its absorption is still inside the /// glucose history reads as unexplained rise and pushes the level up: pass carbs for the whole history. /// With 12 h of history the level reaches only about 40 % of its working value. case slowRetrospectiveCorrection diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index fd8e9d8..ba338bf 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -15,6 +15,8 @@ public enum AlgorithmError: Error { case sensitivityTimelineStartsTooLate case sensitivityTimelineEndsTooEarly case futureBasalNotAllowed + /// The carb ratio or sensitivity timeline starts after the oldest carb entry. + case carbRatioOrSensitivityStartsAfterCarbEntry } public struct LoopAlgorithmEffects { @@ -791,6 +793,16 @@ public struct LoopAlgorithm { throw AlgorithmError.sensitivityTimelineEndsTooEarly } + // Make sure carb ratio and ISF cover every carb entry + if let oldestCarbEntry = input.carbEntries.map({ $0.startDate }).min() { + guard + let carbRatioStart = input.carbRatio.first?.startDate, carbRatioStart <= oldestCarbEntry, + let sensitivityStart = input.sensitivity.first?.startDate, sensitivityStart <= oldestCarbEntry + else { + throw AlgorithmError.carbRatioOrSensitivityStartsAfterCarbEntry + } + } + // Make sure Basal covers needed timeline guard let scheduledBasalRate = input.basal.closestPrior(to: input.predictionStart)?.value else { throw AlgorithmError.basalTimelineIncomplete diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 0784e30..de2053e 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -474,6 +474,41 @@ final class LoopAlgorithmTests: XCTestCase { } + func testCarbEntryBeforeTimelinesIsAnError() { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = Bundle.module.url(forResource: "meal-bolus-isf", withExtension: "json", subdirectory: "Fixtures")! + var input = try! decoder.decode(AlgorithmInputFixture.self, from: try! Data(contentsOf: url)) + + // A carb entry older than the start of the carb ratio and ISF timelines + let timelinesStart = min(input.carbRatio.first!.startDate, input.sensitivity.first!.startDate) + let entryDate = timelinesStart.addingTimeInterval(.minutes(-30)) + input.carbEntries.insert(FixtureCarbEntry(absorptionTime: .hours(3), startDate: entryDate, quantity: .carbs(20)), at: 0) + + var output = LoopAlgorithm.run(input: input) + guard case .failure(AlgorithmError.carbRatioOrSensitivityStartsAfterCarbEntry) = output.recommendationResult else { + XCTFail("Expected carbRatioOrSensitivityStartsAfterCarbEntry failure") + return + } + + // Extend both timelines back to the entry + let firstCarbRatio = input.carbRatio.first! + input.carbRatio.insert(AbsoluteScheduleValue(startDate: entryDate, endDate: firstCarbRatio.startDate, value: firstCarbRatio.value), at: 0) + output = LoopAlgorithm.run(input: input) + guard case .failure(AlgorithmError.carbRatioOrSensitivityStartsAfterCarbEntry) = output.recommendationResult else { + XCTFail("Expected failure while ISF still starts after the entry") + return + } + + let firstSensitivity = input.sensitivity.first! + input.sensitivity.insert(AbsoluteScheduleValue(startDate: entryDate, endDate: firstSensitivity.startDate, value: firstSensitivity.value), at: 0) + output = LoopAlgorithm.run(input: input) + guard case .success = output.recommendationResult else { + XCTFail("Expected recommendationResult success") + return + } + } + func testIncompleteISFTimelineDetectedForMidAbsorptionISF() { let now = ISO8601DateFormatter().date(from: "2020-03-11T12:13:14-0700")! var input = AlgorithmInputFixture.mock(for: now)