diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index fce1746..ef53a46 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -174,6 +174,10 @@ public struct LoopAlgorithm { basal: [AbsoluteScheduleValue], sensitivity: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], + // Correction-range timeline — used only to clamp the IntegralRC integral term + // (deployed-LoopKit safety bound). nil ⇒ IRC clamp skipped (legacy unclamped + // behavior). `run()` always passes `input.target`, so deployed dosing is clamped. + target: GlucoseRangeTimeline? = nil, algorithmEffectsOptions: AlgorithmEffectsOptions = .all, useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, @@ -262,10 +266,18 @@ public struct LoopAlgorithm { } if let latestGlucose = glucoseHistory.last { + // Inputs for the IntegralRC integral-correction clamp (deployed-LoopKit safety + // bound). nil target ⇒ clamp skipped (legacy unclamped behavior). + let clampISF = sensitivity.closestPrior(to: start)?.value + let clampBasal = basal.closestPrior(to: start)?.value + let clampRange = target?.closestPrior(to: start)?.value retrospectiveCorrectionEffects = rc.computeEffect( startingAt: latestGlucose, retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, recencyInterval: TimeInterval(minutes: 15), + insulinSensitivity: clampISF, + basalRate: clampBasal, + correctionRange: clampRange, retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval ) @@ -397,6 +409,12 @@ public struct LoopAlgorithm { carbEntries: [CarbType], sensitivity: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], + // Correction-range timeline + scheduled basal rate — used only to clamp the + // IntegralRC integral term (deployed-LoopKit safety bound). This overload has no + // basal schedule (insulin is precomputed), so the scheduled basal rate at the + // decision time is passed in. nil target ⇒ IRC clamp skipped (legacy behavior). + target: GlucoseRangeTimeline? = nil, + scheduledBasalRate: Double? = nil, algorithmEffectsOptions: AlgorithmEffectsOptions = .all, useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, @@ -474,10 +492,18 @@ public struct LoopAlgorithm { var totalRetrospectiveCorrectionEffect: LoopQuantity? if let latestGlucose = glucoseHistory.last { + // Inputs for the IntegralRC integral-correction clamp (deployed-LoopKit safety + // bound). This overload has no basal schedule (insulin is precomputed), so the + // scheduled basal rate is passed in. nil target/basal ⇒ clamp skipped. + let clampISF = sensitivity.closestPrior(to: start)?.value + let clampRange = target?.closestPrior(to: start)?.value retrospectiveCorrectionEffects = rc.computeEffect( startingAt: latestGlucose, retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, recencyInterval: TimeInterval(minutes: 15), + insulinSensitivity: clampISF, + basalRate: scheduledBasalRate, + correctionRange: clampRange, retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval ) totalRetrospectiveCorrectionEffect = rc.totalGlucoseCorrectionEffect @@ -559,6 +585,9 @@ public struct LoopAlgorithm { basal: input.basal, sensitivity: input.sensitivity, carbRatio: input.carbRatio, + // Note: LoopPredictionInput carries no correction range, so the IntegralRC + // clamp is skipped for pure-prediction use. The deployed dosing path (run(), + // below) passes `input.target`, so its IntegralRC IS clamped. algorithmEffectsOptions: input.algorithmEffectsOptions, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, carbAbsorptionModel: input.carbAbsorptionModel.model, @@ -741,6 +770,9 @@ public struct LoopAlgorithm { basal: input.basal, sensitivity: input.sensitivity, carbRatio: input.carbRatio, + // Correction range for the IntegralRC integral-correction clamp + // (deployed-LoopKit safety bound). + target: input.target, algorithmEffectsOptions: .all, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, includingPositiveVelocityAndRC: input.includePositiveVelocityAndRC, diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift index d850b9d..fad33c3 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift @@ -75,6 +75,13 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { startingAt startingGlucose: GlucoseValue, retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, recencyInterval: TimeInterval, + // Inputs for the integral-correction clamp — the deployed-LoopKit safety bound + // that was dropped in the LoopAlgorithm port. When all three are supplied the + // integral term is clamped exactly as deployed Loop does; when any is nil the + // clamp is skipped (legacy unclamped behavior). + insulinSensitivity: LoopQuantity? = nil, + basalRate: Double? = nil, + correctionRange: ClosedRange? = nil, retrospectiveCorrectionGroupingInterval: TimeInterval ) -> [GlucoseEffect] { @@ -128,6 +135,30 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { IntegralRetrospectiveCorrection.integralGain * discrepancy integralCorrectionEffectMinutes += 2.0 * IntegralRetrospectiveCorrection.delta.minutes } + + // Integral-correction clamp (deployed-LoopKit safety bound, restored). Bounds + // the wound-up integral by an ISF×basal-scaled, target-relative window so it + // can't drive the forecast into over-/under-dosing: + // (+) limit: between 1× and 4× zeroTempEffect, larger the further BG is above + // the correction-range top (more time/room to correct a real high). + // (−) limit: at most ~(glucose − rangeMin) below target (10 mg/dL floor), + // capping over-suspension. + // Applied only when the controller's ISF/basal/correction-range at decision + // time are supplied (any nil ⇒ legacy unclamped behavior). + if let insulinSensitivity, let basalRate, let correctionRange { + let correctionRangeMin = correctionRange.lowerBound.doubleValue(for: unit) + let correctionRangeMax = correctionRange.upperBound.doubleValue(for: unit) + let latestGlucoseValue = startingGlucose.quantity.doubleValue(for: unit) + let glucoseError = latestGlucoseValue - correctionRangeMax + let zeroTempEffect = abs(insulinSensitivity.doubleValue(for: unit) * basalRate) + // Limit for (+) integral effect: between 1× and 4× zeroTempEffect + let integralEffectPositiveLimit = min(max(glucoseError, 1.0 * zeroTempEffect), 4.0 * zeroTempEffect) + // Limit for (−) integral effect: glucose prediction reduced by no more than + // 10 mg/dL below the correction range minimum + let integralEffectNegativeLimit = -max(10.0, latestGlucoseValue - correctionRangeMin) + integralCorrection = min(max(integralCorrection, integralEffectNegativeLimit), integralEffectPositiveLimit) + } + // Limit effect duration integralCorrectionEffectMinutes = min(integralCorrectionEffectMinutes, IntegralRetrospectiveCorrection.maximumCorrectionEffectDuration.minutes) diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift index ae32319..c12b20a 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift @@ -20,12 +20,20 @@ public protocol RetrospectiveCorrection { /// - startingAt: Initial glucose value /// - retrospectiveGlucoseDiscrepanciesSummed: Timeline of past discepancies /// - recencyInterval: how recent discrepancy data must be, otherwise effect will be cleared + /// - insulinSensitivity: Insulin sensitivity at the decision time. Together with + /// `basalRate` and `correctionRange` this bounds the integral-correction effect + /// (the deployed-LoopKit safety clamp). Pass `nil` to skip the clamp. + /// - basalRate: Scheduled basal rate (U/hr) at the decision time. See `insulinSensitivity`. + /// - correctionRange: Correction range at the decision time. See `insulinSensitivity`. /// - retrospectiveCorrectionGroupingInterval: Duration of discrepancy measurements /// - Returns: Glucose correction effects func computeEffect( startingAt startingGlucose: GlucoseValue, retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, recencyInterval: TimeInterval, + insulinSensitivity: LoopQuantity?, + basalRate: Double?, + correctionRange: ClosedRange?, retrospectiveCorrectionGroupingInterval: TimeInterval ) -> [GlucoseEffect] } diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift index 0ee2aff..61abb04 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift @@ -31,6 +31,11 @@ public class StandardRetrospectiveCorrection: RetrospectiveCorrection { startingAt startingGlucose: GlucoseValue, retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, recencyInterval: TimeInterval, + // Standard RC has no integral term to bound, so these are accepted for + // protocol conformance and ignored. + insulinSensitivity: LoopQuantity? = nil, + basalRate: Double? = nil, + correctionRange: ClosedRange? = nil, retrospectiveCorrectionGroupingInterval: TimeInterval ) -> [GlucoseEffect] { // Last discrepancy should be recent, otherwise clear the effect and return diff --git a/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift b/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift index ccd112f..74eb787 100644 --- a/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift +++ b/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift @@ -45,4 +45,114 @@ 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: - Integral-correction clamp (deployed-LoopKit safety bound) + // + // The clamp bounds the wound-up integral term by an ISF×basal-scaled, target-relative + // window (restored from deployed LoopKit; dropped in the LoopAlgorithm port). It is + // active only when insulinSensitivity, basalRate and correctionRange are supplied. + + /// `count` contiguous 5-min discrepancies of `valuePerStep` mg/dL, ending at `endingAt`. + private func windup(endingAt: Date, count: Int, valuePerStep: Double) -> [GlucoseChange] { + (0..