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..1893bc6 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,21 @@ 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. 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 } /// Loop's glucose-based application factor (`glucoseBasedApplicationFactor`). @@ -64,6 +82,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 +103,9 @@ struct ExperimentParameters: Equatable { volatilityBand = VolatilityBand(gain: 1.0) } } + if experiments.contains(.slowRetrospectiveCorrection) { + slowRetrospectiveCorrection = SlowRetrospectiveCorrection() + } } init() {} @@ -111,6 +133,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..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 { @@ -753,6 +755,7 @@ public struct LoopAlgorithm { // Now validate/recommend let result: Result var guardAndRunway: GuardAndRunwayOutput? + var slowRetrospectiveCorrection: SlowRetrospectiveCorrectionOutput? do { guard let latestGlucose = input.glucoseHistory.last else { @@ -790,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 @@ -799,35 +812,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 +878,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 +888,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 +898,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 +915,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 +973,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 { 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)