Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions Sources/LoopAlgorithm/LoopAlgorithm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ public struct LoopAlgorithm {
basal: [AbsoluteScheduleValue<Double>],
sensitivity: [AbsoluteScheduleValue<LoopQuantity>],
carbRatio: [AbsoluteScheduleValue<Double>],
// 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,
Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -397,6 +409,12 @@ public struct LoopAlgorithm {
carbEntries: [CarbType],
sensitivity: [AbsoluteScheduleValue<LoopQuantity>],
carbRatio: [AbsoluteScheduleValue<Double>],
// 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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoopQuantity>? = nil,
retrospectiveCorrectionGroupingInterval: TimeInterval
) -> [GlucoseEffect] {

Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoopQuantity>?,
retrospectiveCorrectionGroupingInterval: TimeInterval
) -> [GlucoseEffect]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoopQuantity>? = nil,
retrospectiveCorrectionGroupingInterval: TimeInterval
) -> [GlucoseEffect] {
// Last discrepancy should be recent, otherwise clear the effect and return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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..<count).reversed().map { i in
let end = endingAt.addingTimeInterval(.minutes(-Double(i) * 5))
return GlucoseChange(startDate: end.addingTimeInterval(.minutes(-5)),
endDate: end,
quantity: .glucose(valuePerStep))
}
}

func testIntegralCorrectionClampedToPositiveLimit() {
let start = dateFormatter.date(from: "2015-07-13T12:00:00")!
// Glucose 150, above a 100–120 correction range; ISF 50, basal 1 U/hr → zeroTempEffect 50.
// positive limit = min(max(150 − 120, 1×50), 4×50) = 50
let startingGlucose = SimpleGlucoseValue(startDate: start, quantity: .glucose(150))
// Large sustained under-prediction so the integral winds up past the (generous,
// 1×–4× zeroTempEffect) positive limit and the clamp is exercised.
let discrepancies = windup(endingAt: start, count: 18, valuePerStep: 100)
let positiveLimit = 50.0

let unclamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
let unclampedEffect = unclamped.computeEffect(
startingAt: startingGlucose,
retrospectiveGlucoseDiscrepanciesSummed: discrepancies,
recencyInterval: TimeInterval(minutes: 15),
retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval)
XCTAssertGreaterThan(unclamped.integralCorrection, positiveLimit,
"windup must exceed the clamp limit so the clamp is exercised")

let clamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
let clampedEffect = clamped.computeEffect(
startingAt: startingGlucose,
retrospectiveGlucoseDiscrepanciesSummed: discrepancies,
recencyInterval: TimeInterval(minutes: 15),
insulinSensitivity: .glucose(50),
basalRate: 1.0,
correctionRange: .glucose(100)...(.glucose(120)),
retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval)

XCTAssertEqual(clamped.integralCorrection, positiveLimit, accuracy: 0.001)
// Clamping the integral pulls the forecast down relative to unclamped.
XCTAssertLessThan(clampedEffect.last!.quantity.doubleValue(for: .milligramsPerDeciliter),
unclampedEffect.last!.quantity.doubleValue(for: .milligramsPerDeciliter))
}

func testIntegralCorrectionClampedToNegativeLimit() {
let start = dateFormatter.date(from: "2015-07-13T12:00:00")!
// Glucose 90, below a 100–120 correction range.
// negative limit = −max(10, 90 − 100) = −10
let startingGlucose = SimpleGlucoseValue(startDate: start, quantity: .glucose(90))
let discrepancies = windup(endingAt: start, count: 18, valuePerStep: -20)
let negativeLimit = -10.0

let unclamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
_ = unclamped.computeEffect(
startingAt: startingGlucose,
retrospectiveGlucoseDiscrepanciesSummed: discrepancies,
recencyInterval: TimeInterval(minutes: 15),
retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval)
XCTAssertLessThan(unclamped.integralCorrection, negativeLimit,
"negative windup must exceed the clamp limit so the clamp is exercised")

let clamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
_ = clamped.computeEffect(
startingAt: startingGlucose,
retrospectiveGlucoseDiscrepanciesSummed: discrepancies,
recencyInterval: TimeInterval(minutes: 15),
insulinSensitivity: .glucose(50),
basalRate: 1.0,
correctionRange: .glucose(100)...(.glucose(120)),
retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval)

XCTAssertEqual(clamped.integralCorrection, negativeLimit, accuracy: 0.001)
}

func testClampIsInertWhenIntegralWithinLimits() {
let start = dateFormatter.date(from: "2015-07-13T12:00:00")!
let startingGlucose = SimpleGlucoseValue(startDate: start, quantity: .glucose(150))
// Small windup: integral stays well within the ±limits, so the clamp must not alter anything.
let discrepancies = windup(endingAt: start, count: 2, valuePerStep: 4)

let unclamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
let e1 = unclamped.computeEffect(
startingAt: startingGlucose,
retrospectiveGlucoseDiscrepanciesSummed: discrepancies,
recencyInterval: TimeInterval(minutes: 15),
retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval)
XCTAssertLessThan(abs(unclamped.integralCorrection), 50.0)

let clamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
let e2 = clamped.computeEffect(
startingAt: startingGlucose,
retrospectiveGlucoseDiscrepanciesSummed: discrepancies,
recencyInterval: TimeInterval(minutes: 15),
insulinSensitivity: .glucose(50),
basalRate: 1.0,
correctionRange: .glucose(100)...(.glucose(120)),
retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval)

XCTAssertEqual(clamped.integralCorrection, unclamped.integralCorrection, accuracy: 0.001)
XCTAssertEqual(e2.last!.quantity.doubleValue(for: .milligramsPerDeciliter),
e1.last!.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: 0.001)
}
}