diff --git a/Sources/LoopAlgorithm/AlgorithmEmulationOptions.swift b/Sources/LoopAlgorithm/AlgorithmEmulationOptions.swift index 5559091..69f1b88 100644 --- a/Sources/LoopAlgorithm/AlgorithmEmulationOptions.swift +++ b/Sources/LoopAlgorithm/AlgorithmEmulationOptions.swift @@ -48,42 +48,74 @@ public struct AlgorithmEmulationOptions: Equatable, Sendable { /// 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. + /// leaves `gradualTransitionsThreshold` in effect for retrospective correction + /// and the fixed 40 mg/dL gate in effect for momentum. public var noGradualTransitionsGate: Bool + /// Disable the max-active-insulin limit on automatic dosing (the + /// `maxBolus × maxActiveInsulinMultiplier` headroom cap). Tidepool Loop 1.0's + /// temp basal recommendation had no active-insulin limit, so with IOB above the + /// limit the current port caps the temp basal below scheduled — possibly to + /// zero — where 1.0 would correct a high. False (default) enforces the limit. + public var disableMaxActiveInsulinLimit: Bool + public init( legacyBasalIOB: Bool = false, legacyRCDecay: Bool = false, integralRCClamp: Bool = false, disableIRCVelocityCeiling: Bool = false, - noGradualTransitionsGate: Bool = false + noGradualTransitionsGate: Bool = false, + disableMaxActiveInsulinLimit: Bool = false ) { self.legacyBasalIOB = legacyBasalIOB self.legacyRCDecay = legacyRCDecay self.integralRCClamp = integralRCClamp self.disableIRCVelocityCeiling = disableIRCVelocityCeiling self.noGradualTransitionsGate = noGradualTransitionsGate + self.disableMaxActiveInsulinLimit = disableMaxActiveInsulinLimit } /// 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: + /// These options cover the prediction/effects math only. Other 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 + /// `.tempBasal`. The fixture default is `.automaticBolus`. + /// - It used standard retrospective correction only (60 min effect); it + /// had no integral retrospective correction: set + /// `useIntegralRetrospectiveCorrection` to false. The two IRC options + /// above then have no effect; they matter only for emulating DIY Loop's + /// integral RC. + /// - It applied each dose's insulin effect with the ISF in effect at the + /// dose's start time: set `useMidAbsorptionISF` to false. + /// - Users chose between the rapid-acting adult and rapid-acting child + /// models: set `recommendationInsulinType` (and dose insulin types) to /// `.rapidActingAdult` or `.rapidActingChild`. + /// + /// Callers must also reproduce these parts of Tidepool Loop 1.0's dosing + /// themselves, since they sit outside this package: + /// - Overrides were applied to the basal, ISF and correction range + /// schedules before dosing. 1.0 checked override expiry against the + /// current time, so an override active at decision time covered every + /// forecast point, even past its end: extend it to the end of the + /// `target` timeline. + /// - The correction was computed at the latest CGM timestamp, including + /// the current glucose in the suspend and minimum-glucose checks: set + /// `predictionStart` to that timestamp. + /// - The recommended rate was rounded to a rate the pump supports, and a + /// running temp basal at the same rate with more than 11 minutes left + /// was continued rather than reissued. + /// - No recommendation was made while a bolus was in progress. public static let loop1 = AlgorithmEmulationOptions( legacyBasalIOB: true, legacyRCDecay: true, integralRCClamp: true, disableIRCVelocityCeiling: true, - noGradualTransitionsGate: true + noGradualTransitionsGate: true, + disableMaxActiveInsulinLimit: true ) } @@ -96,6 +128,7 @@ extension AlgorithmEmulationOptions: Codable { case integralRCClamp case disableIRCVelocityCeiling case noGradualTransitionsGate + case disableMaxActiveInsulinLimit } public init(from decoder: Decoder) throws { @@ -116,6 +149,7 @@ extension AlgorithmEmulationOptions: Codable { 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 + self.disableMaxActiveInsulinLimit = try container.decodeIfPresent(Bool.self, forKey: .disableMaxActiveInsulinLimit) ?? false } public func encode(to encoder: Encoder) throws { @@ -130,5 +164,6 @@ extension AlgorithmEmulationOptions: Codable { try container.encode(integralRCClamp, forKey: .integralRCClamp) try container.encode(disableIRCVelocityCeiling, forKey: .disableIRCVelocityCeiling) try container.encode(noGradualTransitionsGate, forKey: .noGradualTransitionsGate) + try container.encode(disableMaxActiveInsulinLimit, forKey: .disableMaxActiveInsulinLimit) } } diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift index 9987980..78fd88d 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift @@ -107,18 +107,22 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In /// - duration: The duration of the effects /// - delta: The time differential for the returned values /// - velocityMaximum: The limit on how fast the momentum effect can be. Defaults to 4 mg/dL/min based on physiological rates, if nil passed. + /// - gradualTransitionThreshold: Maximum allowed difference between consecutive readings in mg/dL; momentum is + /// not computed across a larger jump. Nil disables the gate, as in deployed Loop before the gate was added. /// - Returns: An array of glucose effects public func linearMomentumEffect( duration: TimeInterval = GlucoseMath.momentumDuration, delta: TimeInterval = GlucoseMath.defaultDelta, - velocityMaximum: LoopQuantity? = nil + velocityMaximum: LoopQuantity? = nil, + gradualTransitionThreshold: Double? = 40.0 ) -> [GlucoseEffect] { let velocityMax = velocityMaximum ?? LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 4.0) guard self.count > 2, // Linear regression isn't much use without 3 or more entries. - hasGradualTransitions() && isContinuous() && !containsCalibrations() && hasSingleProvenance, + gradualTransitionThreshold.map({ hasGradualTransitions(gradualTransitionThreshold: $0) }) ?? true, + isContinuous() && !containsCalibrations() && hasSingleProvenance, let firstSample = self.first, let lastSample = self.last, let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([lastSample], duration: duration, delta: delta) diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 50974c8..890abe1 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -331,7 +331,10 @@ public struct LoopAlgorithm { var useMomentum: Bool = true if algorithmEffectsOptions.contains(.momentum) { let momentumInputData = glucoseHistory.filterDateRange(start.addingTimeInterval(-GlucoseMath.momentumDataInterval), start) - momentumEffects = momentumInputData.linearMomentumEffect(velocityMaximum: momentumVelocityMaximum) + momentumEffects = momentumInputData.linearMomentumEffect( + velocityMaximum: momentumVelocityMaximum, + gradualTransitionThreshold: emulation.noGradualTransitionsGate ? nil : 40.0 + ) if !includingPositiveVelocityAndRC, let netMomentum = momentumEffects.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { // positive momentum is turned off useMomentum = false @@ -545,7 +548,10 @@ public struct LoopAlgorithm { let momentumInputData = glucoseHistory.filterDateRange( start.addingTimeInterval(-GlucoseMath.momentumDataInterval), start ) - momentumEffects = momentumInputData.linearMomentumEffect(velocityMaximum: momentumVelocityMaximum) + momentumEffects = momentumInputData.linearMomentumEffect( + velocityMaximum: momentumVelocityMaximum, + gradualTransitionThreshold: emulation.noGradualTransitionsGate ? nil : 40.0 + ) if !includingPositiveVelocityAndRC, let netMomentum = momentumEffects.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { @@ -813,7 +819,10 @@ public struct LoopAlgorithm { sensitivity: sensitivityForDosing, insulinModel: input.recommendationInsulinModel) - let maxActiveInsulin = input.maxBolus * (input.maxActiveInsulinMultiplier ?? 2) + let emulation = input.emulation ?? AlgorithmEmulationOptions() + let maxActiveInsulin = emulation.disableMaxActiveInsulinLimit + ? Double.infinity + : input.maxBolus * (input.maxActiveInsulinMultiplier ?? 2) switch input.recommendationType { case .manualBolus: diff --git a/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift b/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift index d77fdfc..b5156f6 100644 --- a/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift +++ b/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift @@ -40,6 +40,7 @@ final class AlgorithmEmulationTests: XCTestCase { XCTAssertFalse(options.integralRCClamp) XCTAssertFalse(options.disableIRCVelocityCeiling) XCTAssertFalse(options.noGradualTransitionsGate) + XCTAssertFalse(options.disableMaxActiveInsulinLimit) } func testUnknownPresetThrows() { @@ -142,6 +143,87 @@ final class AlgorithmEmulationTests: XCTestCase { XCTAssertGreaterThan(maxDiff, 0.1, "Legacy decay should differ for unaligned sample timestamps") } + // MARK: - Momentum gradual-transitions gate + + private func momentumSamples(jumpAt jumpIndex: Int, jump: Double) -> [FixtureGlucoseSample] { + let start = fmt.date(from: "2020-01-01T00:00:00")! + return (0..<4).map { i in + let value = 120 + Double(i) * 5 + (i >= jumpIndex ? jump : 0) + return FixtureGlucoseSample( + startDate: start.addingTimeInterval(Double(i) * 5 * 60), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: value) + ) + } + } + + func testMomentumGateDropsEffectAcrossLargeJumpByDefault() { + let samples = momentumSamples(jumpAt: 2, jump: 45) + XCTAssertTrue(samples.linearMomentumEffect().isEmpty) + XCTAssertTrue(samples.linearMomentumEffect(gradualTransitionThreshold: 40).isEmpty) + } + + func testNilThresholdDisablesMomentumGate() { + // Deployed Loop 1.0 computed momentum across any jump (LoopKit + // GlucoseMath.linearMomentumEffect guarded only continuity, calibration + // and provenance). + let samples = momentumSamples(jumpAt: 2, jump: 45) + XCTAssertFalse(samples.linearMomentumEffect(gradualTransitionThreshold: nil).isEmpty) + } + + func testMomentumGateIsNoOpWithoutLargeJump() { + let samples = momentumSamples(jumpAt: 2, jump: 0) + let gated = samples.linearMomentumEffect() + let ungated = samples.linearMomentumEffect(gradualTransitionThreshold: nil) + XCTAssertFalse(gated.isEmpty) + XCTAssertEqual(gated, ungated) + } + + // MARK: - Max active insulin limit + + private func highTempBasalFixture(maxBolus: Double, emulation: AlgorithmEmulationOptions?) throws -> AlgorithmInputFixture { + // The suspend scenario with its glucose history raised 150 mg/dL, so the + // forecast is well above range and the temp basal wants to go high. + 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["recommendationType"] = "tempBasal" + json["maxBolus"] = maxBolus + json["glucoseHistory"] = (json["glucoseHistory"] as! [[String: Any]]).map { sample in + var sample = sample + sample["value"] = (sample["value"] as! Double) + 150 + return sample + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + var fixture = try decoder.decode(AlgorithmInputFixture.self, from: try JSONSerialization.data(withJSONObject: json)) + fixture.emulation = emulation + return fixture + } + + private func tempBasalRate(_ output: AlgorithmOutput) throws -> Double { + let recommendation = try output.recommendationResult.get() + return try XCTUnwrap(recommendation.automatic?.basalAdjustment.unitsPerHour) + } + + func testDisableMaxActiveInsulinLimitRemovesTempBasalCap() throws { + // With a tiny maxBolus the active-insulin limit binds: the port caps the + // temp basal at or below scheduled despite a forecast high. Tidepool Loop + // 1.0 had no such limit, so the emulated rate must be uncapped and match + // the rate with an unbounded limit. + let capped = LoopAlgorithm.run(input: try highTempBasalFixture(maxBolus: 0.05, emulation: nil)) + let uncapped = LoopAlgorithm.run(input: try highTempBasalFixture( + maxBolus: 0.05, emulation: AlgorithmEmulationOptions(disableMaxActiveInsulinLimit: true))) + let reference = LoopAlgorithm.run(input: try highTempBasalFixture(maxBolus: 1000, emulation: nil)) + + XCTAssertGreaterThan(try tempBasalRate(uncapped), try tempBasalRate(capped)) + XCTAssertEqual(try tempBasalRate(uncapped), try tempBasalRate(reference), accuracy: 1e-9) + } + + func testLoop1PresetIncludesNewFlags() { + XCTAssertTrue(AlgorithmEmulationOptions.loop1.noGradualTransitionsGate) + XCTAssertTrue(AlgorithmEmulationOptions.loop1.disableMaxActiveInsulinLimit) + XCTAssertFalse(AlgorithmEmulationOptions().disableMaxActiveInsulinLimit) + } + // MARK: - Insulin model presets (Tidepool Loop 1.0 adult/child choice) func testRapidActingModelPresetsSelectableAsInsulinTypes() throws {