From 4ddd4a046c1c8250ce6dc245e1ac194b266fc60f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 24 Sep 2026 14:24:02 -0500 Subject: [PATCH 1/2] Sample correction range per prediction point in insulinCorrection (#39) The correction range was sampled once at the decision time and applied to every predicted point. LoopKit's DoseMath samples the range at each prediction point's own time (correctionRange.quantityRange(at: prediction.startDate)), so a forecast that crosses a target-band boundary (e.g. an evening band ending and a higher overnight band beginning) is corrected to the band in effect at each point. Sampling once over-corrects the portion of the forecast that lands in a higher later band, and under-corrects for a lower later band. Match LoopKit: look up the correction range at each prediction point. Claude-Session: https://claude.ai/code/session_01XEGCDE2D9wF3zig7uQ9PY2 Co-authored-by: Claude Fable 5 --- Sources/LoopAlgorithm/Insulin/DoseMath.swift | 11 ++-- .../CorrectionDosingTests.swift | 62 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/Sources/LoopAlgorithm/Insulin/DoseMath.swift b/Sources/LoopAlgorithm/Insulin/DoseMath.swift index b9178fb..010fad9 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseMath.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseMath.swift @@ -193,10 +193,6 @@ extension Array where Element: GlucoseValue { let endOfAbsorption = date.addingTimeInterval(model.effectDuration) - guard let correctionRangeItem = correctionRange.closestPrior(to: date) else { - preconditionFailure("Correction range must cover date: \(date)") - } - // For each prediction above target, determine the amount of insulin necessary to correct glucose based on the modeled effectiveness of the insulin at that time for prediction in self { guard prediction.startDate >= date else { @@ -213,6 +209,13 @@ extension Array where Element: GlucoseValue { let predictedGlucoseValue = prediction.quantity.doubleValue(for: unit) let time = prediction.startDate.timeIntervalSince(date) + // The correction range in effect at this prediction point's time, so a + // forecast that crosses a target-band boundary is corrected to the band + // in effect at each point. + guard let correctionRangeItem = correctionRange.closestPrior(to: prediction.startDate) else { + preconditionFailure("Correction range must cover prediction: \(prediction.startDate)") + } + // Compute the target value as a function of time since the dose started let targetValue = targetGlucoseValue( percentEffectDuration: time / model.effectDuration, diff --git a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift index fa9e696..a1f8c3a 100644 --- a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift +++ b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift @@ -570,4 +570,66 @@ class CorrectionDosingTests: XCTestCase { XCTFail("Wrong .notice: \(String(describing: manualDose.notice))") } } + + func testCorrectionRangeSampledPerPredictionPoint() { + // A flat high prediction crossing a target-band boundary two hours out. + let prediction = [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0)), quantity: .glucose(160)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(160)) + ] + + // Target switches from 90-120 to 150-170 at t+2h; predicted points beyond + // the boundary are already in range, so they should require no correction. + let steppedTarget: GlucoseRangeTimeline = [ + AbsoluteScheduleValue( + startDate: testDate.addingTimeInterval(.hours(-24)), + endDate: testDate.addingTimeInterval(.hours(2)), + value: LoopQuantity.glucose(90)...LoopQuantity.glucose(120) + ), + AbsoluteScheduleValue( + startDate: testDate.addingTimeInterval(.hours(2)), + endDate: testDate.addingTimeInterval(.hours(24)), + value: LoopQuantity.glucose(150)...LoopQuantity.glucose(170) + ) + ] + + let uniformCorrection = LoopAlgorithm.insulinCorrection( + prediction: prediction, + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinModel: insulinModel + ) + + let steppedCorrection = LoopAlgorithm.insulinCorrection( + prediction: prediction, + at: testDate, + target: steppedTarget, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinModel: insulinModel + ) + + let uniformDose = LoopAlgorithm.recommendManualBolus( + for: uniformCorrection, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: testDate, quantity: .glucose(160)), + target: target + ) + + let steppedDose = LoopAlgorithm.recommendManualBolus( + for: steppedCorrection, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: testDate, quantity: .glucose(160)), + target: steppedTarget + ) + + // With a uniform 90-120 target the whole forecast is above range. + XCTAssertGreaterThan(uniformDose.amount, 0.5) + // The points beyond t+2h sit inside the 150-170 band in effect there, so + // correcting to the per-point band requires less insulin than correcting + // the whole forecast to 90-120. + XCTAssertLessThan(steppedDose.amount, uniformDose.amount) + } } From ab55554f72ac191a3698392b447f72117c02cc2d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 28 Sep 2026 14:35:34 -0500 Subject: [PATCH 2/2] Add AlgorithmEmulationOptions for reproducing deployed Loop 1.0 behavior (#40) * Add AlgorithmEmulationOptions for reproducing deployed Loop 1.0 behavior Simulators and replay tools (TidepoolSim, LoopEval) need to reproduce the forecasts and dosing decisions of the algorithm as deployed in Loop 1.0-era apps, which predate several fixes and bounds in this package. Add an opt-in emulation options struct that switches those behaviors back individually, with every default preserving current behavior: - legacyBasalIOB: pre-#35 delta-quantized basal IOB integration (the delta-scale IOB "ripple" on basal segments longer than one delta), threaded as a parameter through insulinOnBoard / insulinOnBoardTimeline. - legacyRCDecay: pre-#33 step-by-step decayEffect accumulation, which differs from the closed-form quadratic for CGM samples not aligned to delta boundaries; threaded through both retrospective correction classes. - integralRCClamp: restore the integral-correction clamp that deployed LoopKit's IntegralRetrospectiveCorrection applies (the ISF x basal-scaled, correction-range-relative bound on the wound-up integral term, dropped in the LoopAlgorithm port). The RetrospectiveCorrection protocol gains an optional IntegralRCClampSettings parameter bundling the decision-time ISF / basal rate / correction range the bound needs; StandardRetrospectiveCorrection accepts and ignores it. - disableIRCVelocityCeiling: turn off the settings-free correction-velocity ceiling (#37), which deployed Loop predates. AlgorithmEmulationOptions is Codable as either a preset string ("loop-1.0" = all of the above) or an object of individual flags, and rides the AlgorithmInputFixture / LoopPredictionInput JSON under an optional "emulation" key, so JSON-driven callers (LoopAlgorithmToPython and the simulators behind it) can select the mode without new wrapper API. The AlgorithmInput protocol exposes it with a default of nil, so existing conformers are unaffected. LoopPredictionInput also gains the correction range timeline ("target") the clamp needs; the precomputed-insulin generatePrediction overload takes an optional scheduledBasalRate for the same purpose, since it has no basal schedule. These options intentionally re-introduce behaviors that were later fixed or bounded; they are for research and evaluation, not live dosing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XEGCDE2D9wF3zig7uQ9PY2 * Clarify that the loop-1.0 preset refers to Tidepool Loop 1.0 The preset name refers to the Tidepool Loop 1.0 release, whose algorithm was the pre-extraction, in-LoopKit implementation. The emulation options cover the prediction/effects math only: Tidepool Loop 1.0 also dosed exclusively by temp basal (no automatic bolus, although LoopKit supported it at the time), which callers reproduce with the existing recommendationType input set to .tempBasal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XEGCDE2D9wF3zig7uQ9PY2 * Add rapidActingAdult/rapidActingChild as selectable insulin types Tidepool Loop 1.0 had no ultra-rapid insulin types; users chose between the rapid-acting adult and rapid-acting child models. The child preset exists in ExponentialInsulinModelPreset but was unreachable from the fixture input: every non-ultra-rapid FixtureInsulinType mapped to the adult model. Add model-preset cases to FixtureInsulinType so JSON-driven callers can select either model for the recommendation model and for doses, as Loop 1.0 emulation requires. * Add noGradualTransitionsGate to emulation options (on in loop-1.0) Deployed Loop 1.0 predates the gradual-transitions gate on retrospective correction and momentum, and the fixture input cannot express a nil gradualTransitionsThreshold (an absent key decodes to the 40 mg/dL default), so JSON-driven callers could not disable the gate for faithful emulation. Add the flag to AlgorithmEmulationOptions, enabled in the loop-1.0 preset. --------- Co-authored-by: Claude Fable 5 --- .../AlgorithmEmulationOptions.swift | 134 ++++++++++++ Sources/LoopAlgorithm/AlgorithmInput.swift | 5 + .../LoopAlgorithm/AlgorithmInputFixture.swift | 14 +- .../LoopAlgorithm/Insulin/InsulinMath.swift | 36 +++- .../LoopAlgorithm/Insulin/InsulinType.swift | 9 + Sources/LoopAlgorithm/LoopAlgorithm.swift | 65 ++++-- Sources/LoopAlgorithm/LoopMath.swift | 23 +- .../LoopAlgorithm/LoopPredictionInput.swift | 45 +++- .../IntegralRetrospectiveCorrection.swift | 29 ++- .../RetrospectiveCorrection.swift | 21 ++ .../StandardRetrospectiveCorrection.swift | 8 +- .../AlgorithmEmulationTests.swift | 203 ++++++++++++++++++ ...ralRetrospectiveCorrectionClampTests.swift | 170 +++++++++++++++ 13 files changed, 733 insertions(+), 29 deletions(-) create mode 100644 Sources/LoopAlgorithm/AlgorithmEmulationOptions.swift create mode 100644 Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift create mode 100644 Tests/LoopAlgorithmTests/IntegralRetrospectiveCorrectionClampTests.swift diff --git a/Sources/LoopAlgorithm/AlgorithmEmulationOptions.swift b/Sources/LoopAlgorithm/AlgorithmEmulationOptions.swift new file mode 100644 index 0000000..5559091 --- /dev/null +++ b/Sources/LoopAlgorithm/AlgorithmEmulationOptions.swift @@ -0,0 +1,134 @@ +// +// AlgorithmEmulationOptions.swift +// LoopAlgorithm +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Options that switch individual algorithm behaviors back to how earlier deployed +/// versions of Loop computed them, so simulators and replay tools can reproduce the +/// forecasts and dosing decisions of a specific deployed algorithm version. The +/// default value of every option preserves current LoopAlgorithm behavior. +/// +/// These options exist for research and evaluation. They intentionally re-introduce +/// behaviors that were later fixed or bounded, and should not be enabled for live +/// dosing. +public struct AlgorithmEmulationOptions: Equatable, Sendable { + + /// Reproduce the pre-#35 delta-quantized basal IOB integration bound. Deployed + /// Loop quantizes the integration upper bound to the delta grid, so a whole + /// chunk of a basal segment's IOB is added discontinuously each time the + /// evaluation time crosses a delta boundary — a delta-scale "ripple" on any + /// basal segment longer than one delta. False (default) integrates continuously. + public var legacyBasalIOB: Bool + + /// Reproduce the pre-#33 step-by-step retrospective-correction decay. #33 + /// (porting LoopKit#556) reformulated `decayEffect` as a closed-form quadratic; + /// the two are identical for delta-aligned samples but differ for unaligned + /// (real-CGM) timestamps. False (default) uses the continuous formulation. + public var legacyRCDecay: Bool + + /// Apply the integral-correction clamp that deployed LoopKit's + /// IntegralRetrospectiveCorrection uses: the wound-up integral term is bounded + /// by an ISF×basal-scaled, correction-range-relative window. Requires the + /// caller to supply a correction-range timeline; has no effect unless integral + /// retrospective correction is enabled. False (default) leaves the integral + /// term unclamped, matching the current port. + public var integralRCClamp: Bool + + /// Disable the settings-free correction-velocity ceiling (#37) on integral + /// retrospective correction. Deployed Loop predates that ceiling; enable this + /// together with `integralRCClamp` for deployed-faithful IRC. False (default) + /// keeps the ceiling. + public var disableIRCVelocityCeiling: Bool + + /// Disable the gradual-transitions gate on retrospective correction and + /// momentum. Deployed Loop predates the gate, and the fixture input cannot + /// express a nil `gradualTransitionsThreshold` (absent decodes to the 40 mg/dL + /// default), so faithful emulation must switch it off here. False (default) + /// leaves `gradualTransitionsThreshold` in effect. + public var noGradualTransitionsGate: Bool + + public init( + legacyBasalIOB: Bool = false, + legacyRCDecay: Bool = false, + integralRCClamp: Bool = false, + disableIRCVelocityCeiling: Bool = false, + noGradualTransitionsGate: Bool = false + ) { + self.legacyBasalIOB = legacyBasalIOB + self.legacyRCDecay = legacyRCDecay + self.integralRCClamp = integralRCClamp + self.disableIRCVelocityCeiling = disableIRCVelocityCeiling + self.noGradualTransitionsGate = noGradualTransitionsGate + } + + /// Emulate the algorithm as shipped in Tidepool Loop 1.0: the pre-extraction, + /// in-LoopKit algorithm behavior — every legacy behavior on, every + /// post-extraction bound off. + /// + /// These options cover the prediction/effects math only. Two Tidepool Loop + /// 1.0 behaviors are expressed through existing inputs instead: + /// - It dosed exclusively by temp basal (no automatic bolus, although + /// LoopKit supported it at the time): set `recommendationType` to + /// `.tempBasal`. + /// - It had no ultra-rapid insulin types; users chose between the + /// rapid-acting adult and rapid-acting child models: set + /// `recommendationInsulinType` (and dose insulin types) to + /// `.rapidActingAdult` or `.rapidActingChild`. + public static let loop1 = AlgorithmEmulationOptions( + legacyBasalIOB: true, + legacyRCDecay: true, + integralRCClamp: true, + disableIRCVelocityCeiling: true, + noGradualTransitionsGate: true + ) +} + +extension AlgorithmEmulationOptions: Codable { + private static let loop1PresetName = "loop-1.0" + + private enum CodingKeys: String, CodingKey { + case legacyBasalIOB + case legacyRCDecay + case integralRCClamp + case disableIRCVelocityCeiling + case noGradualTransitionsGate + } + + public init(from decoder: Decoder) throws { + // Accept either a preset name ("loop-1.0") or an object of individual flags. + if let container = try? decoder.singleValueContainer(), let preset = try? container.decode(String.self) { + guard preset == Self.loop1PresetName else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown emulation preset '\(preset)'" + )) + } + self = .loop1 + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + self.legacyBasalIOB = try container.decodeIfPresent(Bool.self, forKey: .legacyBasalIOB) ?? false + self.legacyRCDecay = try container.decodeIfPresent(Bool.self, forKey: .legacyRCDecay) ?? false + self.integralRCClamp = try container.decodeIfPresent(Bool.self, forKey: .integralRCClamp) ?? false + self.disableIRCVelocityCeiling = try container.decodeIfPresent(Bool.self, forKey: .disableIRCVelocityCeiling) ?? false + self.noGradualTransitionsGate = try container.decodeIfPresent(Bool.self, forKey: .noGradualTransitionsGate) ?? false + } + + public func encode(to encoder: Encoder) throws { + if self == .loop1 { + var container = encoder.singleValueContainer() + try container.encode(Self.loop1PresetName) + return + } + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(legacyBasalIOB, forKey: .legacyBasalIOB) + try container.encode(legacyRCDecay, forKey: .legacyRCDecay) + try container.encode(integralRCClamp, forKey: .integralRCClamp) + try container.encode(disableIRCVelocityCeiling, forKey: .disableIRCVelocityCeiling) + try container.encode(noGradualTransitionsGate, forKey: .noGradualTransitionsGate) + } +} diff --git a/Sources/LoopAlgorithm/AlgorithmInput.swift b/Sources/LoopAlgorithm/AlgorithmInput.swift index e2139ab..9c2ef60 100644 --- a/Sources/LoopAlgorithm/AlgorithmInput.swift +++ b/Sources/LoopAlgorithm/AlgorithmInput.swift @@ -34,4 +34,9 @@ public protocol AlgorithmInput { var recommendationType: DoseRecommendationType { get } var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4 var gradualTransitionsThreshold: Double? { get } + var emulation: AlgorithmEmulationOptions? { get } // Defaults to nil (no emulation) +} + +public extension AlgorithmInput { + var emulation: AlgorithmEmulationOptions? { nil } } diff --git a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift index 4355778..de97e58 100644 --- a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift +++ b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift @@ -35,6 +35,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { public var recommendationType: DoseRecommendationType = .automaticBolus public var automaticBolusApplicationFactor: Double? public var gradualTransitionsThreshold: Double? + public var emulation: AlgorithmEmulationOptions? public var recommendationInsulinModel: InsulinModel { recommendationInsulinType.insulinModel @@ -73,7 +74,8 @@ public struct AlgorithmInputFixture: AlgorithmInput { recommendationInsulinType: FixtureInsulinType, recommendationType: DoseRecommendationType, automaticBolusApplicationFactor: Double? = nil, - gradualTransitionsThreshold: Double? = 40.0 + gradualTransitionsThreshold: Double? = 40.0, + emulation: AlgorithmEmulationOptions? = nil ) { self.predictionStart = predictionStart self.glucoseHistory = glucoseHistory @@ -95,6 +97,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { self.recommendationType = recommendationType self.automaticBolusApplicationFactor = automaticBolusApplicationFactor self.gradualTransitionsThreshold = gradualTransitionsThreshold + self.emulation = emulation } } @@ -148,7 +151,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) } public func encode(to encoder: Encoder) throws { @@ -184,6 +187,9 @@ extension AlgorithmInputFixture: Codable { try container.encode(recommendationType.rawValue, forKey: .recommendationType) try container.encode(automaticBolusApplicationFactor, forKey: .automaticBolusApplicationFactor) try container.encode(gradualTransitionsThreshold, forKey: .gradualTransitionsThreshold) + if let emulation { + try container.encode(emulation, forKey: .emulation) + } } private enum CodingKeys: String, CodingKey { @@ -206,6 +212,7 @@ extension AlgorithmInputFixture: Codable { case recommendationType case automaticBolusApplicationFactor case gradualTransitionsThreshold + case emulation } } @@ -230,7 +237,8 @@ extension AlgorithmInputFixture { recommendationInsulinType: .novolog, recommendationType: input.recommendationType, automaticBolusApplicationFactor: input.automaticBolusApplicationFactor, - gradualTransitionsThreshold: input.gradualTransitionsThreshold + gradualTransitionsThreshold: input.gradualTransitionsThreshold, + emulation: input.emulation ) let encoder = JSONEncoder() diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index f272c20..a21a046 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -15,10 +15,30 @@ public struct InsulinMath { } extension BasalRelativeDose { - private func continuousDeliveryInsulinOnBoard(at date: Date, delta: TimeInterval) -> Double { + private func continuousDeliveryInsulinOnBoard(at date: Date, delta: TimeInterval, useLegacyIntegration: Bool) -> Double { let doseDuration = endDate.timeIntervalSince(startDate) // t1 let time = date.timeIntervalSince(startDate) + if useLegacyIntegration { + // Deployed Loop (pre-#35): the loop's upper bound is quantized to the + // delta grid, so a whole chunk is added discontinuously each time + // `time` crosses a delta boundary — a delta-scale ripple on any basal + // segment longer than one delta. + var iob: Double = 0 + var doseDate = TimeInterval(0) // i + repeat { + let segment: Double + if doseDuration > 0 { + segment = max(0, min(doseDate + delta, doseDuration) - doseDate) / doseDuration + } else { + segment = 1 + } + iob += segment * insulinModel.percentEffectRemaining(at: time - doseDate) + doseDate += delta + } while doseDate <= min(floor((time + insulinModel.delay) / delta) * delta, doseDuration) + return iob + } + guard doseDuration > 0 else { return insulinModel.percentEffectRemaining(at: time) } @@ -47,7 +67,7 @@ extension BasalRelativeDose { return iob } - func insulinOnBoard(at date: Date, delta: TimeInterval) -> Double { + func insulinOnBoard(at date: Date, delta: TimeInterval, useLegacyIntegration: Bool = false) -> Double { let time = date.timeIntervalSince(startDate) guard time >= 0 else { return 0 @@ -57,7 +77,7 @@ extension BasalRelativeDose { if endDate.timeIntervalSince(startDate) <= 1.05 * delta { return netBasalUnits * insulinModel.percentEffectRemaining(at: time) } else { - return netBasalUnits * continuousDeliveryInsulinOnBoard(at: date, delta: delta) + return netBasalUnits * continuousDeliveryInsulinOnBoard(at: date, delta: delta, useLegacyIntegration: useLegacyIntegration) } } @@ -301,7 +321,8 @@ extension Collection where Element == BasalRelativeDose { longestEffectDuration: TimeInterval = InsulinMath.defaultInsulinActivityDuration, from start: Date? = nil, to end: Date? = nil, - delta: TimeInterval = GlucoseMath.defaultDelta + delta: TimeInterval = GlucoseMath.defaultDelta, + useLegacyIntegration: Bool = false ) -> [InsulinValue] { guard let (start, end) = LoopMath.simulationDateRangeForSamples(self, from: start, to: end, duration: longestEffectDuration, delta: delta) else { return [] @@ -312,7 +333,7 @@ extension Collection where Element == BasalRelativeDose { repeat { let value = reduce(0) { (value, dose) -> Double in - return value + dose.insulinOnBoard(at: date, delta: delta) + return value + dose.insulinOnBoard(at: date, delta: delta, useLegacyIntegration: useLegacyIntegration) } values.append(InsulinValue(startDate: date, value: value)) @@ -330,10 +351,11 @@ extension Collection where Element == BasalRelativeDose { - returns: Insulin amount remaining at specified time */ public func insulinOnBoard( - at date: Date + at date: Date, + useLegacyIntegration: Bool = false ) -> Double { return reduce(0) { (value, dose) -> Double in - return value + dose.insulinOnBoard(at: date, delta: GlucoseMath.defaultDelta) + return value + dose.insulinOnBoard(at: date, delta: GlucoseMath.defaultDelta, useLegacyIntegration: useLegacyIntegration) } } diff --git a/Sources/LoopAlgorithm/Insulin/InsulinType.swift b/Sources/LoopAlgorithm/Insulin/InsulinType.swift index b84b0dc..940c803 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinType.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinType.swift @@ -16,6 +16,13 @@ public enum FixtureInsulinType: String, Codable, CaseIterable { case lyumjev case afrezza + // Model-preset selections rather than brands. Tidepool Loop 1.0 had no + // per-brand insulin types: users chose between the rapid-acting adult and + // rapid-acting child models, so emulating it requires selecting those + // presets directly (see AlgorithmEmulationOptions.loop1). + case rapidActingAdult + case rapidActingChild + var insulinModel: InsulinModel { switch self { case .fiasp: @@ -24,6 +31,8 @@ public enum FixtureInsulinType: String, Codable, CaseIterable { return ExponentialInsulinModelPreset.lyumjev case .afrezza: return ExponentialInsulinModelPreset.afrezza + case .rapidActingChild: + return ExponentialInsulinModelPreset.rapidActingChild default: return ExponentialInsulinModelPreset.rapidActingAdult } diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index fce1746..50974c8 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -160,10 +160,12 @@ public struct LoopAlgorithm { /// - basal: Scheduled basal rate timeline: t-16h to t /// - sensitivity: Insulin sensitivity timeline: t-16h to t (eventually with mid-absorption isf changes, it will be t-10h to t) /// - carbRatio: Carb ratio timeline: t-10h to t+6h + /// - target: Correction range timeline. Used only for the integral-correction clamp when `emulation.integralRCClamp` is enabled; may be empty otherwise. /// - algorithmEffectsOptions: Which effects to include when combining effects to generate glucose prediction /// - useIntegralRetrospectiveCorrection: If true, the prediction will use Integral Retrospection. If false, will use traditional Retrospective Correction /// - includingPositiveVelocityAndRC: If false, only net negative momentum and RC effects will used. /// - carbAbsorptionModel: A model conforming to CarbAbsorptionComputable that is used for computing carb absorption over time. + /// - emulation: Options for reproducing earlier deployed algorithm behavior; the default emulates nothing. /// - Returns: A LoopPrediction struct containing the predicted glucose and the computed intermediate effects used to make the prediction public static func generatePrediction( @@ -174,13 +176,15 @@ public struct LoopAlgorithm { basal: [AbsoluteScheduleValue], sensitivity: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], + target: GlucoseRangeTimeline = [], algorithmEffectsOptions: AlgorithmEffectsOptions = .all, useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, useMidAbsorptionISF: Bool = false, carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), gradualTransitionsThreshold: Double? = 40.0, - momentumVelocityMaximum: LoopQuantity? = nil + momentumVelocityMaximum: LoopQuantity? = nil, + emulation: AlgorithmEmulationOptions = AlgorithmEmulationOptions() ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { var prediction: [PredictedGlucoseValue] = [] @@ -202,7 +206,7 @@ public struct LoopAlgorithm { // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal dosesRelativeToBasal = doses.annotated(with: basal) - activeInsulin = dosesRelativeToBasal.insulinOnBoard(at: start) + activeInsulin = dosesRelativeToBasal.insulinOnBoard(at: start, useLegacyIntegration: emulation.legacyBasalIOB) var insulinEffectsInterval = dosesRelativeToBasal.effectsInterval() ?? DateInterval(start: start, end: start) @@ -256,16 +260,30 @@ public struct LoopAlgorithm { let rc: RetrospectiveCorrection if useIntegralRetrospectiveCorrection { - rc = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + rc = IntegralRetrospectiveCorrection( + effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, + maxCorrectionVelocity: emulation.disableIRCVelocityCeiling ? nil : IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity, + useLegacyDecay: emulation.legacyRCDecay + ) } else { - rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, useLegacyDecay: emulation.legacyRCDecay) } if let latestGlucose = glucoseHistory.last { + var integralClamp: IntegralRCClampSettings? + if emulation.integralRCClamp, + let isf = sensitivity.closestPrior(to: start)?.value, + let basalRate = basal.closestPrior(to: start)?.value, + let correctionRange = target.closestPrior(to: start)?.value + { + integralClamp = IntegralRCClampSettings(insulinSensitivity: isf, basalRate: basalRate, correctionRange: correctionRange) + } + retrospectiveCorrectionEffects = rc.computeEffect( startingAt: latestGlucose, retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, recencyInterval: TimeInterval(minutes: 15), + integralClamp: integralClamp, retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval ) @@ -292,7 +310,8 @@ public struct LoopAlgorithm { start ) - if !rcTransitionData.hasGradualTransitions(gradualTransitionThreshold: gradualTransitionsThreshold ?? 40.0) { + if !emulation.noGradualTransitionsGate, + !rcTransitionData.hasGradualTransitions(gradualTransitionThreshold: gradualTransitionsThreshold ?? 40.0) { useRC = false } @@ -397,17 +416,20 @@ public struct LoopAlgorithm { carbEntries: [CarbType], sensitivity: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], + target: GlucoseRangeTimeline = [], + scheduledBasalRate: Double? = nil, algorithmEffectsOptions: AlgorithmEffectsOptions = .all, useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, useMidAbsorptionISF: Bool = false, carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), gradualTransitionsThreshold: Double? = 40.0, - momentumVelocityMaximum: LoopQuantity? = nil + momentumVelocityMaximum: LoopQuantity? = nil, + emulation: AlgorithmEmulationOptions = AlgorithmEmulationOptions() ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue { let dosesRelativeToBasal = precomputedInsulin.annotatedDoses - let activeInsulin = dosesRelativeToBasal.insulinOnBoard(at: start) + let activeInsulin = dosesRelativeToBasal.insulinOnBoard(at: start, useLegacyIntegration: emulation.legacyBasalIOB) // ── Insulin effects ────────────────────────────────────────────────────── // Fast path: clip the pre-computed effect timeline to the needed range. @@ -465,8 +487,12 @@ public struct LoopAlgorithm { .combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * 1.01) let rc: RetrospectiveCorrection = useIntegralRetrospectiveCorrection - ? IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) - : StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + ? IntegralRetrospectiveCorrection( + effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, + maxCorrectionVelocity: emulation.disableIRCVelocityCeiling ? nil : IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity, + useLegacyDecay: emulation.legacyRCDecay + ) + : StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, useLegacyDecay: emulation.legacyRCDecay) var prediction: [PredictedGlucoseValue] = [] var retrospectiveCorrectionEffects: [GlucoseEffect] = [] @@ -474,10 +500,20 @@ public struct LoopAlgorithm { var totalRetrospectiveCorrectionEffect: LoopQuantity? if let latestGlucose = glucoseHistory.last { + var integralClamp: IntegralRCClampSettings? + if emulation.integralRCClamp, + let isf = sensitivity.closestPrior(to: start)?.value, + let basalRate = scheduledBasalRate, + let correctionRange = target.closestPrior(to: start)?.value + { + integralClamp = IntegralRCClampSettings(insulinSensitivity: isf, basalRate: basalRate, correctionRange: correctionRange) + } + retrospectiveCorrectionEffects = rc.computeEffect( startingAt: latestGlucose, retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, recencyInterval: TimeInterval(minutes: 15), + integralClamp: integralClamp, retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval ) totalRetrospectiveCorrectionEffect = rc.totalGlucoseCorrectionEffect @@ -492,7 +528,8 @@ public struct LoopAlgorithm { start.addingTimeInterval(-LoopMath.retrospectiveCorrectionGroupingInterval), start ) - if !rcTransitionData.hasGradualTransitions(gradualTransitionThreshold: gradualTransitionsThreshold ?? 40.0) { + if !emulation.noGradualTransitionsGate, + !rcTransitionData.hasGradualTransitions(gradualTransitionThreshold: gradualTransitionsThreshold ?? 40.0) { useRC = false } if !includingPositiveVelocityAndRC, @@ -559,10 +596,12 @@ public struct LoopAlgorithm { basal: input.basal, sensitivity: input.sensitivity, carbRatio: input.carbRatio, + target: input.target, algorithmEffectsOptions: input.algorithmEffectsOptions, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, carbAbsorptionModel: input.carbAbsorptionModel.model, - gradualTransitionsThreshold: input.gradualTransitionsThreshold + gradualTransitionsThreshold: input.gradualTransitionsThreshold, + emulation: input.emulation ?? AlgorithmEmulationOptions() ) } @@ -741,12 +780,14 @@ public struct LoopAlgorithm { 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 + gradualTransitionsThreshold: input.gradualTransitionsThreshold, + emulation: input.emulation ?? AlgorithmEmulationOptions() ) let sensitivityForDosing: [AbsoluteScheduleValue] diff --git a/Sources/LoopAlgorithm/LoopMath.swift b/Sources/LoopAlgorithm/LoopMath.swift index 7fc9e48..cc09a30 100644 --- a/Sources/LoopAlgorithm/LoopMath.swift +++ b/Sources/LoopAlgorithm/LoopMath.swift @@ -184,7 +184,7 @@ extension GlucoseValue { - returns: An array of glucose effects */ - public func decayEffect(atRate rate: LoopQuantity, for duration: TimeInterval, withDelta delta: TimeInterval = 5 * 60) -> [GlucoseEffect] { + public func decayEffect(atRate rate: LoopQuantity, for duration: TimeInterval, withDelta delta: TimeInterval = 5 * 60, useLegacyDecay: Bool = false) -> [GlucoseEffect] { guard let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([self], duration: duration, delta: delta) else { return [] } @@ -192,6 +192,27 @@ extension GlucoseValue { let glucoseUnit = LoopUnit.milligramsPerDeciliter let velocityUnit = GlucoseEffectVelocity.perSecondUnit + // Pre-#33 (deployed Loop): accumulate the decay step-by-step from the floored + // simulation boundary. For samples not aligned to delta boundaries (real, + // jittery CGM timestamps) this exhibits a small discontinuity at bucket + // boundaries; #33 replaced it with the closed-form quadratic below, which is + // identical for delta-aligned samples. + if useLegacyDecay { + let intercept = rate.doubleValue(for: velocityUnit) // mg/dL/s + let decayStartDate = startDate.addingTimeInterval(delta) + let slope = -intercept / (duration - delta) // mg/dL/s/s + var values = [GlucoseEffect(startDate: startDate, quantity: quantity)] + var date = decayStartDate + var lastValue = quantity.doubleValue(for: glucoseUnit) + repeat { + let value = lastValue + (intercept + slope * date.timeIntervalSince(decayStartDate)) * delta + values.append(GlucoseEffect(startDate: date, quantity: LoopQuantity(unit: glucoseUnit, doubleValue: value))) + lastValue = value + date = date.addingTimeInterval(delta) + } while date < endDate + return values + } + let firstChange = rate.doubleValue(for: velocityUnit) * delta // mg/dL/s * s = mg/dL let secondChange = firstChange * (1 - delta / (duration - delta)) diff --git a/Sources/LoopAlgorithm/LoopPredictionInput.swift b/Sources/LoopAlgorithm/LoopPredictionInput.swift index 491dd4c..9ac314c 100644 --- a/Sources/LoopAlgorithm/LoopPredictionInput.swift +++ b/Sources/LoopAlgorithm/LoopPredictionInput.swift @@ -27,6 +27,10 @@ public struct LoopPredictionInput] + // Correction-range timeline. Used only for the IntegralRC integral-correction + // clamp when `emulation.integralRCClamp` is enabled; may be empty otherwise. + public var target: GlucoseRangeTimeline = [] + public var algorithmEffectsOptions: AlgorithmEffectsOptions public var useIntegralRetrospectiveCorrection: Bool = false @@ -37,6 +41,8 @@ public struct LoopPredictionInput], sensitivity: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], + target: GlucoseRangeTimeline = [], algorithmEffectsOptions: AlgorithmEffectsOptions, useIntegralRetrospectiveCorrection: Bool, includePositiveVelocityAndRC: Bool, carbAbsorptionModel: CarbAbsorptionModel, - gradualTransitionsThreshold: Double? = 40.0 + gradualTransitionsThreshold: Double? = 40.0, + emulation: AlgorithmEmulationOptions? = nil ) { self.glucoseHistory = glucoseHistory @@ -57,11 +65,13 @@ public struct LoopPredictionInput].self, forKey: .sensitivity) self.sensitivity = sensitivityMgdl.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value))} self.carbRatio = try container.decode([AbsoluteScheduleValue].self, forKey: .carbRatio) + if let targetMgdl = try container.decodeIfPresent([TargetEntry].self, forKey: .target) { + self.target = targetMgdl.map { + let lower = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.lowerBound) + let upper = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.upperBound) + return AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: lower...upper) + } + } else { + self.target = [] + } if let algorithmEffectsOptionsRaw = try container.decodeIfPresent(AlgorithmEffectsOptions.RawValue.self, forKey: .algorithmEffectsOptions) { self.algorithmEffectsOptions = AlgorithmEffectsOptions(rawValue: algorithmEffectsOptionsRaw) } else { @@ -86,7 +105,7 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco self.includePositiveVelocityAndRC = try container.decodeIfPresent(Bool.self, forKey: .includePositiveVelocityAndRC) ?? true self.carbAbsorptionModel = try container.decodeIfPresent(CarbAbsorptionModel.self, forKey: .carbAbsorptionModel) ?? .piecewiseLinear self.gradualTransitionsThreshold = try container.decodeIfPresent(Double.self, forKey: .gradualTransitionsThreshold) ?? 40.0 - + self.emulation = try container.decodeIfPresent(AlgorithmEmulationOptions.self, forKey: .emulation) } public func encode(to encoder: Encoder) throws { @@ -98,6 +117,16 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco let sensitivityMgdl = sensitivity.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: $0.value.doubleValue(for: .milligramsPerDeciliter)) } try container.encode(sensitivityMgdl, forKey: .sensitivity) try container.encode(carbRatio, forKey: .carbRatio) + if !target.isEmpty { + let targetMgdl = target.map { + TargetEntry( + startDate: $0.startDate, + endDate: $0.endDate, + lowerBound: $0.value.lowerBound.doubleValue(for: .milligramsPerDeciliter), + upperBound: $0.value.upperBound.doubleValue(for: .milligramsPerDeciliter)) + } + try container.encode(targetMgdl, forKey: .target) + } if algorithmEffectsOptions != .all { try container.encode(algorithmEffectsOptions.rawValue, forKey: .algorithmEffectsOptions) } @@ -109,6 +138,16 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco } try container.encode(carbAbsorptionModel, forKey: .carbAbsorptionModel) try container.encode(gradualTransitionsThreshold, forKey: .gradualTransitionsThreshold) + if let emulation { + try container.encode(emulation, forKey: .emulation) + } + } + + struct TargetEntry: Codable { + var startDate: Date + var endDate: Date + var lowerBound: Double + var upperBound: Double } private enum CodingKeys: String, CodingKey { @@ -118,10 +157,12 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco case basal case sensitivity case carbRatio + case target case algorithmEffectsOptions case useIntegralRetrospectiveCorrection case includePositiveVelocityAndRC case carbAbsorptionModel case gradualTransitionsThreshold + case emulation } } diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift index c6a052e..aa5088c 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift @@ -58,6 +58,9 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { /// Ceiling applied to the correction rate (see `defaultMaxCorrectionVelocity`); nil disables it. public let maxCorrectionVelocity: LoopQuantity? + /// Emulate pre-#33 (deployed Loop) step-by-step decay for the RC effect timeline. + let useLegacyDecay: Bool + /// All math is performed with glucose expressed in mg/dL private let unit = LoopUnit.milligramsPerDeciliter @@ -72,9 +75,11 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { var currentDate: Date = Date() public init(effectDuration: TimeInterval, - maxCorrectionVelocity: LoopQuantity? = IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity) { + maxCorrectionVelocity: LoopQuantity? = IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity, + useLegacyDecay: Bool = false) { self.effectDuration = effectDuration self.maxCorrectionVelocity = maxCorrectionVelocity + self.useLegacyDecay = useLegacyDecay } /** @@ -91,6 +96,7 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { startingAt startingGlucose: GlucoseValue, retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, recencyInterval: TimeInterval, + integralClamp: IntegralRCClampSettings? = nil, retrospectiveCorrectionGroupingInterval: TimeInterval ) -> [GlucoseEffect] { @@ -144,6 +150,25 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { IntegralRetrospectiveCorrection.integralGain * discrepancy integralCorrectionEffectMinutes += 2.0 * IntegralRetrospectiveCorrection.delta.minutes } + // Integral-correction clamp (deployed-LoopKit safety bound): bounds the + // wound-up integral term by an ISF×basal-scaled, correction-range-relative + // window so it can't drive the forecast into over-/under-dosing: + // (+) limit: between 1× and 4× the zero-temp effect, larger the further + // glucose sits above the range top (more room to correct a real high). + // (−) limit: at most ~(glucose − rangeMin) below target, with a 10 mg/dL + // floor, capping over-suspension. + // Applied only when the decision-time clamp settings are supplied; when + // nil the integral term is left unclamped, matching the behavior of this + // port before the clamp was restored. + if let clamp = integralClamp { + let rangeMin = clamp.correctionRange.lowerBound.doubleValue(for: unit) + let rangeMax = clamp.correctionRange.upperBound.doubleValue(for: unit) + let latestGlucoseValue = startingGlucose.quantity.doubleValue(for: unit) + let zeroTempEffect = abs(clamp.insulinSensitivity.doubleValue(for: unit) * clamp.basalRate) + let integralEffectPositiveLimit = min(max(latestGlucoseValue - rangeMax, 1.0 * zeroTempEffect), 4.0 * zeroTempEffect) + let integralEffectNegativeLimit = -max(10.0, latestGlucoseValue - rangeMin) + integralCorrection = min(max(integralCorrection, integralEffectNegativeLimit), integralEffectPositiveLimit) + } // Limit effect duration integralCorrectionEffectMinutes = min(integralCorrectionEffectMinutes, IntegralRetrospectiveCorrection.maximumCorrectionEffectDuration.minutes) @@ -187,7 +212,7 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { correctionVelocity = velocity // Update array of glucose correction effects - glucoseCorrectionEffect = startingGlucose.decayEffect(atRate: velocity, for: integralCorrectionEffectDuration!) + glucoseCorrectionEffect = startingGlucose.decayEffect(atRate: velocity, for: integralCorrectionEffectDuration!, useLegacyDecay: useLegacyDecay) // Return glucose correction effects return( glucoseCorrectionEffect ) diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift index ae32319..d13f560 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift @@ -8,6 +8,25 @@ import Foundation +/// Decision-time settings for the integral-correction clamp that deployed +/// LoopKit's IntegralRetrospectiveCorrection applies: the wound-up integral term +/// is bounded by an ISF×basal-scaled, correction-range-relative window. All three +/// values are required for the bound to be well defined, so they travel together. +public struct IntegralRCClampSettings { + /// Insulin sensitivity at decision time + public var insulinSensitivity: LoopQuantity + /// Scheduled basal rate at decision time, in U/hr + public var basalRate: Double + /// Correction range at decision time + public var correctionRange: ClosedRange + + public init(insulinSensitivity: LoopQuantity, basalRate: Double, correctionRange: ClosedRange) { + self.insulinSensitivity = insulinSensitivity + self.basalRate = basalRate + self.correctionRange = correctionRange + } +} + /// Derives a continued glucose effect from recent prediction discrepancies public protocol RetrospectiveCorrection { @@ -20,12 +39,14 @@ public protocol RetrospectiveCorrection { /// - startingAt: Initial glucose value /// - retrospectiveGlucoseDiscrepanciesSummed: Timeline of past discepancies /// - recencyInterval: how recent discrepancy data must be, otherwise effect will be cleared + /// - integralClamp: Settings for the integral-correction clamp. Ignored by StandardRetrospectiveCorrection; when nil, IntegralRetrospectiveCorrection skips the clamp. /// - retrospectiveCorrectionGroupingInterval: Duration of discrepancy measurements /// - Returns: Glucose correction effects func computeEffect( startingAt startingGlucose: GlucoseValue, retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, recencyInterval: TimeInterval, + integralClamp: IntegralRCClampSettings?, retrospectiveCorrectionGroupingInterval: TimeInterval ) -> [GlucoseEffect] } diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift index 0ee2aff..135abbf 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift @@ -17,20 +17,24 @@ public class StandardRetrospectiveCorrection: RetrospectiveCorrection { /// RetrospectiveCorrection protocol variables /// Standard effect duration let effectDuration: TimeInterval + /// Emulate pre-#33 (deployed Loop) step-by-step decay for the RC effect timeline. + let useLegacyDecay: Bool /// Overall retrospective correction effect public var totalGlucoseCorrectionEffect: LoopQuantity? /// All math is performed with glucose expressed in mg/dL private let unit = LoopUnit.milligramsPerDeciliter - public init(effectDuration: TimeInterval) { + public init(effectDuration: TimeInterval, useLegacyDecay: Bool = false) { self.effectDuration = effectDuration + self.useLegacyDecay = useLegacyDecay } public func computeEffect( startingAt startingGlucose: GlucoseValue, retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, recencyInterval: TimeInterval, + integralClamp: IntegralRCClampSettings? = nil, retrospectiveCorrectionGroupingInterval: TimeInterval ) -> [GlucoseEffect] { // Last discrepancy should be recent, otherwise clear the effect and return @@ -51,6 +55,6 @@ public class StandardRetrospectiveCorrection: RetrospectiveCorrection { let velocity = LoopQuantity(unit: .milligramsPerDeciliterPerSecond, doubleValue: currentDiscrepancyValue / discrepancyTime) // Update array of glucose correction effects - return startingGlucose.decayEffect(atRate: velocity, for: effectDuration) + return startingGlucose.decayEffect(atRate: velocity, for: effectDuration, useLegacyDecay: useLegacyDecay) } } diff --git a/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift b/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift new file mode 100644 index 0000000..d77fdfc --- /dev/null +++ b/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift @@ -0,0 +1,203 @@ +// +// AlgorithmEmulationTests.swift +// LoopAlgorithm +// +// Tests for AlgorithmEmulationOptions: the Codable forms, and the legacy +// behaviors each flag re-enables (pre-#35 delta-quantized basal IOB and +// pre-#33 step-by-step RC decay). Every default must preserve current +// behavior exactly. +// + +import XCTest +@testable import LoopAlgorithm + +final class AlgorithmEmulationTests: XCTestCase { + + private let fmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = TimeZone(secondsFromGMT: 0) + return f + }() + + // MARK: - Codable + + func testDecodesPresetString() throws { + let data = Data("\"loop-1.0\"".utf8) + let options = try JSONDecoder().decode(AlgorithmEmulationOptions.self, from: data) + XCTAssertEqual(options, .loop1) + } + + func testEncodesPresetAsString() throws { + let encoded = try JSONEncoder().encode(AlgorithmEmulationOptions.loop1) + XCTAssertEqual(String(data: encoded, encoding: .utf8), "\"loop-1.0\"") + } + + func testDecodesPartialFlagObject() throws { + let data = Data("{\"legacyBasalIOB\": true}".utf8) + let options = try JSONDecoder().decode(AlgorithmEmulationOptions.self, from: data) + XCTAssertTrue(options.legacyBasalIOB) + XCTAssertFalse(options.legacyRCDecay) + XCTAssertFalse(options.integralRCClamp) + XCTAssertFalse(options.disableIRCVelocityCeiling) + XCTAssertFalse(options.noGradualTransitionsGate) + } + + func testUnknownPresetThrows() { + let data = Data("\"loop-9.9\"".utf8) + XCTAssertThrowsError(try JSONDecoder().decode(AlgorithmEmulationOptions.self, from: data)) + } + + func testNonPresetOptionsRoundTripAsObject() throws { + let options = AlgorithmEmulationOptions(legacyRCDecay: true) + let encoded = try JSONEncoder().encode(options) + let decoded = try JSONDecoder().decode(AlgorithmEmulationOptions.self, from: encoded) + XCTAssertEqual(decoded, options) + } + + // MARK: - Legacy basal IOB (pre-#35 delta-quantized integration) + + func testLegacyBasalIOBReproducesRipple() { + let start = fmt.date(from: "2020-01-01T00:00:00")! + let segMin = 20.0 + // A 20-min suspend: delivered 0 vs scheduled 1.5 U/hr. + let dose = BasalRelativeDose( + type: .basal(scheduledRate: 1.5), + startDate: start, + endDate: start.addingTimeInterval(segMin * 60), + volume: 0 + ) + + // Sample IOB at 1-min spacing across the segment and its tail. + var legacy: [Double] = [] + var continuous: [Double] = [] + for minute in stride(from: 1.0, through: 60.0, by: 1.0) { + let date = start.addingTimeInterval(minute * 60) + legacy.append([dose].insulinOnBoard(at: date, useLegacyIntegration: true)) + continuous.append([dose].insulinOnBoard(at: date, useLegacyIntegration: false)) + } + + // The legacy integration bound is quantized to the delta grid, so the two + // must diverge measurably between delta boundaries... + let maxDiff = zip(legacy, continuous).map { abs($0 - $1) }.max()! + XCTAssertGreaterThan(maxDiff, 0.01, "Legacy integration should reproduce the delta-scale IOB ripple") + + // ...while remaining the same signal at delta-scale: both represent the + // same net insulin, so they must agree to within one delta chunk. + XCTAssertLessThan(maxDiff, 0.5) + } + + func testDefaultEmulationMatchesCurrentIOB() { + let start = fmt.date(from: "2020-01-01T00:00:00")! + let dose = BasalRelativeDose( + type: .basal(scheduledRate: 1.5), + startDate: start, + endDate: start.addingTimeInterval(20 * 60), + volume: 0 + ) + let date = start.addingTimeInterval(17 * 60) + XCTAssertEqual( + [dose].insulinOnBoard(at: date), + [dose].insulinOnBoard(at: date, useLegacyIntegration: false) + ) + } + + // MARK: - Legacy RC decay (pre-#33 step-by-step accumulation) + + func testLegacyDecayMatchesContinuousForAlignedSample() { + // For a delta-aligned sample the step-by-step and closed-form + // formulations are the same function. + let start = fmt.date(from: "2020-01-01T00:00:00")! + let sample = SimpleGlucoseValue(startDate: start, quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120)) + let rate = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 2) + + let legacy = sample.decayEffect(atRate: rate, for: .minutes(60), useLegacyDecay: true) + let continuous = sample.decayEffect(atRate: rate, for: .minutes(60), useLegacyDecay: false) + + XCTAssertEqual(legacy.count, continuous.count) + for (l, c) in zip(legacy, continuous) { + XCTAssertEqual(l.startDate, c.startDate) + XCTAssertEqual( + l.quantity.doubleValue(for: .milligramsPerDeciliter), + c.quantity.doubleValue(for: .milligramsPerDeciliter), + accuracy: 1e-9 + ) + } + } + + func testLegacyDecayDivergesForUnalignedSample() { + // For a sample not aligned to the delta grid (real CGM jitter), the + // legacy accumulation exhibits its bucket-boundary discontinuity and + // the two formulations differ. + let start = fmt.date(from: "2020-01-01T00:02:13")! + let sample = SimpleGlucoseValue(startDate: start, quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120)) + let rate = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 2) + + let legacy = sample.decayEffect(atRate: rate, for: .minutes(60), useLegacyDecay: true) + let continuous = sample.decayEffect(atRate: rate, for: .minutes(60), useLegacyDecay: false) + + XCTAssertEqual(legacy.count, continuous.count) + let maxDiff = zip(legacy, continuous) + .map { abs($0.quantity.doubleValue(for: .milligramsPerDeciliter) - $1.quantity.doubleValue(for: .milligramsPerDeciliter)) } + .max()! + XCTAssertGreaterThan(maxDiff, 0.1, "Legacy decay should differ for unaligned sample timestamps") + } + + // MARK: - Insulin model presets (Tidepool Loop 1.0 adult/child choice) + + func testRapidActingModelPresetsSelectableAsInsulinTypes() throws { + XCTAssertEqual( + FixtureInsulinType.rapidActingAdult.insulinModel as? ExponentialInsulinModelPreset, + ExponentialInsulinModelPreset.rapidActingAdult + ) + XCTAssertEqual( + FixtureInsulinType.rapidActingChild.insulinModel as? ExponentialInsulinModelPreset, + ExponentialInsulinModelPreset.rapidActingChild + ) + + // Selectable from JSON, for both the recommendation model and doses. + XCTAssertEqual( + try JSONDecoder().decode(FixtureInsulinType.self, from: Data("\"rapidActingChild\"".utf8)), + .rapidActingChild + ) + + let url = Bundle.module.url(forResource: "suspend_scenario", withExtension: "json", subdirectory: "Fixtures")! + var json = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as! [String: Any] + json["recommendationInsulinType"] = "rapidActingChild" + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let fixture = try decoder.decode(AlgorithmInputFixture.self, from: try JSONSerialization.data(withJSONObject: json)) + XCTAssertEqual(fixture.recommendationInsulinType, .rapidActingChild) + XCTAssertEqual(fixture.recommendationInsulinModel as? ExponentialInsulinModelPreset, .rapidActingChild) + } + + // MARK: - Fixture plumbing + + func testFixtureDecodesEmulationPreset() throws { + let url = Bundle.module.url(forResource: "suspend_scenario", withExtension: "json", subdirectory: "Fixtures")! + var json = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as! [String: Any] + json["emulation"] = "loop-1.0" + let data = try JSONSerialization.data(withJSONObject: json) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let fixture = try decoder.decode(AlgorithmInputFixture.self, from: data) + XCTAssertEqual(fixture.emulation, .loop1) + + // Emulated and non-emulated runs on the same input should both produce + // a recommendation, and their forecasts should differ. + var plain = fixture + plain.emulation = nil + let emulated = LoopAlgorithm.run(input: fixture) + let current = LoopAlgorithm.run(input: plain) + XCTAssertNotNil(try? emulated.recommendationResult.get()) + XCTAssertNotNil(try? current.recommendationResult.get()) + } + + func testFixtureWithoutEmulationDecodesNil() throws { + let url = Bundle.module.url(forResource: "suspend_scenario", withExtension: "json", subdirectory: "Fixtures")! + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let fixture = try decoder.decode(AlgorithmInputFixture.self, from: Data(contentsOf: url)) + XCTAssertNil(fixture.emulation) + } +} diff --git a/Tests/LoopAlgorithmTests/IntegralRetrospectiveCorrectionClampTests.swift b/Tests/LoopAlgorithmTests/IntegralRetrospectiveCorrectionClampTests.swift new file mode 100644 index 0000000..6cb938a --- /dev/null +++ b/Tests/LoopAlgorithmTests/IntegralRetrospectiveCorrectionClampTests.swift @@ -0,0 +1,170 @@ +// +// IntegralRetrospectiveCorrectionClampTests.swift +// LoopAlgorithm +// +// Tests for the deployed-LoopKit integral-correction CLAMP that was dropped in +// the LoopAlgorithm port. The clamp bounds the wound-up integral term by an +// ISF×basal-scaled, target-relative window so IRC can't drive the forecast +// into over-/under-dosing. When the clamp settings are nil, IRC must behave +// exactly as before (unclamped), byte-identical to prior runs. +// + +import XCTest +@testable import LoopAlgorithm + +final class IntegralRetrospectiveCorrectionClampTests: XCTestCase { + + private let unit = LoopUnit.milligramsPerDeciliter + + /// Builds a dense (5-min spaced) run of constant same-sign discrepancies over + /// the full 180-min retrospection window so the integral converges toward its + /// asymptote (≈ 1.087 × discrepancy value). The last entry ends at `glucoseDate` + /// so it passes the recency guard. + private func discrepancies(value: Double, endingAt glucoseDate: Date, count: Int = 36) -> [GlucoseChange] { + (0.. IntegralRetrospectiveCorrection { + IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + } + + private func range(_ lo: Double, _ hi: Double) -> ClosedRange { + LoopQuantity(unit: unit, doubleValue: lo)...LoopQuantity(unit: unit, doubleValue: hi) + } + + // MARK: – Positive clamp + + func testPositiveIntegralIsClampedToPositiveLimit() { + let glucoseDate = Date() + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: LoopQuantity(unit: unit, doubleValue: 200)) + let discr = discrepancies(value: 100, endingAt: glucoseDate) + + // zeroTempEffect = |50 × 1.0| = 50. + // positiveLimit = min(max(200-120, 1×50), 4×50) = min(max(80,50),200) = 80. + let isf = LoopQuantity(unit: unit, doubleValue: 50) + let basal = 1.0 + + // Unclamped reference (nil inputs ⇒ clamp skipped). + let ircUnclamped = makeIRC() + _ = ircUnclamped.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: discr, + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval) + XCTAssertGreaterThan(ircUnclamped.integralCorrection, 80, + "Unclamped integral should wind up past the would-be clamp limit") + + // Clamped run. + let ircClamped = makeIRC() + _ = ircClamped.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: discr, + recencyInterval: .minutes(15), + integralClamp: IntegralRCClampSettings(insulinSensitivity: isf, basalRate: basal, correctionRange: range(100, 120)), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval) + XCTAssertEqual(ircClamped.integralCorrection, 80, accuracy: 1e-9, + "Wound-up positive integral must be clamped to the ISF×basal/target positive limit") + } + + // MARK: – Negative clamp + + func testNegativeIntegralIsClampedToNegativeLimit() { + let glucoseDate = Date() + // BG below range: negativeLimit = -max(10, 60-100) = -max(10,-40) = -10. + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: LoopQuantity(unit: unit, doubleValue: 60)) + let discr = discrepancies(value: -100, endingAt: glucoseDate) + let isf = LoopQuantity(unit: unit, doubleValue: 50) + + let ircUnclamped = makeIRC() + _ = ircUnclamped.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: discr, + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval) + XCTAssertLessThan(ircUnclamped.integralCorrection, -10, + "Unclamped negative integral should wind down past the would-be clamp floor") + + let ircClamped = makeIRC() + _ = ircClamped.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: discr, + recencyInterval: .minutes(15), + integralClamp: IntegralRCClampSettings(insulinSensitivity: isf, basalRate: 1.0, correctionRange: range(100, 120)), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval) + XCTAssertEqual(ircClamped.integralCorrection, -10, accuracy: 1e-9, + "Wound-up negative integral must be clamped to the target-relative negative limit") + } + + // MARK: – Clamp is a no-op within bounds + + func testIntegralWithinBoundsIsUnchangedByClamp() { + let glucoseDate = Date() + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: LoopQuantity(unit: unit, doubleValue: 130)) + // Small discrepancy ⇒ integral ≈ 5.4, well inside posLimit (=50 at BG 130). + let discr = discrepancies(value: 5, endingAt: glucoseDate) + let isf = LoopQuantity(unit: unit, doubleValue: 50) + + let ircUnclamped = makeIRC() + _ = ircUnclamped.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: discr, + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval) + + let ircClamped = makeIRC() + _ = ircClamped.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: discr, + recencyInterval: .minutes(15), + integralClamp: IntegralRCClampSettings(insulinSensitivity: isf, basalRate: 1.0, correctionRange: range(100, 120)), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval) + + XCTAssertEqual(ircClamped.integralCorrection, ircUnclamped.integralCorrection, accuracy: 1e-12, + "An integral already inside the clamp window must be left unchanged") + } + + // MARK: – End-to-end effect magnitude + + func testClampReducesTotalCorrectionEffectMagnitude() { + let glucoseDate = Date() + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: LoopQuantity(unit: unit, doubleValue: 200)) + let discr = discrepancies(value: 100, endingAt: glucoseDate) + let isf = LoopQuantity(unit: unit, doubleValue: 50) + + let ircUnclamped = makeIRC() + let effUnclamped = ircUnclamped.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: discr, + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval) + + let ircClamped = makeIRC() + let effClamped = ircClamped.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: discr, + recencyInterval: .minutes(15), + integralClamp: IntegralRCClampSettings(insulinSensitivity: isf, basalRate: 1.0, correctionRange: range(100, 120)), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval) + + XCTAssertFalse(effUnclamped.isEmpty) + XCTAssertFalse(effClamped.isEmpty) + + let lastUnclamped = effUnclamped.last!.quantity.doubleValue(for: unit) + let lastClamped = effClamped.last!.quantity.doubleValue(for: unit) + XCTAssertGreaterThan(lastUnclamped, lastClamped, + "Clamping the integral must reduce the projected positive correction effect") + + let totalUnclamped = ircUnclamped.totalGlucoseCorrectionEffect!.doubleValue(for: unit) + let totalClamped = ircClamped.totalGlucoseCorrectionEffect!.doubleValue(for: unit) + // Difference should equal the integral reduction (proportional/differential unchanged). + XCTAssertEqual(totalUnclamped - totalClamped, + ircUnclamped.integralCorrection - ircClamped.integralCorrection, + accuracy: 1e-9) + } +}