From 377c18a757e869336262fd8dec14b116490dd79e Mon Sep 17 00:00:00 2001 From: LoopKit Developer Date: Fri, 24 Jul 2026 17:20:13 -0500 Subject: [PATCH] Bound IntegralRC correction rate to a settings-free physiological ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port from LoopKit to LoopAlgorithm dropped IntegralRetrospectiveCorrection's integral clamp — a safety bound on the wound-up integral term. Rather than restore the original clamp (which scaled the bound by ISF x basal and the target range), this bounds the RC correction RATE to a physiological ceiling (mg/dL/min), independent of user settings. Rationale: RC forecasts unmodeled physiology, whose plausible velocity does not depend on a person's insulin needs or target range. The original clamp's implied "plausible unmodeled velocity" varied ~11x across settings (0.55-4.9 mg/dL/min for ISF x basal of 18-160) - trusting the same real carb rise for a high-need user while clamping it for a low-need one. The integral is already self-limiting (leaky integrator; converges to ~1.087 x discrepancy), so the clamp is a rare-event backstop for large or spurious discrepancies, and a fixed physiological rate ceiling is the right shape for that. Default 4 mg/dL/min (top of plausible sustained unmodeled velocity). On real IRC data (two datasets) this reproduces the deployed clamp's behavior - both near-inert in normal operation - without the settings coupling. Configurable via IntegralRetrospectiveCorrection(effectDuration:maxCorrectionVelocity:); nil disables. Tests: correction rate clamped to +/- the ceiling on a large windup, symmetric for negative discrepancies, and inert when the rate is below the ceiling. --- .../IntegralRetrospectiveCorrection.swift | 36 +++++++-- ...IntegralRetrospectiveCorrectionTests.swift | 77 +++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift index d850b9d..c6a052e 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift @@ -45,20 +45,36 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { static let integralGain: Double = ((1 - integralForget) / integralForget) * (persistentDiscrepancyGain - currentDiscrepancyGain) static let proportionalGain: Double = currentDiscrepancyGain - integralGain - + + /// Default ceiling on the RC correction rate. Bounds how fast the integral RC may bend + /// the forecast, so a large or spurious sustained discrepancy can't wind the correction + /// up into over-/under-dosing. Chosen at the top of physiologically-plausible *sustained* + /// unmodeled glucose velocity (~4 mg/dL/min); unlike a bound scaled by ISF/basal/target, + /// it does not depend on the user's dosing settings — the plausible velocity of unmodeled + /// physiology is the same regardless of a person's insulin needs or target range. + public static let defaultMaxCorrectionVelocity = LoopQuantity( + unit: .milligramsPerDeciliterPerSecond, doubleValue: 4.0 / 60.0) + + /// Ceiling applied to the correction rate (see `defaultMaxCorrectionVelocity`); nil disables it. + public let maxCorrectionVelocity: LoopQuantity? + /// All math is performed with glucose expressed in mg/dL private let unit = LoopUnit.milligramsPerDeciliter - + /// State variables reported in diagnostic issue report var recentDiscrepancyValues: [Double] = [] var integralCorrectionEffectDuration: TimeInterval? var proportionalCorrection: Double = 0.0 var integralCorrection: Double = 0.0 var differentialCorrection: Double = 0.0 + /// Correction rate actually used (after the `maxCorrectionVelocity` clamp), for diagnostics. + var correctionVelocity: LoopQuantity? var currentDate: Date = Date() - public init(effectDuration: TimeInterval) { + public init(effectDuration: TimeInterval, + maxCorrectionVelocity: LoopQuantity? = IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity) { self.effectDuration = effectDuration + self.maxCorrectionVelocity = maxCorrectionVelocity } /** @@ -158,8 +174,18 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { let retrospectionTimeInterval = currentDiscrepancy.endDate.timeIntervalSince(currentDiscrepancy.startDate) let discrepancyTime = max(retrospectionTimeInterval, retrospectiveCorrectionGroupingInterval) - let velocity = LoopQuantity(unit: .milligramsPerDeciliterPerSecond, doubleValue: scaledCorrection / discrepancyTime) - + var velocityValue = scaledCorrection / discrepancyTime + + // Bound the correction rate to a settings-free physiological ceiling (see + // `defaultMaxCorrectionVelocity`), so a large or spurious sustained discrepancy + // can't wind the integral up into over-/under-dosing. + if let maxCorrectionVelocity { + let cap = abs(maxCorrectionVelocity.doubleValue(for: .milligramsPerDeciliterPerSecond)) + velocityValue = min(max(velocityValue, -cap), cap) + } + let velocity = LoopQuantity(unit: .milligramsPerDeciliterPerSecond, doubleValue: velocityValue) + correctionVelocity = velocity + // Update array of glucose correction effects glucoseCorrectionEffect = startingGlucose.decayEffect(atRate: velocity, for: integralCorrectionEffectDuration!) diff --git a/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift b/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift index ccd112f..d30ba76 100644 --- a/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift +++ b/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift @@ -45,4 +45,81 @@ final class IntegralRetrospectiveCorrectionTests: XCTestCase { XCTAssertEqual(effect.last?.quantity.doubleValue(for: .milligramsPerDeciliter) ?? 0, 110, accuracy: 0.05) XCTAssertEqual(effect.last?.startDate, dateFormatter.date(from: "2015-07-13T13:00:00")!) } + + // MARK: - Correction-rate clamp (settings-free) + // + // A physiological ceiling on the RC correction rate replaces the deployed-LoopKit + // integral clamp, which scaled the bound by ISF×basal and the target range. See + // `defaultMaxCorrectionVelocity`. + + private let capMgdlPerSec = 4.0 / 60.0 + + /// `count` contiguous 5-min discrepancies of `valuePerStep` mg/dL, ending at `endingAt`. + private func windup(endingAt: Date, count: Int, valuePerStep: Double) -> [GlucoseChange] { + (0.. [GlucoseEffect] { + irc.computeEffect( + startingAt: from, + retrospectiveGlucoseDiscrepanciesSummed: discrepancies, + recencyInterval: TimeInterval(minutes: 15), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval) + } + + func testCorrectionRateClampedToCeiling() { + let start = dateFormatter.date(from: "2015-07-13T12:00:00")! + let g = SimpleGlucoseValue(startDate: start, quantity: .glucose(150)) + // Huge sustained under-prediction so the correction rate exceeds the ceiling. + let discrepancies = windup(endingAt: start, count: 18, valuePerStep: 250) + + let unclamped = IntegralRetrospectiveCorrection( + effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, maxCorrectionVelocity: nil) + let unclampedEffect = compute(unclamped, from: g, discrepancies) + XCTAssertGreaterThan(unclamped.correctionVelocity!.doubleValue(for: .milligramsPerDeciliterPerSecond), + capMgdlPerSec, "windup must exceed the ceiling so the clamp is exercised") + + // Default ceiling (4 mg/dL/min). + let clamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + let clampedEffect = compute(clamped, from: g, discrepancies) + XCTAssertEqual(clamped.correctionVelocity!.doubleValue(for: .milligramsPerDeciliterPerSecond), + capMgdlPerSec, accuracy: 1e-9) + // Clamping the rate reduces the forecast excursion. + XCTAssertLessThan(clampedEffect.last!.quantity.doubleValue(for: .milligramsPerDeciliter), + unclampedEffect.last!.quantity.doubleValue(for: .milligramsPerDeciliter)) + } + + func testCorrectionRateClampIsSymmetricForNegativeDiscrepancies() { + let start = dateFormatter.date(from: "2015-07-13T12:00:00")! + let g = SimpleGlucoseValue(startDate: start, quantity: .glucose(90)) + let discrepancies = windup(endingAt: start, count: 18, valuePerStep: -250) + + let clamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + _ = compute(clamped, from: g, discrepancies) + XCTAssertEqual(clamped.correctionVelocity!.doubleValue(for: .milligramsPerDeciliterPerSecond), + -capMgdlPerSec, accuracy: 1e-9) + } + + func testCorrectionRateUnclampedWhenBelowCeiling() { + let start = dateFormatter.date(from: "2015-07-13T12:00:00")! + let g = SimpleGlucoseValue(startDate: start, quantity: .glucose(120)) + // Small windup: rate stays under the ceiling, so the clamp must not alter anything. + let discrepancies = windup(endingAt: start, count: 3, valuePerStep: 5) + + let clamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + let e1 = compute(clamped, from: g, discrepancies) + XCTAssertLessThan(abs(clamped.correctionVelocity!.doubleValue(for: .milligramsPerDeciliterPerSecond)), + capMgdlPerSec) + + let noClamp = IntegralRetrospectiveCorrection( + effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, maxCorrectionVelocity: nil) + let e2 = compute(noClamp, from: g, discrepancies) + XCTAssertEqual(e1.last!.quantity.doubleValue(for: .milligramsPerDeciliter), + e2.last!.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: 1e-9) + } }