From b638c53ec32d86290cfc21f6b84a5299c9b38122 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 12 Jul 2026 14:08:44 -0500 Subject: [PATCH] Fix delta-scale IOB ripple for basal segments longer than delta continuousDeliveryInsulinOnBoard quantized its integration bound to the delta grid (floor((time + delay) / delta) * delta), so a whole chunk was added discontinuously each time `time` crossed a delta boundary. This produced a delta-scale ripple in insulinOnBoard for any basal segment longer than one delta -- i.e. essentially every real temp basal / suspend (median ~10 min in real dose histories). Integrate the delivered fraction up to `time`, weighting a partial final chunk, and sample each chunk's remaining-effect at its midpoint. IOB is now continuous and matches a finely-subdivided equivalent delivery. Scoped to the insulinOnBoard path; glucoseEffect (and therefore dosing) is unchanged. Adds a regression test. All package tests pass. --- .../LoopAlgorithm/Insulin/InsulinMath.swift | 34 +++++--- .../InsulinMathBasalSegmentRippleTests.swift | 83 +++++++++++++++++++ 2 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 Tests/LoopAlgorithmTests/InsulinMathBasalSegmentRippleTests.swift diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index b78edf3..f272c20 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -18,21 +18,31 @@ extension BasalRelativeDose { private func continuousDeliveryInsulinOnBoard(at date: Date, delta: TimeInterval) -> Double { let doseDuration = endDate.timeIntervalSince(startDate) // t1 let time = date.timeIntervalSince(startDate) - var iob: Double = 0 - var doseDate = TimeInterval(0) // i - - repeat { - let segment: Double - if doseDuration > 0 { - segment = max(0, min(doseDate + delta, doseDuration) - doseDate) / doseDuration - } else { - segment = 1 - } + guard doseDuration > 0 else { + return insulinModel.percentEffectRemaining(at: time) + } - iob += segment * insulinModel.percentEffectRemaining(at: time - doseDate) + // Integrate the delivered fraction of the dose up to `time`, in `delta` + // steps. Previously the loop's upper bound was quantized to the delta + // grid (`floor((time + delay) / delta) * delta`), so a whole chunk was + // added discontinuously each time `time` crossed a delta boundary — + // producing a delta-scale ripple in IOB for any segment longer than one + // delta (i.e. essentially every real temp basal / suspend). Integrating + // up to `time` and weighting a partial final chunk by delivery-so-far + // makes IOB continuous. Each chunk's remaining-effect is sampled at its + // midpoint (a midpoint Riemann sum), which converges to the continuous + // integral without stepping. + var iob: Double = 0 + let upper = min(time, doseDuration) + var doseDate = TimeInterval(0) // i + while doseDate < upper { + let chunkEnd = min(doseDate + delta, upper) + let segment = (chunkEnd - doseDate) / doseDuration + let mid = (doseDate + chunkEnd) / 2 + iob += segment * insulinModel.percentEffectRemaining(at: time - mid) doseDate += delta - } while doseDate <= min(floor((time + insulinModel.delay) / delta) * delta, doseDuration) + } return iob } diff --git a/Tests/LoopAlgorithmTests/InsulinMathBasalSegmentRippleTests.swift b/Tests/LoopAlgorithmTests/InsulinMathBasalSegmentRippleTests.swift new file mode 100644 index 0000000..14546e1 --- /dev/null +++ b/Tests/LoopAlgorithmTests/InsulinMathBasalSegmentRippleTests.swift @@ -0,0 +1,83 @@ +// +// InsulinMathBasalSegmentRippleTests.swift +// +// Regression test for the delta-scale IOB ripple that `insulinOnBoard` produced +// for basal segments longer than one `delta` (i.e. essentially every real temp +// basal / suspend). `continuousDeliveryInsulinOnBoard` previously quantized its +// integration bound to the delta grid, stepping IOB at each delta boundary. It +// now integrates continuously up to `time`, so a single long segment yields the +// same smooth IOB as the equivalent finely-subdivided delivery. +// + +import XCTest +@testable import LoopAlgorithm + +final class InsulinMathBasalSegmentRippleTests: XCTestCase { + + private let fmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = TimeZone(secondsFromGMT: 0) + return f + }() + + private func curvatureRMS(_ a: [Double]) -> Double { + guard a.count >= 3 else { return 0 } + var d2: [Double] = [] + for i in 2.. Double { + let n = Swift.min(a.count, b.count) + var m = 0.0 + for i in 0.. BasalRelativeDose { + let hrs = b.timeIntervalSince(a) / 3600 + return BasalRelativeDose(type: .basal(scheduledRate: schedRate), + startDate: a, endDate: b, volume: deliveredRate * hrs) + } + let single = [basal(start, start.addingTimeInterval(segMin * 60))] + let fine = (0.. [Double] { + doses.insulinOnBoardTimeline(from: start, to: end).map { $0.value } + } + let iobSingle = iob(single) + let iobFine = iob(fine) + + let maxDiff = maxAbsDiff(iobSingle, iobFine) + let curvSingle = curvatureRMS(iobSingle) + let curvFine = curvatureRMS(iobFine) + print(String(format: "\n[ripple-fix] single-vs-fine maxΔ=%.4f U curvature single=%.5f fine=%.5f\n", + maxDiff, curvSingle, curvFine)) + + // The single long segment is now as smooth as the finely-subdivided + // delivery (no delta-scale ripple). Pre-fix curvature was ~0.18 (>20×). + XCTAssertLessThan(curvSingle, 3 * curvFine + 0.005, + "long-segment IOB should be ~as smooth as the fine subdivision") + // And close to the finely-subdivided equivalent delivery. + XCTAssertLessThan(maxDiff, 0.05, "long-segment IOB should match the fine subdivision") + } +}