From 783575961afc958bf14893562e287ac3b3a4bd35 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 24 Sep 2026 12:29:45 -0500 Subject: [PATCH] Sample correction range per prediction point in insulinCorrection 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. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XEGCDE2D9wF3zig7uQ9PY2 --- 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) + } }