From 7ba61e16408e0ac093faf0801c05f159ae8035eb Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 21 Aug 2025 09:44:55 -0500 Subject: [PATCH 01/15] Fix decoding of old AutomaticDoseRecommendation structures without basalAdjustment (#20) --- .../LoopAlgorithm/AutomaticDoseRecommendation.swift | 10 +++++++++- Sources/LoopAlgorithm/Insulin/InsulinMath.swift | 8 ++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift index 68439a4..29965fe 100644 --- a/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift +++ b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift @@ -38,4 +38,12 @@ public struct AutomaticDoseRecommendation: Equatable { } } -extension AutomaticDoseRecommendation: Codable {} +extension AutomaticDoseRecommendation: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Provide default TempBasalRecommendation if basalAdjustment is missing + self.basalAdjustment = try container.decodeIfPresent(TempBasalRecommendation.self, forKey: .basalAdjustment) ?? TempBasalRecommendation(unitsPerHour: 0, duration: 0) + self.bolusUnits = try container.decodeIfPresent(Double.self, forKey: .bolusUnits) + self.direction = try container.decode(Direction.self, forKey: .direction) + } +} diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index 620af78..3e250cf 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -418,8 +418,12 @@ extension Collection where Element == BasalRelativeDose { return value + isfSegments.reduce(0, { partialResult, segment in let start = Swift.max(lastDate, segment.startDate) let end = Swift.min(date, segment.endDate) - let effect = dose.glucoseEffect(during: DateInterval(start: start, end: end), insulinSensitivity: segment.value.doubleValue(for: unit), delta: delta) - return partialResult + effect + if start != end { + let effect = dose.glucoseEffect(during: DateInterval(start: start, end: end), insulinSensitivity: segment.value.doubleValue(for: unit), delta: delta) + return partialResult + effect + } else { + return partialResult + } }) } From 29c7b52756e8b7c39a69475188f34f557094567f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 28 Aug 2025 11:53:27 -0500 Subject: [PATCH 02/15] Carb absorption model selection updates. (#21) * Add ability to specify carbAbsorptionModel in LoopPredictionInput and in encoded files * Make codable rep for CarbAbsorptionModel a string --- Sources/LoopAlgorithm/Carbs/CarbMath.swift | 6 +++--- Sources/LoopAlgorithm/LoopPredictionInput.swift | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/LoopAlgorithm/Carbs/CarbMath.swift b/Sources/LoopAlgorithm/Carbs/CarbMath.swift index fecc42e..53b5779 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbMath.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbMath.swift @@ -15,9 +15,9 @@ public struct CarbMath { public static let defaultEffectDelay: TimeInterval = .minutes(10) } -public enum CarbAbsorptionModel { - case linear - case piecewiseLinear +public enum CarbAbsorptionModel: String, Codable { + case linear = "linear" + case piecewiseLinear = "piecewiseLinear" public var model: CarbAbsorptionComputable { switch self { diff --git a/Sources/LoopAlgorithm/LoopPredictionInput.swift b/Sources/LoopAlgorithm/LoopPredictionInput.swift index 759978c..d94a6ba 100644 --- a/Sources/LoopAlgorithm/LoopPredictionInput.swift +++ b/Sources/LoopAlgorithm/LoopPredictionInput.swift @@ -44,7 +44,8 @@ public struct LoopPredictionInput], algorithmEffectsOptions: AlgorithmEffectsOptions, useIntegralRetrospectiveCorrection: Bool, - includePositiveVelocityAndRC: Bool + includePositiveVelocityAndRC: Bool, + carbAbsorptionModel: CarbAbsorptionModel ) { self.glucoseHistory = glucoseHistory @@ -56,6 +57,7 @@ public struct LoopPredictionInput Date: Fri, 31 Oct 2025 12:03:01 -0500 Subject: [PATCH 03/15] LOOP-5502 Allow setting of max active insulin multiplier (#22) * Allow setting of max active insulin multiplier * Fix formatting for older xcode * Fix formatting for older xcode --- Sources/LoopAlgorithm/AlgorithmInput.swift | 3 +- .../LoopAlgorithm/AlgorithmInputFixture.swift | 6 ++ Sources/LoopAlgorithm/LoopAlgorithm.swift | 25 ++++--- .../CorrectionDosingTests.swift | 66 ++++++++++++------- .../LoopAlgorithmTests.swift | 2 - 5 files changed, 67 insertions(+), 35 deletions(-) diff --git a/Sources/LoopAlgorithm/AlgorithmInput.swift b/Sources/LoopAlgorithm/AlgorithmInput.swift index d6af8aa..fa3a398 100644 --- a/Sources/LoopAlgorithm/AlgorithmInput.swift +++ b/Sources/LoopAlgorithm/AlgorithmInput.swift @@ -24,6 +24,7 @@ public protocol AlgorithmInput { var target: GlucoseRangeTimeline { get } var suspendThreshold: LoopQuantity? { get } var maxBolus: Double { get } + var maxActiveInsulinMultiplier: Double? { get } // Defaults to 2 (2x maxBolus) var maxBasalRate: Double { get } var useIntegralRetrospectiveCorrection: Bool { get } var includePositiveVelocityAndRC: Bool { get } @@ -31,7 +32,7 @@ public protocol AlgorithmInput { var carbAbsorptionModel: CarbAbsorptionModel { get } var recommendationInsulinModel: InsulinModel { get } var recommendationType: DoseRecommendationType { get } - var automaticBolusApplicationFactor: Double? { get } + var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4 } diff --git a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift index 17110eb..cc7f2a2 100644 --- a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift +++ b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift @@ -25,6 +25,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { public var target: GlucoseRangeTimeline public var suspendThreshold: LoopQuantity? public var maxBolus: Double + public var maxActiveInsulinMultiplier: Double? public var maxBasalRate: Double public var useIntegralRetrospectiveCorrection: Bool public var includePositiveVelocityAndRC: Bool @@ -62,6 +63,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { target: GlucoseRangeTimeline, suspendThreshold: LoopQuantity?, maxBolus: Double, + maxActiveInsulinMultiplier: Double? = nil, maxBasalRate: Double, useIntegralRetrospectiveCorrection: Bool = false, useMidAbsorptionISF: Bool = false, @@ -81,6 +83,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { self.target = target self.suspendThreshold = suspendThreshold self.maxBolus = maxBolus + self.maxActiveInsulinMultiplier = maxActiveInsulinMultiplier self.maxBasalRate = maxBasalRate self.useIntegralRetrospectiveCorrection = useIntegralRetrospectiveCorrection self.includePositiveVelocityAndRC = includePositiveVelocityAndRC @@ -116,6 +119,7 @@ extension AlgorithmInputFixture: Codable { self.suspendThreshold = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: suspendThresholdMgdl) } self.maxBolus = try container.decode(Double.self, forKey: .maxBolus) + self.maxActiveInsulinMultiplier = try container.decodeIfPresent(Double.self, forKey: .maxActiveInsulinMultiplier) self.maxBasalRate = try container.decode(Double.self, forKey: .maxBasalRate) self.useIntegralRetrospectiveCorrection = try container.decodeIfPresent(Bool.self, forKey: .useIntegralRetrospectiveCorrection) ?? false self.includePositiveVelocityAndRC = try container.decodeIfPresent(Bool.self, forKey: .includePositiveVelocityAndRC) ?? true @@ -163,6 +167,7 @@ extension AlgorithmInputFixture: Codable { try container.encode(targetMgdl, forKey: .target) try container.encode(suspendThreshold?.doubleValue(for: .milligramsPerDeciliter), forKey: .suspendThreshold) try container.encode(maxBolus, forKey: .maxBolus) + try container.encode(maxActiveInsulinMultiplier, forKey: .maxActiveInsulinMultiplier) try container.encode(maxBasalRate, forKey: .maxBasalRate) if useIntegralRetrospectiveCorrection { try container.encode(useIntegralRetrospectiveCorrection, forKey: .useIntegralRetrospectiveCorrection) @@ -187,6 +192,7 @@ extension AlgorithmInputFixture: Codable { case target case suspendThreshold case maxBolus + case maxActiveInsulinMultiplier case maxBasalRate case useIntegralRetrospectiveCorrection case includePositiveVelocityAndRC diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 0bb3c06..bd49d46 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -374,7 +374,8 @@ public struct LoopAlgorithm { neutralBasalRate: Double, activeInsulin: Double, maxBolus: Double, - maxBasalRate: Double + maxBasalRate: Double, + maxActiveInsulin: Double ) -> TempBasalRecommendation { var maxBasalRate = maxBasalRate @@ -386,12 +387,11 @@ public struct LoopAlgorithm { maxBasalRate = neutralBasalRate } - // Enforce max IOB, calculated from the user entered maxBolus - let automaticDosingIOBLimit = maxBolus * 2.0 - let iobHeadroom = automaticDosingIOBLimit - activeInsulin + // Enforce max active insulin + let activeInsulinHeadroom = maxActiveInsulin - activeInsulin - let maxThirtyMinuteRateToKeepIOBBelowLimit = iobHeadroom * (TimeInterval.hours(1) / tempBasalDuration) + neutralBasalRate // 30 minutes of a U/hr rate - maxBasalRate = Swift.min(maxThirtyMinuteRateToKeepIOBBelowLimit, maxBasalRate) + let maxThirtyMinuteRateToKeepActiveInsulinBelowLimit = activeInsulinHeadroom * (TimeInterval.hours(1) / tempBasalDuration) + neutralBasalRate // 30 minutes of a U/hr rate + maxBasalRate = Swift.min(maxThirtyMinuteRateToKeepActiveInsulinBelowLimit, maxBasalRate) return correction.asTempBasal( neutralBasalRate: neutralBasalRate, @@ -407,11 +407,12 @@ public struct LoopAlgorithm { neutralBasalRate: Double, activeInsulin: Double, maxBolus: Double, - maxBasalRate: Double + maxBasalRate: Double, + maxActiveInsulin: Double ) -> AutomaticDoseRecommendation { - let deliveryHeadroom = max(0, maxBolus * 2.0 - activeInsulin) + let deliveryHeadroom = max(0, maxActiveInsulin - activeInsulin) var deliveryMax = min(maxBolus * applicationFactor, deliveryHeadroom) @@ -555,6 +556,8 @@ public struct LoopAlgorithm { sensitivity: sensitivityForDosing, insulinModel: input.recommendationInsulinModel) + let maxActiveInsulin = input.maxBolus * (input.maxActiveInsulinMultiplier ?? 2) + switch input.recommendationType { case .manualBolus: let recommendation = recommendManualBolus( @@ -570,7 +573,8 @@ public struct LoopAlgorithm { neutralBasalRate: scheduledBasalRate, activeInsulin: prediction.activeInsulin!, maxBolus: input.maxBolus, - maxBasalRate: input.maxBasalRate) + maxBasalRate: input.maxBasalRate, + maxActiveInsulin: maxActiveInsulin) result = .success(.init(automatic: recommendation)) case .tempBasal: let recommendation = recommendTempBasal( @@ -578,7 +582,8 @@ public struct LoopAlgorithm { neutralBasalRate: scheduledBasalRate, activeInsulin: prediction.activeInsulin!, maxBolus: input.maxBolus, - maxBasalRate: input.maxBasalRate) + maxBasalRate: input.maxBasalRate, + maxActiveInsulin: maxActiveInsulin) result = .success(.init(automatic: AutomaticDoseRecommendation(basalAdjustment: recommendation, direction: .from(correction: correction)))) } } catch { diff --git a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift index b630ac3..fa9e696 100644 --- a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift +++ b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift @@ -57,7 +57,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, basalRate) @@ -69,7 +70,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -101,7 +103,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, basalRate) @@ -113,7 +116,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -145,7 +149,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 1) @@ -157,7 +162,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -190,7 +196,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 1) @@ -202,7 +209,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -236,7 +244,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 0) @@ -248,7 +257,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -285,7 +295,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 1.0) @@ -297,7 +308,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -334,7 +346,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 3.0) @@ -346,7 +359,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits!, 0.65, accuracy: 0.05) @@ -380,7 +394,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 1.63, accuracy: 0.05) @@ -392,7 +407,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits!, 0.10, accuracy: 0.05) @@ -425,7 +441,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 1.63, accuracy: 0.05) @@ -437,7 +454,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits!, 0.10, accuracy: 0.05) @@ -470,7 +488,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 3.0, accuracy: 0.05) @@ -482,7 +501,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits!, 0.5, accuracy: 0.05) @@ -515,7 +535,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 0, accuracy: 0.05) @@ -527,7 +548,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits!, 0.0, accuracy: 0.05) diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index f704dac..6d8d5ae 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -203,10 +203,8 @@ final class LoopAlgorithmTests: XCTestCase { // 150 mg/dL is the middle of the target range XCTAssertEqual(output2.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 150, accuracy: 0.1) - } - func testMidAborptionISFFlag() { let now = ISO8601DateFormatter().date(from: "2024-01-03T00:00:00+0000")! var input = AlgorithmInputFixture.mock(for: now) From 8093b57fac6c0d9a1f00b94fd4bb4b122a775975 Mon Sep 17 00:00:00 2001 From: markjudeconnolly <159162013+markjudeconnolly@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:41:30 -0500 Subject: [PATCH 04/15] Mjc/has gradual transitions (#23) * Adds gradual transition validation for retrospective correction as a mitigation for spurious iCGM values * Add gradualTransitionsThreshold to AlgorithmInput and related structures Set RC transition check to only use the last 7 samples * Refactor conditions in momentum to include gradual transitions check * Changed default for gradualTransitionThreshold from 20 -> 40 * fixed naming for gradualTransitionThreshold * Missed a name change on cleanup * Add tests * fix comma for older xcode * Update xcode * Update xcode version * Change resource class * Update simulator --------- Co-authored-by: Pete Schwamb --- .circleci/config.yml | 10 +- Sources/LoopAlgorithm/AlgorithmInput.swift | 3 +- .../LoopAlgorithm/AlgorithmInputFixture.swift | 12 +- .../LoopAlgorithm/Glucose/GlucoseMath.swift | 32 +++- Sources/LoopAlgorithm/LoopAlgorithm.swift | 37 +++- .../LoopAlgorithm/LoopPredictionInput.swift | 9 +- .../LoopAlgorithmTests/GlucoseMathTests.swift | 173 ++++++++++++++++++ .../LoopAlgorithmTests.swift | 42 +++++ 8 files changed, 297 insertions(+), 21 deletions(-) create mode 100644 Tests/LoopAlgorithmTests/GlucoseMathTests.swift diff --git a/.circleci/config.yml b/.circleci/config.yml index f989715..7caff5f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,24 +7,26 @@ version: 2.1 jobs: test: macos: - xcode: 16.0.0 + xcode: 26.1.0 + resource_class: m4pro.medium steps: - checkout - run: name: Test command: | - set -o pipefail && xcodebuild -scheme LoopAlgorithm test -destination "platform=iOS Simulator,name=iPhone 16,OS=latest" | xcpretty + set -o pipefail && xcodebuild -scheme LoopAlgorithm test -destination "platform=iOS Simulator,name=iPhone 17,OS=latest" | xcpretty - store_test_results: path: test_output package: macos: - xcode: 16.0.0 + xcode: 26.1.0 + resource_class: m4pro.medium steps: - checkout - run: name: Build LoopAlgorithmPackage command: | - set -o pipefail && xcodebuild build -scheme LoopAlgorithm -destination "platform=iOS Simulator,name=iPhone 16,OS=latest" | xcpretty + set -o pipefail && xcodebuild build -scheme LoopAlgorithm -destination "platform=iOS Simulator,name=iPhone 17,OS=latest" | xcpretty # # Workflows # diff --git a/Sources/LoopAlgorithm/AlgorithmInput.swift b/Sources/LoopAlgorithm/AlgorithmInput.swift index fa3a398..e2139ab 100644 --- a/Sources/LoopAlgorithm/AlgorithmInput.swift +++ b/Sources/LoopAlgorithm/AlgorithmInput.swift @@ -33,6 +33,5 @@ public protocol AlgorithmInput { var recommendationInsulinModel: InsulinModel { get } var recommendationType: DoseRecommendationType { get } var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4 + var gradualTransitionsThreshold: Double? { get } } - - diff --git a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift index cc7f2a2..4355778 100644 --- a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift +++ b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift @@ -34,6 +34,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { public var recommendationInsulinType: FixtureInsulinType = .novolog public var recommendationType: DoseRecommendationType = .automaticBolus public var automaticBolusApplicationFactor: Double? + public var gradualTransitionsThreshold: Double? public var recommendationInsulinModel: InsulinModel { recommendationInsulinType.insulinModel @@ -71,7 +72,8 @@ public struct AlgorithmInputFixture: AlgorithmInput { carbAbsorptionModel: CarbAbsorptionModel = .piecewiseLinear, recommendationInsulinType: FixtureInsulinType, recommendationType: DoseRecommendationType, - automaticBolusApplicationFactor: Double? = nil + automaticBolusApplicationFactor: Double? = nil, + gradualTransitionsThreshold: Double? = 40.0 ) { self.predictionStart = predictionStart self.glucoseHistory = glucoseHistory @@ -92,6 +94,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { self.recommendationInsulinType = recommendationInsulinType self.recommendationType = recommendationType self.automaticBolusApplicationFactor = automaticBolusApplicationFactor + self.gradualTransitionsThreshold = gradualTransitionsThreshold } } @@ -144,6 +147,7 @@ extension AlgorithmInputFixture: Codable { } self.automaticBolusApplicationFactor = try container.decodeIfPresent(Double.self, forKey: .automaticBolusApplicationFactor) + self.gradualTransitionsThreshold = try container.decodeIfPresent(Double.self, forKey: .gradualTransitionsThreshold) ?? 40.0 } @@ -179,6 +183,7 @@ extension AlgorithmInputFixture: Codable { try container.encode(recommendationInsulinType.rawValue, forKey: .recommendationInsulinType) try container.encode(recommendationType.rawValue, forKey: .recommendationType) try container.encode(automaticBolusApplicationFactor, forKey: .automaticBolusApplicationFactor) + try container.encode(gradualTransitionsThreshold, forKey: .gradualTransitionsThreshold) } private enum CodingKeys: String, CodingKey { @@ -200,6 +205,7 @@ extension AlgorithmInputFixture: Codable { case recommendationInsulinType case recommendationType case automaticBolusApplicationFactor + case gradualTransitionsThreshold } } @@ -223,7 +229,8 @@ extension AlgorithmInputFixture { carbAbsorptionModel: input.carbAbsorptionModel, recommendationInsulinType: .novolog, recommendationType: input.recommendationType, - automaticBolusApplicationFactor: input.automaticBolusApplicationFactor + automaticBolusApplicationFactor: input.automaticBolusApplicationFactor, + gradualTransitionsThreshold: input.gradualTransitionsThreshold ) let encoder = JSONEncoder() @@ -261,4 +268,3 @@ extension CarbEntry { ) } } - diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift index a69d2a6..9987980 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift @@ -61,7 +61,7 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In /// - Parameters: /// - interval: The interval between readings, on average, used to determine if we have a contiguous set of values /// - Returns: True if the samples are continuous - public func isContinuous(within interval: TimeInterval = TimeInterval(5 * 60)) -> Bool { + func isContinuous(within interval: TimeInterval = TimeInterval(minutes: 5)) -> Bool { if let first = first, let last = last, // Ensure that the entries are contiguous @@ -73,6 +73,34 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In return false } + /// Whether the collection has gradual transitions (no large glucose jumps between consecutive readings) + /// + /// - Parameters: + /// - gradualTransitionThreshold: Maximum allowed difference between consecutive readings in mg/dL (default 40.0) + /// - Returns: True if all consecutive differences are within the threshold + public func hasGradualTransitions(gradualTransitionThreshold: Double = 40.0) -> Bool { + guard count > 1 else { + return false // A single point could be a spike and should not be used for momentum calculation + } + + // Check glucose value continuity (no large transitions) + let unit = LoopUnit.milligramsPerDeciliter + for i in 0..<(count - 1) { + let current = self[self.index(self.startIndex, offsetBy: i)] + let next = self[self.index(self.startIndex, offsetBy: i + 1)] + + let currentValue = current.quantity.doubleValue(for: unit) + let nextValue = next.quantity.doubleValue(for: unit) + let difference = abs(nextValue - currentValue) + + if difference > gradualTransitionThreshold { + return false + } + } + + return true + } + /// Calculates the short-term predicted momentum effect using linear regression /// /// - Parameters: @@ -90,7 +118,7 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In guard self.count > 2, // Linear regression isn't much use without 3 or more entries. - isContinuous() && !containsCalibrations() && hasSingleProvenance, + hasGradualTransitions() && isContinuous() && !containsCalibrations() && hasSingleProvenance, let firstSample = self.first, let lastSample = self.last, let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([lastSample], duration: duration, delta: delta) diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index bd49d46..2e711a1 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -178,7 +178,8 @@ public struct LoopAlgorithm { useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, useMidAbsorptionISF: Bool = false, - carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() + carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), + gradualTransitionsThreshold: Double? = 40.0 ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { var prediction: [PredictedGlucoseValue] = [] @@ -259,8 +260,6 @@ public struct LoopAlgorithm { rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) } - - if let latestGlucose = glucoseHistory.last { retrospectiveCorrectionEffects = rc.computeEffect( startingAt: latestGlucose, @@ -282,9 +281,28 @@ public struct LoopAlgorithm { } if algorithmEffectsOptions.contains(.retrospection) { - if !includingPositiveVelocityAndRC, let netRC = retrospectiveCorrectionEffects.netEffect(), netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { - // positive RC is turned off - } else { + // Check if glucose data is smooth enough for RC + // Use the same input window as retrospective correction + var useRC: Bool = true + + // Don't apply RC if glucose has large jumps + let rcTransitionData = glucoseHistory.filterDateRange( + start.addingTimeInterval(-LoopMath.retrospectiveCorrectionGroupingInterval), + start + ) + + if !rcTransitionData.hasGradualTransitions(gradualTransitionThreshold: gradualTransitionsThreshold ?? 40.0) { + useRC = false + } + + // Don't apply positive RC if that setting is disabled + if !includingPositiveVelocityAndRC, + let netRC = retrospectiveCorrectionEffects.netEffect(), + netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + useRC = false + } + + if useRC { effects.append(retrospectiveCorrectionEffects) } } @@ -347,7 +365,8 @@ public struct LoopAlgorithm { carbRatio: input.carbRatio, algorithmEffectsOptions: input.algorithmEffectsOptions, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, - carbAbsorptionModel: input.carbAbsorptionModel.model + carbAbsorptionModel: input.carbAbsorptionModel.model, + gradualTransitionsThreshold: input.gradualTransitionsThreshold ) } @@ -530,7 +549,8 @@ public struct LoopAlgorithm { useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, includingPositiveVelocityAndRC: input.includePositiveVelocityAndRC, useMidAbsorptionISF: input.useMidAbsorptionISF, - carbAbsorptionModel: input.carbAbsorptionModel.model + carbAbsorptionModel: input.carbAbsorptionModel.model, + gradualTransitionsThreshold: input.gradualTransitionsThreshold ) let sensitivityForDosing: [AbsoluteScheduleValue] @@ -600,4 +620,3 @@ public struct LoopAlgorithm { ) } } - diff --git a/Sources/LoopAlgorithm/LoopPredictionInput.swift b/Sources/LoopAlgorithm/LoopPredictionInput.swift index d94a6ba..491dd4c 100644 --- a/Sources/LoopAlgorithm/LoopPredictionInput.swift +++ b/Sources/LoopAlgorithm/LoopPredictionInput.swift @@ -34,6 +34,8 @@ public struct LoopPredictionInput MockGlucoseSample { + MockGlucoseSample( + startDate: date, + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdL), + provenanceIdentifier: provenance, + isDisplayOnly: displayOnly, + wasUserEntered: false, + condition: nil, + trendRate: nil + ) + } + + func testHasGradualTransitions_SingleSample_ReturnsFalse() { + let now = Date() + let samples: [MockGlucoseSample] = [sample(at: now, glucose: 120)] + + XCTAssertFalse(samples.hasGradualTransitions(), + "A single sample should be considered a possible spike -> false") + } + + func testHasGradualTransitions_TwoSamplesWithinThreshold_ReturnsTrue() { + let base = Date() + let s1 = sample(at: base, glucose: 100) + let s2 = sample(at: base.addingTimeInterval(.minutes(5)), glucose: 110) // +10 mg/dL + + let samples = [s1, s2] + XCTAssertTrue(samples.hasGradualTransitions(), + "10 mg/dL change is less than or equal to default 40 mg/dL -> true") + } + + func testHasGradualTransitions_TwoSamplesExceedingThreshold_ReturnsFalse() { + let base = Date() + let s1 = sample(at: base, glucose: 100) + let s2 = sample(at: base.addingTimeInterval(.minutes(5)), glucose: 150) // +50 mg/dL + + let samples = [s1, s2] + XCTAssertFalse(samples.hasGradualTransitions(), + "50 mg/dL change exceeds default 40 mg/dL -> false") + } + + func testHasGradualTransitions_MultipleSamplesAllWithinThreshold_ReturnsTrue() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 115), + sample(at: base + .minutes(10), glucose: 125), + sample(at: base + .minutes(15), glucose: 118) + ] // max delta = 15 mg/dL + + XCTAssertTrue(samples.hasGradualTransitions(), + "All consecutive changes less than or equal to 40 mg/dL -> true") + } + + func testHasGradualTransitions_OneJumpExceedsThreshold_ReturnsFalse() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 115), + sample(at: base + .minutes(10), glucose: 160) // +45 mg/dL jump + ] + + XCTAssertFalse(samples.hasGradualTransitions(), + "A single jump of 45 mg/dL exceeds 40 mg/dL -> false") + } + + func testHasGradualTransitions_CustomThreshold() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 150) // +50 mg/dL + ] + + // 50 mg/dL is greater than 40, but less than or equal to 55 -> should pass with 55 + XCTAssertTrue(samples.hasGradualTransitions(gradualTransitionThreshold: 55), + "Custom threshold of 55 mg/dL allows a 50 mg/dL change") + XCTAssertFalse(samples.hasGradualTransitions(gradualTransitionThreshold: 45), + "Custom threshold of 45 mg/dL rejects a 50 mg/dL change") + } + + // MARK: - Supporting checks used by other GlucoseMath methods + + func testIsContinuous_EmptyCollection_ReturnsFalse() { + let samples: [MockGlucoseSample] = [] + XCTAssertFalse(samples.isContinuous(), + "Empty collection is not continuous") + } + + func testIsContinuous_Regular5MinSpacing_ReturnsTrue() { + let base = Date() + let samples: [MockGlucoseSample] = (0..<6).map { + sample(at: base + .minutes(Double($0 * 5)), glucose: 100 + Double($0)) + } + + XCTAssertTrue(samples.isContinuous(within: .minutes(5.5)), + "Samples every 5 min are within a 5.5 min tolerance -> true") + } + + func testIsContinuous_GapLargerThanTolerance_ReturnsFalse() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 105), + sample(at: base + .minutes(20), glucose: 110) // 15 min gap + ] + + XCTAssertFalse(samples.isContinuous(within: .minutes(6)), + "A 15 min gap exceeds a 6 min tolerance -> false") + } + + func testContainsCalibrations_NoCalibrations_ReturnsFalse() { + let samples = (0..<3).map { sample(at: Date() + .minutes(Double($0*5)), glucose: 100) } + XCTAssertFalse(samples.containsCalibrations(), + "No display-only samples -> false") + } + + func testContainsCalibrations_HasCalibration_ReturnsTrue() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 105, displayOnly: true) // calibration + ] + + XCTAssertTrue(samples.containsCalibrations(), + "One display-only sample -> true") + } + + func testHasSingleProvenance_AllSame_ReturnsTrue() { + let samples = (0..<4).map { sample(at: Date() + .minutes(Double($0*5)), glucose: 100, provenance: "CGM") } + XCTAssertTrue(samples.hasSingleProvenance, + "All samples share the same provenance -> true") + } + + func testHasSingleProvenance_DifferentProvenance_ReturnsFalse() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100, provenance: "CGM"), + sample(at: base + .minutes(5), glucose: 105, provenance: "Manual") + ] + + XCTAssertFalse(samples.hasSingleProvenance, + "Different provenance identifiers -> false") + } +} diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 6d8d5ae..e8d1f1f 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -11,6 +11,20 @@ import XCTest final class LoopAlgorithmTests: XCTestCase { + private func sample(at date: Date, + glucose mgdL: Double, + provenance: String = "test", + displayOnly: Bool = false) -> FixtureGlucoseSample { + FixtureGlucoseSample( + provenanceIdentifier: provenance, + startDate: date, + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdL), + isDisplayOnly: displayOnly, + wasUserEntered: false + ) + } + + func loadScenario(_ name: String) -> (input: AlgorithmInputFixture, recommendation: LoopAlgorithmDoseRecommendation) { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -176,6 +190,34 @@ final class LoopAlgorithmTests: XCTestCase { } } + func testSpuriousReadingDisablesRCAndMomentum() { + + let base = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + + var input = AlgorithmInputFixture.mock(for: base.addingTimeInterval(.minutes(30))) + + input.glucoseHistory = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 115), + sample(at: base + .minutes(10), glucose: 125), + sample(at: base + .minutes(15), glucose: 179), // +45 jump + sample(at: base + .minutes(20), glucose: 148), + sample(at: base + .minutes(25), glucose: 158), + sample(at: base + .minutes(30), glucose: 168) + ] + + // With a spurious reading over the gradualTransitionsThreshold of 40 mg/dL, momentum and rc are turned off, and the forecast is lower. + var output = LoopAlgorithm.run(input: input) + XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 164.5, accuracy: 0.1) + + // With the threshold set high, our spurious reading is still considered gradual, and RC and momentum will be used, resulting in a higher forecast. + input.gradualTransitionsThreshold = 60 + output = LoopAlgorithm.run(input: input) + XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 216, accuracy: 0.1) + + } + + func testMealBolusScenario() { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 From 13cb4b45258cee5be1eb2ad941b374dde53de551 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 18 Nov 2025 16:28:29 -0600 Subject: [PATCH 05/15] Move glucosemath tests from LoopKit to LoopAlgorithm (#24) --- Tests/LoopAlgorithmTests/CarbMathTests.swift | 5 - .../Extensions/DateFormatter.swift | 8 +- ...lling_glucose_almost_duplicates_input.json | 34 +++ ...ling_glucose_almost_duplicates_output.json | 20 ++ ...eraction_effect_falling_glucose_input.json | 18 ++ ...action_effect_falling_glucose_insulin.json | 22 ++ ...raction_effect_falling_glucose_output.json | 20 ++ ...omentum_effect_bouncing_glucose_input.json | 14 + ...mentum_effect_bouncing_glucose_output.json | 42 +++ ...tum_effect_display_only_glucose_input.json | 17 ++ ...mentum_effect_duplicate_glucose_input.json | 14 + ...momentum_effect_falling_glucose_input.json | 18 ++ ...omentum_effect_falling_glucose_output.json | 37 +++ ...entum_effect_incomplete_glucose_input.json | 14 + ...effect_mixed_provenance_glucose_input.json | 18 ++ ...t_rising_glucose_double_entries_input.json | 30 ++ .../momentum_effect_rising_glucose_input.json | 18 ++ ...momentum_effect_rising_glucose_output.json | 37 +++ .../momentum_effect_stable_glucose_input.json | 14 + ...momentum_effect_stable_glucose_output.json | 37 +++ .../LoopAlgorithmTests/GlucoseMathTests.swift | 268 +++++++++++++++++- .../LoopAlgorithmTests/InsulinMathTests.swift | 6 - 22 files changed, 696 insertions(+), 15 deletions(-) create mode 100644 Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_almost_duplicates_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_almost_duplicates_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_insulin.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_bouncing_glucose_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_bouncing_glucose_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_display_only_glucose_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_duplicate_glucose_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_falling_glucose_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_falling_glucose_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_incomplete_glucose_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_mixed_provenance_glucose_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_double_entries_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_stable_glucose_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/momentum_effect_stable_glucose_output.json diff --git a/Tests/LoopAlgorithmTests/CarbMathTests.swift b/Tests/LoopAlgorithmTests/CarbMathTests.swift index ece635f..6cc4670 100644 --- a/Tests/LoopAlgorithmTests/CarbMathTests.swift +++ b/Tests/LoopAlgorithmTests/CarbMathTests.swift @@ -13,11 +13,6 @@ public typealias JSONDictionary = [String: Any] class CarbMathTests: XCTestCase { - public func loadFixture(_ resourceName: String) -> T { - let url = Bundle.module.url(forResource: resourceName, withExtension: "json", subdirectory: "Fixtures")! - return try! JSONSerialization.jsonObject(with: Data(contentsOf: url), options: []) as! T - } - private func loadEffectOutputFixture(_ name: String) -> [GlucoseEffect] { let fixture: [JSONDictionary] = loadFixture(name) let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: TimeZone(secondsFromGMT: 0)!) diff --git a/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift b/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift index 449703f..2abfbd7 100644 --- a/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift +++ b/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift @@ -10,8 +10,14 @@ import Foundation // MARK: - Extensions useful in parsing fixture dates +extension TimeZone { + static var currentFixed: TimeZone { + return TimeZone(secondsFromGMT: TimeZone.current.secondsFromGMT())! + } +} + extension ISO8601DateFormatter { - static func localTimeDate(timeZone: TimeZone) -> Self { + static func localTimeDate(timeZone: TimeZone = .currentFixed) -> Self { let formatter = self.init() formatter.formatOptions = .withInternetDateTime diff --git a/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_almost_duplicates_input.json b/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_almost_duplicates_input.json new file mode 100644 index 0000000..49392a0 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_almost_duplicates_input.json @@ -0,0 +1,34 @@ +[ + { + "date": "2015-10-25T19:15:00", + "amount": 159 + }, + { + "date": "2015-10-25T19:15:01", + "amount": 159 + }, + { + "date": "2015-10-25T19:19:59", + "amount": 136 + }, + { + "date": "2015-10-25T19:20:00", + "amount": 136 + }, + { + "date": "2015-10-25T19:25:00", + "amount": 123 + }, + { + "date": "2015-10-25T19:25:01", + "amount": 123 + }, + { + "date": "2015-10-25T19:30:00", + "amount": 120 + }, + { + "date": "2015-10-25T19:30:01", + "amount": 120 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_almost_duplicates_output.json b/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_almost_duplicates_output.json new file mode 100644 index 0000000..adfbec4 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_almost_duplicates_output.json @@ -0,0 +1,20 @@ +[ + { + "value" : -4.61538461538461, + "startDate" : "2015-10-25T19:15:00", + "unit" : "mg\/min·dL", + "endDate" : "2015-10-25T19:19:59" + }, + { + "value" : -2.59136212624585, + "startDate" : "2015-10-25T19:19:59", + "unit" : "mg\/min·dL", + "endDate" : "2015-10-25T19:25:00" + }, + { + "value" : -0.59999999999999998, + "startDate" : "2015-10-25T19:25:00", + "unit" : "mg\/min·dL", + "endDate" : "2015-10-25T19:30:00" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_input.json new file mode 100644 index 0000000..7efafb8 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_input.json @@ -0,0 +1,18 @@ +[ + { + "date": "2015-10-25T19:15:00", + "amount": 159 + }, + { + "date": "2015-10-25T19:20:00", + "amount": 136 + }, + { + "date": "2015-10-25T19:25:00", + "amount": 123 + }, + { + "date": "2015-10-25T19:30:00", + "amount": 120 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_insulin.json b/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_insulin.json new file mode 100644 index 0000000..088b682 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_insulin.json @@ -0,0 +1,22 @@ +[ + { + "date": "2015-10-25T19:15:00", + "amount": 100, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:20:00", + "amount": 100, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:25:00", + "amount": 100, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:30:00", + "amount": 100, + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_output.json b/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_output.json new file mode 100644 index 0000000..a8ce519 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/counteraction_effect_falling_glucose_output.json @@ -0,0 +1,20 @@ +[ + { + "value" : -4.5999999999999988, + "startDate" : "2015-10-25T19:15:00", + "unit" : "mg\/min·dL", + "endDate" : "2015-10-25T19:20:00" + }, + { + "value" : -2.5999999999999996, + "startDate" : "2015-10-25T19:20:00", + "unit" : "mg\/min·dL", + "endDate" : "2015-10-25T19:25:00" + }, + { + "value" : -0.59999999999999998, + "startDate" : "2015-10-25T19:25:00", + "unit" : "mg\/min·dL", + "endDate" : "2015-10-25T19:30:00" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_bouncing_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_bouncing_glucose_input.json new file mode 100644 index 0000000..f95ecf1 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_bouncing_glucose_input.json @@ -0,0 +1,14 @@ +[ + { + "date": "2015-10-25T19:19:37", + "amount": 123 + }, + { + "date": "2015-10-25T19:24:36", + "amount": 120 + }, + { + "date": "2015-10-25T19:29:37", + "amount": 129 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_bouncing_glucose_output.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_bouncing_glucose_output.json new file mode 100644 index 0000000..8214796 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_bouncing_glucose_output.json @@ -0,0 +1,42 @@ +[ + { + "date": "2015-10-25T19:25:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:30:00", + "amount": 0.23051025736941719, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:35:00", + "amount": 3.2371657882748588, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:40:00", + "amount": 6.2438213191803005, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:45:00", + "amount": 9.2504768500857413, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:50:00", + "amount": 12.257132380991184, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:55:00", + "amount": 15.263787911896625, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T20:00:00", + "amount": 18.270443442802062, + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_display_only_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_display_only_glucose_input.json new file mode 100644 index 0000000..6e6d976 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_display_only_glucose_input.json @@ -0,0 +1,17 @@ +[ + { + "date": "2015-10-25T19:19:37", + "amount": 123, + "display_only": false, + }, + { + "date": "2015-10-25T19:24:36", + "amount": 120, + "display_only": true, + }, + { + "date": "2015-10-25T19:29:37", + "amount": 129, + "display_only": false, + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_duplicate_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_duplicate_glucose_input.json new file mode 100644 index 0000000..2cd6db6 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_duplicate_glucose_input.json @@ -0,0 +1,14 @@ +[ + { + "date": "2015-10-25T19:25:00", + "amount": 125 + }, + { + "date": "2015-10-25T19:25:00", + "amount": 125 + }, + { + "date": "2015-10-25T19:25:00", + "amount": 125 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_falling_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_falling_glucose_input.json new file mode 100644 index 0000000..dad3a6b --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_falling_glucose_input.json @@ -0,0 +1,18 @@ +[ + { + "date": "2015-10-25T19:15:00", + "amount": 129 + }, + { + "date": "2015-10-25T19:20:00", + "amount": 126 + }, + { + "date": "2015-10-25T19:25:00", + "amount": 123 + }, + { + "date": "2015-10-25T19:30:00", + "amount": 120 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_falling_glucose_output.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_falling_glucose_output.json new file mode 100644 index 0000000..ad766c6 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_falling_glucose_output.json @@ -0,0 +1,37 @@ +[ + { + "date": "2015-10-25T19:30:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:35:00", + "amount": -3, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:40:00", + "amount": -6, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:45:00", + "amount": -9, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:50:00", + "amount": -12, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:55:00", + "amount": -15, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T20:00:00", + "amount": -18, + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_incomplete_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_incomplete_glucose_input.json new file mode 100644 index 0000000..6e8c0e3 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_incomplete_glucose_input.json @@ -0,0 +1,14 @@ +[ + { + "date": "2015-10-25T19:14:37", + "amount": 123 + }, + { + "date": "2015-10-25T19:24:36", + "amount": 120 + }, + { + "date": "2015-10-25T19:29:37", + "amount": 129 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_mixed_provenance_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_mixed_provenance_glucose_input.json new file mode 100644 index 0000000..9446f1f --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_mixed_provenance_glucose_input.json @@ -0,0 +1,18 @@ +[ + { + "date": "2015-10-25T19:19:37", + "amount": 123, + "display_only": false, + }, + { + "date": "2015-10-25T19:24:36", + "amount": 120, + "display_only": false, + "provenance_identifier": "com.developer.BLEGlucoseMeter" + }, + { + "date": "2015-10-25T19:29:37", + "amount": 129, + "display_only": false, + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_double_entries_input.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_double_entries_input.json new file mode 100644 index 0000000..63bbc3d --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_double_entries_input.json @@ -0,0 +1,30 @@ +[ + { + "date": "2015-10-25T19:15:00", + "amount": 120 + }, + { + "date": "2015-10-25T19:17:30", + "amount": 121.5 + }, + { + "date": "2015-10-25T19:20:00", + "amount": 123 + }, + { + "date": "2015-10-25T19:22:30", + "amount": 124.5 + }, + { + "date": "2015-10-25T19:25:00", + "amount": 126 + }, + { + "date": "2015-10-25T19:27:30", + "amount": 127.5 + }, + { + "date": "2015-10-25T19:30:00", + "amount": 129 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_input.json new file mode 100644 index 0000000..20a6fb7 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_input.json @@ -0,0 +1,18 @@ +[ + { + "date": "2015-10-25T19:15:00", + "amount": 120 + }, + { + "date": "2015-10-25T19:20:00", + "amount": 123 + }, + { + "date": "2015-10-25T19:25:00", + "amount": 126 + }, + { + "date": "2015-10-25T19:30:00", + "amount": 129 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_output.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_output.json new file mode 100644 index 0000000..304c657 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_rising_glucose_output.json @@ -0,0 +1,37 @@ +[ + { + "date": "2015-10-25T19:30:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:35:00", + "amount": 3, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:40:00", + "amount": 6, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:45:00", + "amount": 9, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:50:00", + "amount": 12, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:55:00", + "amount": 15, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T20:00:00", + "amount": 18, + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_stable_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_stable_glucose_input.json new file mode 100644 index 0000000..dc6441e --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_stable_glucose_input.json @@ -0,0 +1,14 @@ +[ + { + "date": "2015-10-25T19:20:00", + "amount": 120 + }, + { + "date": "2015-10-25T19:25:00", + "amount": 120 + }, + { + "date": "2015-10-25T19:30:00", + "amount": 120 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_stable_glucose_output.json b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_stable_glucose_output.json new file mode 100644 index 0000000..f7dd49f --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/momentum_effect_stable_glucose_output.json @@ -0,0 +1,37 @@ +[ + { + "date": "2015-10-25T19:30:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:35:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:40:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:45:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:50:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:55:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T20:00:00", + "amount": 0.0, + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/GlucoseMathTests.swift b/Tests/LoopAlgorithmTests/GlucoseMathTests.swift index 0473dbc..ee346ba 100644 --- a/Tests/LoopAlgorithmTests/GlucoseMathTests.swift +++ b/Tests/LoopAlgorithmTests/GlucoseMathTests.swift @@ -8,6 +8,40 @@ import XCTest @testable import LoopAlgorithm +extension XCTestCase { + public func loadFixture(_ resourceName: String) -> T { + let url = Bundle.module.url(forResource: resourceName, withExtension: "json", subdirectory: "Fixtures")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: url), options: []) as! T + } +} + +public struct GlucoseFixtureValue: GlucoseSampleValue { + public let startDate: Date + public let quantity: LoopQuantity + public let isDisplayOnly: Bool + public let wasUserEntered: Bool + public let provenanceIdentifier: String + public let condition: GlucoseCondition? + public let trendRate: LoopQuantity? + public var syncIdentifier: String? + + public init(startDate: Date, quantity: LoopQuantity, isDisplayOnly: Bool, wasUserEntered: Bool, provenanceIdentifier: String?, condition: GlucoseCondition?, trendRate: LoopQuantity?) { + self.startDate = startDate + self.quantity = quantity + self.isDisplayOnly = isDisplayOnly + self.wasUserEntered = wasUserEntered + self.provenanceIdentifier = provenanceIdentifier ?? "com.loopkit.LoopKitTests" + self.condition = condition + self.trendRate = trendRate + } +} + +extension GlucoseFixtureValue: Comparable { + public static func <(lhs: GlucoseFixtureValue, rhs: GlucoseFixtureValue) -> Bool { + return lhs.startDate < rhs.startDate + } +} + final class GlucoseMathTests: XCTestCase { // MARK: - Helper to create a mock GlucoseSampleValue @@ -20,9 +54,6 @@ final class GlucoseMathTests: XCTestCase { var wasUserEntered: Bool var condition: GlucoseCondition? var trendRate: LoopQuantity? - - // GlucoseValue conformance - var endDate: Date { startDate } } private func sample(at date: Date, @@ -170,4 +201,235 @@ final class GlucoseMathTests: XCTestCase { XCTAssertFalse(samples.hasSingleProvenance, "Different provenance identifiers -> false") } + + func loadInputFixture(_ resourceName: String) -> [GlucoseFixtureValue] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseFixtureValue( + startDate: dateFormatter.date(from: $0["date"] as! String)!, + quantity: LoopQuantity(unit: LoopUnit.milligramsPerDeciliter, doubleValue: $0["amount"] as! Double), + isDisplayOnly: ($0["display_only"] as? Bool) ?? false, + wasUserEntered: ($0["user_entered"] as? Bool) ?? false, + provenanceIdentifier: $0["provenance_identifier"] as? String, + condition: ($0["condition"] as? String).flatMap { GlucoseCondition(rawValue: $0) }, + trendRate: ($0["trend_rate"] as? Double).flatMap { LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0) } + ) + } + } + + func loadOutputFixture(_ resourceName: String) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue: $0["amount"] as! Double)) + } + } + + func loadEffectVelocityFixture(_ resourceName: String) -> [GlucoseEffectVelocity] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseEffectVelocity(startDate: dateFormatter.date(from: $0["startDate"] as! String)!, endDate: dateFormatter.date(from: $0["endDate"] as! String)!, quantity: LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue:$0["value"] as! Double)) + } + } + + func testMomentumEffectForBouncingGlucose() { + let input = loadInputFixture("momentum_effect_bouncing_glucose_input") + let output = loadOutputFixture("momentum_effect_bouncing_glucose_output") + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForRisingGlucose() { + let input = loadInputFixture("momentum_effect_rising_glucose_input") + let output = loadOutputFixture("momentum_effect_rising_glucose_output") + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForRisingGlucoseDoubles() { + let input = loadInputFixture("momentum_effect_rising_glucose_double_entries_input") + let output = loadOutputFixture("momentum_effect_rising_glucose_output") + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForFallingGlucose() { + let input = loadInputFixture("momentum_effect_falling_glucose_input") + let output = loadOutputFixture("momentum_effect_falling_glucose_output") + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForFallingGlucoseDuplicates() { + var input = loadInputFixture("momentum_effect_falling_glucose_input") + let output = loadOutputFixture("momentum_effect_falling_glucose_output") + input.append(contentsOf: input) + input.sort(by: <) + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForStableGlucose() { + let input = loadInputFixture("momentum_effect_stable_glucose_input") + let output = loadOutputFixture("momentum_effect_stable_glucose_output") + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForDuplicateGlucose() { + let input = loadInputFixture("momentum_effect_duplicate_glucose_input") + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testMomentumEffectForEmptyGlucose() { + let input = [GlucoseFixtureValue]() + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testMomentumEffectForSpacedOutGlucose() { + let input = loadInputFixture("momentum_effect_incomplete_glucose_input") + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testMomentumEffectForTooFewGlucose() { + let input = loadInputFixture("momentum_effect_bouncing_glucose_input")[0...1] + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testMomentumEffectForDisplayOnlyGlucose() { + let input = loadInputFixture("momentum_effect_display_only_glucose_input") + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testMomentumEffectForMixedProvenanceGlucose() { + let input = loadInputFixture("momentum_effect_mixed_provenance_glucose_input") + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testCounteractionEffectsForFallingGlucose() { + let input = loadInputFixture("counteraction_effect_falling_glucose_input") + let insulinEffect = loadOutputFixture("counteraction_effect_falling_glucose_insulin") + let output = loadEffectVelocityFixture("counteraction_effect_falling_glucose_output") + + let effects = input.counteractionEffects(to: insulinEffect) + let unit = LoopUnit.milligramsPerDeciliterPerMinute + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testCounteractionEffectsForFallingGlucoseDuplicates() { + var input = loadInputFixture("counteraction_effect_falling_glucose_input") + input.append(contentsOf: input) + input.sort(by: <) + let insulinEffect = loadOutputFixture("counteraction_effect_falling_glucose_insulin") + let output = loadEffectVelocityFixture("counteraction_effect_falling_glucose_output") + + let effects = input.counteractionEffects(to: insulinEffect) + let unit = LoopUnit.milligramsPerDeciliterPerMinute + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testCounteractionEffectsForFallingGlucoseAlmostDuplicates() { + let input = loadInputFixture("counteraction_effect_falling_glucose_almost_duplicates_input") + let insulinEffect = loadOutputFixture("counteraction_effect_falling_glucose_insulin") + let output = loadEffectVelocityFixture("counteraction_effect_falling_glucose_almost_duplicates_output") + + let effects = input.counteractionEffects(to: insulinEffect) + let unit = LoopUnit.milligramsPerDeciliterPerMinute + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.endDate, calculated.endDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testCounteractionEffectsForNoGlucose() { + let input = [GlucoseFixtureValue]() + let insulinEffect = loadOutputFixture("counteraction_effect_falling_glucose_insulin") + let output = [GlucoseEffectVelocity]() + + let effects = input.counteractionEffects(to: insulinEffect) + + XCTAssertEqual(output.count, effects.count) + } + } diff --git a/Tests/LoopAlgorithmTests/InsulinMathTests.swift b/Tests/LoopAlgorithmTests/InsulinMathTests.swift index b38046f..04b74c2 100644 --- a/Tests/LoopAlgorithmTests/InsulinMathTests.swift +++ b/Tests/LoopAlgorithmTests/InsulinMathTests.swift @@ -27,12 +27,6 @@ class InsulinMathTests: XCTestCase { print("\n\n") } - - public func loadFixture(_ resourceName: String) -> T { - let url = Bundle.module.url(forResource: resourceName, withExtension: "json", subdirectory: "Fixtures")! - return try! JSONSerialization.jsonObject(with: Data(contentsOf: url), options: []) as! T - } - func loadGlucoseEffectFixture(_ resourceName: String) -> [GlucoseEffect] { let fixture: [JSONDictionary] = loadFixture(resourceName) let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) From 9e1ff9b12eb6712867b810402a5fdf0100ecddc3 Mon Sep 17 00:00:00 2001 From: Petr David Date: Wed, 11 Mar 2026 16:21:13 +0100 Subject: [PATCH 06/15] add test case for meal bolus without mid absorption isf --- .../Fixtures/meal-bolus-isf.json | 192 ++++++++++++++++++ .../Fixtures/meal-bolus-no-isf.json | 192 ++++++++++++++++++ .../LoopAlgorithmTests.swift | 37 +++- 3 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 Tests/LoopAlgorithmTests/Fixtures/meal-bolus-isf.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/meal-bolus-no-isf.json diff --git a/Tests/LoopAlgorithmTests/Fixtures/meal-bolus-isf.json b/Tests/LoopAlgorithmTests/Fixtures/meal-bolus-isf.json new file mode 100644 index 0000000..d89763f --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/meal-bolus-isf.json @@ -0,0 +1,192 @@ +{ + "automaticBolusApplicationFactor" : 0.4, + "basal" : [ + { + "endDate" : "2025-07-29T05:00:00Z", + "startDate" : "2025-07-28T20:00:00Z", + "value" : 0.85 + }, + { + "endDate" : "2025-07-29T14:49:56Z", + "startDate" : "2025-07-29T05:00:00Z", + "value" : 1 + }, + { + "endDate" : "2025-07-29T16:12:36Z", + "startDate" : "2025-07-29T14:49:56Z", + "value" : 1 + }, + { + "endDate" : "2025-07-29T16:12:44Z", + "startDate" : "2025-07-29T16:12:36Z", + "value" : 1 + }, + { + "endDate" : "2025-07-29T16:39:30Z", + "startDate" : "2025-07-29T16:12:44Z", + "value" : 1 + } + ], + "carbEntries" : [ + { + "absorptionTime" : 10800, + "date" : "2025-07-29T16:12:48Z", + "grams" : 20 + } + ], + "carbRatio" : [ + { + "endDate" : "2025-07-29T05:00:00Z", + "startDate" : "2025-07-29T04:11:52Z", + "value" : 10 + }, + { + "endDate" : "2025-07-29T14:49:56Z", + "startDate" : "2025-07-29T05:00:00Z", + "value" : 10 + }, + { + "endDate" : "2025-07-29T16:12:36Z", + "startDate" : "2025-07-29T14:49:56Z", + "value" : 10 + }, + { + "endDate" : "2025-07-29T16:12:44Z", + "startDate" : "2025-07-29T16:12:36Z", + "value" : 10 + }, + { + "endDate" : "2025-07-29T18:12:44Z", + "startDate" : "2025-07-29T16:12:44Z", + "value" : 10 + }, + { + "endDate" : "2025-07-29T22:25:00Z", + "startDate" : "2025-07-29T18:12:44Z", + "value" : 10 + } + ], + "doses" : [ + { + "endDate" : "2025-07-29T05:00:00Z", + "startDate" : "2025-07-28T20:00:00Z", + "type" : "basal", + "volume" : 7.65 + }, + { + "endDate" : "2025-07-29T14:49:01Z", + "startDate" : "2025-07-29T05:00:00Z", + "type" : "basal", + "volume" : 9.8 + }, + { + "endDate" : "2025-07-29T15:07:36Z", + "startDate" : "2025-07-29T14:49:01Z", + "type" : "basal", + "volume" : 0.3 + }, + { + "endDate" : "2025-07-29T15:57:58Z", + "startDate" : "2025-07-29T15:07:36Z", + "type" : "basal", + "volume" : 0.85 + }, + { + "endDate" : "2025-07-29T16:01:05Z", + "startDate" : "2025-07-29T15:57:58Z", + "type" : "basal", + "volume" : 0.05 + }, + { + "endDate" : "2025-07-29T16:07:02Z", + "startDate" : "2025-07-29T16:01:05Z", + "type" : "basal", + "volume" : 0.1 + }, + { + "endDate" : "2025-07-29T16:09:30Z", + "startDate" : "2025-07-29T16:07:02Z", + "type" : "basal", + "volume" : 0.04098938193586137 + }, + { + "endDate" : "2025-07-29T16:39:30Z", + "startDate" : "2025-07-29T16:09:30Z", + "type" : "basal", + "volume" : 0 + } + ], + "glucoseHistory" : [ + { + "date" : "2025-07-29T14:49:01Z", + "value" : 110 + }, + { + "date" : "2025-07-29T14:54:01Z", + "value" : 112 + }, + { + "date" : "2025-07-29T14:59:01Z", + "value" : 113 + }, + { + "date" : "2025-07-29T15:04:37Z", + "value" : 115 + }, + { + "date" : "2025-07-29T15:07:36Z", + "value" : 116 + }, + { + "date" : "2025-07-29T15:14:11Z", + "value" : 119 + }, + { + "date" : "2025-07-29T15:57:58Z", + "value" : 129 + }, + { + "date" : "2025-07-29T16:01:05Z", + "value" : 129 + }, + { + "date" : "2025-07-29T16:06:05Z", + "value" : 129 + }, + { + "date" : "2025-07-29T16:07:02Z", + "value" : 130 + }, + { + "date" : "2025-07-29T16:09:30Z", + "value" : 130 + } + ], + "maxBasalRate" : 5, + "maxBolus" : 10, + "predictionStart" : "2025-07-29T16:12:52Z", + "recommendationInsulinType" : "novolog", + "recommendationType" : "manualBolus", + "sensitivity" : [ + { + "endDate" : "2025-07-29T16:30:00Z", + "startDate" : "2025-07-28T20:00:00Z", + "value" : 55 + }, + { + "endDate" : "2025-07-29T22:50:00Z", + "startDate" : "2025-07-29T16:30:00Z", + "value" : 65 + } + ], + "suspendThreshold" : 75, + "target" : [ + { + "endDate" : "2025-07-29T22:25:00Z", + "lowerBound" : 140, + "startDate" : "2025-07-29T16:12:44Z", + "upperBound" : 160 + } + ], + "useMidAbsorptionISF" : true +} diff --git a/Tests/LoopAlgorithmTests/Fixtures/meal-bolus-no-isf.json b/Tests/LoopAlgorithmTests/Fixtures/meal-bolus-no-isf.json new file mode 100644 index 0000000..807510c --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/meal-bolus-no-isf.json @@ -0,0 +1,192 @@ +{ + "automaticBolusApplicationFactor" : 0.4, + "basal" : [ + { + "endDate" : "2025-07-29T05:00:00Z", + "startDate" : "2025-07-28T20:00:00Z", + "value" : 0.85 + }, + { + "endDate" : "2025-07-29T14:49:56Z", + "startDate" : "2025-07-29T05:00:00Z", + "value" : 1 + }, + { + "endDate" : "2025-07-29T16:12:36Z", + "startDate" : "2025-07-29T14:49:56Z", + "value" : 1 + }, + { + "endDate" : "2025-07-29T16:12:44Z", + "startDate" : "2025-07-29T16:12:36Z", + "value" : 1 + }, + { + "endDate" : "2025-07-29T16:39:30Z", + "startDate" : "2025-07-29T16:12:44Z", + "value" : 1 + } + ], + "carbEntries" : [ + { + "absorptionTime" : 10800, + "date" : "2025-07-29T16:12:48Z", + "grams" : 20 + } + ], + "carbRatio" : [ + { + "endDate" : "2025-07-29T05:00:00Z", + "startDate" : "2025-07-29T04:11:52Z", + "value" : 10 + }, + { + "endDate" : "2025-07-29T14:49:56Z", + "startDate" : "2025-07-29T05:00:00Z", + "value" : 10 + }, + { + "endDate" : "2025-07-29T16:12:36Z", + "startDate" : "2025-07-29T14:49:56Z", + "value" : 10 + }, + { + "endDate" : "2025-07-29T16:12:44Z", + "startDate" : "2025-07-29T16:12:36Z", + "value" : 10 + }, + { + "endDate" : "2025-07-29T18:12:44Z", + "startDate" : "2025-07-29T16:12:44Z", + "value" : 10 + }, + { + "endDate" : "2025-07-29T22:25:00Z", + "startDate" : "2025-07-29T18:12:44Z", + "value" : 10 + } + ], + "doses" : [ + { + "endDate" : "2025-07-29T05:00:00Z", + "startDate" : "2025-07-28T20:00:00Z", + "type" : "basal", + "volume" : 7.65 + }, + { + "endDate" : "2025-07-29T14:49:01Z", + "startDate" : "2025-07-29T05:00:00Z", + "type" : "basal", + "volume" : 9.8 + }, + { + "endDate" : "2025-07-29T15:07:36Z", + "startDate" : "2025-07-29T14:49:01Z", + "type" : "basal", + "volume" : 0.3 + }, + { + "endDate" : "2025-07-29T15:57:58Z", + "startDate" : "2025-07-29T15:07:36Z", + "type" : "basal", + "volume" : 0.85 + }, + { + "endDate" : "2025-07-29T16:01:05Z", + "startDate" : "2025-07-29T15:57:58Z", + "type" : "basal", + "volume" : 0.05 + }, + { + "endDate" : "2025-07-29T16:07:02Z", + "startDate" : "2025-07-29T16:01:05Z", + "type" : "basal", + "volume" : 0.1 + }, + { + "endDate" : "2025-07-29T16:09:30Z", + "startDate" : "2025-07-29T16:07:02Z", + "type" : "basal", + "volume" : 0.04098938193586137 + }, + { + "endDate" : "2025-07-29T16:39:30Z", + "startDate" : "2025-07-29T16:09:30Z", + "type" : "basal", + "volume" : 0 + } + ], + "glucoseHistory" : [ + { + "date" : "2025-07-29T14:49:01Z", + "value" : 110 + }, + { + "date" : "2025-07-29T14:54:01Z", + "value" : 112 + }, + { + "date" : "2025-07-29T14:59:01Z", + "value" : 113 + }, + { + "date" : "2025-07-29T15:04:37Z", + "value" : 115 + }, + { + "date" : "2025-07-29T15:07:36Z", + "value" : 116 + }, + { + "date" : "2025-07-29T15:14:11Z", + "value" : 119 + }, + { + "date" : "2025-07-29T15:57:58Z", + "value" : 129 + }, + { + "date" : "2025-07-29T16:01:05Z", + "value" : 129 + }, + { + "date" : "2025-07-29T16:06:05Z", + "value" : 129 + }, + { + "date" : "2025-07-29T16:07:02Z", + "value" : 130 + }, + { + "date" : "2025-07-29T16:09:30Z", + "value" : 130 + } + ], + "maxBasalRate" : 5, + "maxBolus" : 10, + "predictionStart" : "2025-07-29T16:12:52Z", + "recommendationInsulinType" : "novolog", + "recommendationType" : "manualBolus", + "sensitivity" : [ + { + "endDate" : "2025-07-29T16:30:00Z", + "startDate" : "2025-07-28T20:00:00Z", + "value" : 55 + }, + { + "endDate" : "2025-07-29T22:50:00Z", + "startDate" : "2025-07-29T16:30:00Z", + "value" : 65 + } + ], + "suspendThreshold" : 75, + "target" : [ + { + "endDate" : "2025-07-29T22:25:00Z", + "lowerBound" : 140, + "startDate" : "2025-07-29T16:12:44Z", + "upperBound" : 160 + } + ], + "useMidAbsorptionISF" : false +} diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index e8d1f1f..20b0aef 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -222,7 +222,7 @@ final class LoopAlgorithmTests: XCTestCase { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let url = Bundle.module.url(forResource: "meal-bolus", withExtension: "json", subdirectory: "Fixtures")! + let url = Bundle.module.url(forResource: "meal-bolus-isf", withExtension: "json", subdirectory: "Fixtures")! var input = try! decoder.decode(AlgorithmInputFixture.self, from: try! Data(contentsOf: url)) let output = LoopAlgorithm.run(input: input) @@ -247,6 +247,41 @@ final class LoopAlgorithmTests: XCTestCase { XCTAssertEqual(output2.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 150, accuracy: 0.1) } + func testMealBolusNoMidAbsorptionISFScenario() { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let url = Bundle.module.url( + forResource: "meal-bolus-no-isf", withExtension: "json", subdirectory: "Fixtures")! + var input = try! decoder.decode( + AlgorithmInputFixture.self, from: try! Data(contentsOf: url)) + + let output = LoopAlgorithm.run(input: input) + + // Should recommend bolus to cover meal + XCTAssertEqual( + output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 269, + accuracy: 0.1) + XCTAssertEqual(output.recommendation!.manual!.amount, 2.16, accuracy: 0.01) + + // Now check forecast if bolus recommendation is accepted and delivered. + input.doses.append( + .init( + deliveryType: .bolus, + startDate: input.predictionStart, + endDate: input.predictionStart.addingTimeInterval(30), + volume: output.recommendation!.manual!.amount + ) + ) + + let output2 = LoopAlgorithm.run(input: input) + + // 150 mg/dL is the middle of the target range + XCTAssertEqual( + output2.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 150, + accuracy: 0.1) + } + func testMidAborptionISFFlag() { let now = ISO8601DateFormatter().date(from: "2024-01-03T00:00:00+0000")! var input = AlgorithmInputFixture.mock(for: now) From 07afc01bf06812d5ca58b69ee5b5def13d2e7364 Mon Sep 17 00:00:00 2001 From: Petr David Date: Wed, 11 Mar 2026 16:56:10 +0100 Subject: [PATCH 07/15] formatting, typo --- Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 20b0aef..30651da 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -259,9 +259,7 @@ final class LoopAlgorithmTests: XCTestCase { let output = LoopAlgorithm.run(input: input) // Should recommend bolus to cover meal - XCTAssertEqual( - output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 269, - accuracy: 0.1) + XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 269, accuracy: 0.1) XCTAssertEqual(output.recommendation!.manual!.amount, 2.16, accuracy: 0.01) // Now check forecast if bolus recommendation is accepted and delivered. @@ -277,12 +275,10 @@ final class LoopAlgorithmTests: XCTestCase { let output2 = LoopAlgorithm.run(input: input) // 150 mg/dL is the middle of the target range - XCTAssertEqual( - output2.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 150, - accuracy: 0.1) + XCTAssertEqual(output2.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 150, accuracy: 0.1) } - func testMidAborptionISFFlag() { + func testMidAbsorptionISFFlag() { let now = ISO8601DateFormatter().date(from: "2024-01-03T00:00:00+0000")! var input = AlgorithmInputFixture.mock(for: now) From 18166737ee7364a743471c9d09a85404276273a3 Mon Sep 17 00:00:00 2001 From: Petr David Date: Wed, 25 Mar 2026 16:29:37 +0100 Subject: [PATCH 08/15] force rebuild --- Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 30651da..1d7ae4b 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -558,4 +558,4 @@ final class LoopAlgorithmTests: XCTestCase { output = LoopAlgorithm.run(input: input) XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 105, accuracy: 0.5) } -} +} \ No newline at end of file From b05f72b2cf3c4f737ec864d11cb7de9e55e40a5e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 21 May 2026 18:19:35 -0500 Subject: [PATCH 09/15] Make decayEffect a continuous function of sample timestamp (#33) * Make decayEffect a continuous function of sample timestamp Reformulates decayEffect using a closed-form quadratic in time-since-sample rather than accumulating step-by-step from the floored simulation boundary. This makes the effect value at any future absolute timestamp independent of which delta-sized simulation bucket the sample's startDate falls into. For samples aligned to delta boundaries the two formulations are mathematically identical. For unaligned samples (the common case with real CGM streams) the new formulation removes a small discontinuity that the old code exhibited at bucket boundaries. Adds LoopMathTests covering continuity across a delta boundary. Existing fixture-calibrated tests are re-pinned to the new values; per-prediction drift is on the order of 0.1 mg/dL. Ports the LoopMath change from LoopKit/LoopKit#556 by Moti Nisenson-Ken to the LoopAlgorithm package, where decayEffect now lives. * Space to kick off tests --------- Co-authored-by: Pete Schwamb --- Sources/LoopAlgorithm/LoopMath.swift | 22 ++- .../carbs_with_isf_change_recommendation.json | 4 +- .../live_capture_predicted_glucose.json | 154 +++++++++--------- .../LoopAlgorithmTests.swift | 20 +-- Tests/LoopAlgorithmTests/LoopMathTests.swift | 48 ++++++ ...IntegralRetrospectiveCorrectionTests.swift | 2 +- 6 files changed, 152 insertions(+), 98 deletions(-) create mode 100644 Tests/LoopAlgorithmTests/LoopMathTests.swift diff --git a/Sources/LoopAlgorithm/LoopMath.swift b/Sources/LoopAlgorithm/LoopMath.swift index 13889fc..7fc9e48 100644 --- a/Sources/LoopAlgorithm/LoopMath.swift +++ b/Sources/LoopAlgorithm/LoopMath.swift @@ -192,19 +192,25 @@ extension GlucoseValue { let glucoseUnit = LoopUnit.milligramsPerDeciliter let velocityUnit = GlucoseEffectVelocity.perSecondUnit - // The starting rate, which we will decay to 0 over the specified duration - let intercept = rate.doubleValue(for: velocityUnit) // mg/dL/s - let decayStartDate = startDate.addingTimeInterval(delta) - let slope = -intercept / (duration - delta) // mg/dL/s/s + let firstChange = rate.doubleValue(for: velocityUnit) * delta // mg/dL/s * s = mg/dL + let secondChange = firstChange * (1 - delta / (duration - delta)) + + // Solve for f(t) = a*t^2 + b*t + c, where t is relative to self.startDate. + // f(0) = c + // f(delta) - c = firstChange = a*delta^2 + b*delta + // f(2*delta) - c = firstChange + secondChange = 4*a*delta^2 + 2*b*delta + // --> firstChange - secondChange = 2*a*delta^2 + let c = quantity.doubleValue(for: glucoseUnit) + let a = (secondChange - firstChange) / (2 * delta * delta) // mg/dL/s^2 + let b = (firstChange + secondChange - 4 * a * delta * delta) / (2 * delta) // mg/dL/s var values = [GlucoseEffect(startDate: startDate, quantity: quantity)] - var date = decayStartDate - var lastValue = quantity.doubleValue(for: glucoseUnit) + var date = startDate.addingTimeInterval(delta) repeat { - let value = lastValue + (intercept + slope * date.timeIntervalSince(decayStartDate)) * delta + let time = min(duration, date.timeIntervalSince(self.startDate)) + let value = a * time * time + b * time + c values.append(GlucoseEffect(startDate: date, quantity: LoopQuantity(unit: glucoseUnit, doubleValue: value))) - lastValue = value date = date.addingTimeInterval(delta) } while date < endDate diff --git a/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json index b4e70af..bde6ac6 100644 --- a/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json +++ b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json @@ -1,5 +1,5 @@ { "manual" : { - "amount" : 10.546890782709953 + "amount" : 10.52269112701204 } -} +} \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/live_capture_predicted_glucose.json b/Tests/LoopAlgorithmTests/Fixtures/live_capture_predicted_glucose.json index b77cb55..1baaacf 100644 --- a/Tests/LoopAlgorithmTests/Fixtures/live_capture_predicted_glucose.json +++ b/Tests/LoopAlgorithmTests/Fixtures/live_capture_predicted_glucose.json @@ -10,383 +10,383 @@ "startDate" : "2023-06-23T02:40:00Z" }, { - "quantity" : 180.52987493690765, + "quantity" : 180.52784693243598, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T02:45:00Z" }, { - "quantity" : 179.77931710835796, + "quantity" : 179.77106522809387, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T02:50:00Z" }, { - "quantity" : 177.81435588000684, + "quantity" : 177.7956842526296, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T02:55:00Z" }, { - "quantity" : 175.04920382978105, + "quantity" : 175.01794458844162, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:00:00Z" }, { - "quantity" : 172.09884468881066, + "quantity" : 172.05499783350902, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:05:00Z" }, { - "quantity" : 169.0341959170697, + "quantity" : 168.97776144780588, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:10:00Z" }, { - "quantity" : 165.91852357330802, + "quantity" : 165.84950149008202, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:15:00Z" }, { - "quantity" : 162.78787379965794, + "quantity" : 162.70626410246973, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:20:00Z" }, { - "quantity" : 159.67566374385987, + "quantity" : 159.58146643270948, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:25:00Z" }, { - "quantity" : 156.6278000530812, + "quantity" : 156.5210151279686, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:30:00Z" }, { - "quantity" : 153.68497899133908, + "quantity" : 153.5656064522643, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:35:00Z" }, { - "quantity" : 150.85857622089654, + "quantity" : 150.73920368182175, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:40:00Z" }, { - "quantity" : 148.1797464838103, + "quantity" : 148.06037394473552, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:45:00Z" }, { - "quantity" : 145.67546444468488, + "quantity" : 145.5560919056101, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:50:00Z" }, { - "quantity" : 143.36889813413907, + "quantity" : 143.24952559506428, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T03:55:00Z" }, { - "quantity" : 141.27978455565565, + "quantity" : 141.16041201658086, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:00:00Z" }, { - "quantity" : 139.4249156157845, + "quantity" : 139.3055430767097, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:05:00Z" }, { - "quantity" : 137.7082164432302, + "quantity" : 137.58884390415542, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:10:00Z" }, { - "quantity" : 135.9914530272836, + "quantity" : 135.8720804882088, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:15:00Z" }, { - "quantity" : 134.2827664300858, + "quantity" : 134.163393891011, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:20:00Z" }, { - "quantity" : 132.58882252103788, + "quantity" : 132.4694499819631, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:25:00Z" }, { - "quantity" : 130.91436540926705, + "quantity" : 130.79499287019226, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:30:00Z" }, { - "quantity" : 129.26245506698106, + "quantity" : 129.14308252790627, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:35:00Z" }, { - "quantity" : 127.63445215517064, + "quantity" : 127.51507961609585, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:40:00Z" }, { - "quantity" : 126.02931442610466, + "quantity" : 125.90994188702987, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:45:00Z" }, { - "quantity" : 124.44584453318035, + "quantity" : 124.32647199410556, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:50:00Z" }, { - "quantity" : 122.88145382927624, + "quantity" : 122.76208129020145, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T04:55:00Z" }, { - "quantity" : 121.33291804466413, + "quantity" : 121.21354550558934, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:00:00Z" }, { - "quantity" : 119.79660318395023, + "quantity" : 119.67723064487544, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:05:00Z" }, { - "quantity" : 118.26822621269756, + "quantity" : 118.14885367362277, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:10:00Z" }, { - "quantity" : 116.74288846240054, + "quantity" : 116.62351592332575, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:15:00Z" }, { - "quantity" : 115.21516364934988, + "quantity" : 115.09579111027509, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:20:00Z" }, { - "quantity" : 113.67917795139525, + "quantity" : 113.55980541232046, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:25:00Z" }, { - "quantity" : 112.12868274578355, + "quantity" : 112.00931020670876, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:30:00Z" }, { - "quantity" : 110.55712056957398, + "quantity" : 110.4377480304992, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:35:00Z" }, { - "quantity" : 108.95768482515078, + "quantity" : 108.83831228607599, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:40:00Z" }, { - "quantity" : 107.32337371691418, + "quantity" : 107.20400117783939, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:45:00Z" }, { - "quantity" : 105.64703887119052, + "quantity" : 105.52766633211573, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:50:00Z" }, { - "quantity" : 103.92146136061618, + "quantity" : 103.80208882154139, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T05:55:00Z" }, { - "quantity" : 102.13957364029821, + "quantity" : 102.02020110122342, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:00:00Z" }, { - "quantity" : 100.29425666336888, + "quantity" : 100.1748841242941, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:05:00Z" }, { - "quantity" : 98.37810372588095, + "quantity" : 98.25873118680616, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:10:00Z" }, { - "quantity" : 96.38393930539169, + "quantity" : 96.2645667663169, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:15:00Z" }, { - "quantity" : 94.30446350902744, + "quantity" : 94.18509096995265, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:20:00Z" }, { - "quantity" : 92.24204127278486, + "quantity" : 92.12266873371007, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:25:00Z" }, { - "quantity" : 90.33818302395392, + "quantity" : 90.21881048487913, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:30:00Z" }, { - "quantity" : 88.58657375772682, + "quantity" : 88.46720121865204, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:35:00Z" }, { - "quantity" : 86.9796355549934, + "quantity" : 86.8602630159186, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:40:00Z" }, { - "quantity" : 85.50932186775859, + "quantity" : 85.3899493286838, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:45:00Z" }, { - "quantity" : 84.16822997919033, + "quantity" : 84.04885744011554, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:50:00Z" }, { - "quantity" : 82.94837192653554, + "quantity" : 82.82899938746075, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T06:55:00Z" }, { - "quantity" : 81.84224397138112, + "quantity" : 81.72287143230633, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:00:00Z" }, { - "quantity" : 80.8433012790305, + "quantity" : 80.72392873995571, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:05:00Z" }, { - "quantity" : 79.94514990703274, + "quantity" : 79.82577736795795, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:10:00Z" }, { - "quantity" : 79.1425285689858, + "quantity" : 79.02315602991101, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:15:00Z" }, { - "quantity" : 78.43073701607969, + "quantity" : 78.3113644770049, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:20:00Z" }, { - "quantity" : 77.80513210408813, + "quantity" : 77.68575956501334, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:25:00Z" }, { - "quantity" : 77.26038909817899, + "quantity" : 77.1410165591042, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:30:00Z" }, { - "quantity" : 76.79214128522554, + "quantity" : 76.67276874615075, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:35:00Z" }, { - "quantity" : 76.39636603545401, + "quantity" : 76.27699349637922, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:40:00Z" }, { - "quantity" : 76.06917517261084, + "quantity" : 75.94980263353605, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:45:00Z" }, { - "quantity" : 75.80681469169488, + "quantity" : 75.6874421526201, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:50:00Z" }, { - "quantity" : 75.60563685065486, + "quantity" : 75.48626431158007, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T07:55:00Z" }, { - "quantity" : 75.46174433219417, + "quantity" : 75.34237179311938, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:00:00Z" }, { - "quantity" : 75.3700976935867, + "quantity" : 75.25072515451191, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:05:00Z" }, { - "quantity" : 75.32563190200372, + "quantity" : 75.20625936292893, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:10:00Z" }, { - "quantity" : 75.32301505961473, + "quantity" : 75.20364252053994, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:15:00Z" }, { - "quantity" : 75.33414614640142, + "quantity" : 75.21477360732663, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:20:00Z" }, { - "quantity" : 75.34232624108009, + "quantity" : 75.2229537020053, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:25:00Z" }, { - "quantity" : 75.34805924470882, + "quantity" : 75.22868670563403, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:30:00Z" }, { - "quantity" : 75.35181912391843, + "quantity" : 75.23244658484364, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:35:00Z" }, { - "quantity" : 75.35405041818424, + "quantity" : 75.23467787910946, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:40:00Z" }, { - "quantity" : 75.35517138501669, + "quantity" : 75.2357988459419, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:45:00Z" }, { - "quantity" : 75.35557365902051, + "quantity" : 75.23620111994572, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:50:00Z" }, { - "quantity" : 75.35562264689557, + "quantity" : 75.23625010782078, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T08:55:00Z" }, { - "quantity" : 75.35562264689557, + "quantity" : 75.23625010782078, "quantityUnit" : "mg\/dL", "startDate" : "2023-06-23T09:00:00Z" } -] +] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 1d7ae4b..0784e30 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -76,7 +76,7 @@ final class LoopAlgorithmTests: XCTestCase { let output = LoopAlgorithm.run(input: input) XCTAssertEqual(output.activeCarbs, 50) - XCTAssertEqual(output.recommendation!.manual!.amount, 5.83, accuracy: 0.01) + XCTAssertEqual(output.recommendation!.manual!.amount, 5.86, accuracy: 0.01) } @@ -112,8 +112,8 @@ final class LoopAlgorithmTests: XCTestCase { XCTAssertEqual(outputA.effects.insulin.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) XCTAssertEqual(outputB.effects.insulin.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) - XCTAssertEqual(outputA.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 165) - XCTAssertEqual(outputB.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 165) + XCTAssertEqual(outputA.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter) ?? 0, 165, accuracy: 0.05) + XCTAssertEqual(outputB.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter) ?? 0, 165, accuracy: 0.05) // These tests fail, because the momentum effect is *not* time independent yet. // Even though all the input data is the same (just shifted in time), momentum effect varies in relation to how offset @@ -228,8 +228,8 @@ final class LoopAlgorithmTests: XCTestCase { let output = LoopAlgorithm.run(input: input) // Should recommend bolus to cover meal - XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 274, accuracy: 0.1) - XCTAssertEqual(output.recommendation!.manual!.amount, 1.9, accuracy: 0.01) + XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 274.14, accuracy: 0.1) + XCTAssertEqual(output.recommendation!.manual!.amount, 1.91, accuracy: 0.01) // Now check forecast if bolus recommendation is accepted and delivered. input.doses.append( @@ -259,7 +259,7 @@ final class LoopAlgorithmTests: XCTestCase { let output = LoopAlgorithm.run(input: input) // Should recommend bolus to cover meal - XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 269, accuracy: 0.1) + XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 269.15, accuracy: 0.1) XCTAssertEqual(output.recommendation!.manual!.amount, 2.16, accuracy: 0.01) // Now check forecast if bolus recommendation is accepted and delivered. @@ -329,7 +329,7 @@ final class LoopAlgorithmTests: XCTestCase { var recommendedBolus = output.recommendation!.automatic?.bolusUnits var activeInsulin = output.activeInsulin! XCTAssertEqual(activeInsulin, 8.0) - XCTAssertEqual(recommendedBolus!, 1.66, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!, 1.69, accuracy: 0.01) // Now try with maxBolus of 4; should not recommend any more insulin, as we're at our max iob input.maxBolus = 4 @@ -397,12 +397,12 @@ final class LoopAlgorithmTests: XCTestCase { // Without mid-absorption ISF input.useMidAbsorptionISF = false var output = LoopAlgorithm.run(input: input) - XCTAssertEqual(2.58, output.recommendation!.manual!.amount, accuracy: 0.01) + XCTAssertEqual(2.73, output.recommendation!.manual!.amount, accuracy: 0.01) // With mid-absorption ISF input.useMidAbsorptionISF = true output = LoopAlgorithm.run(input: input) - XCTAssertEqual(1.41, output.recommendation!.manual!.amount, accuracy: 0.01) + XCTAssertEqual(1.49, output.recommendation!.manual!.amount, accuracy: 0.01) } func testIncompleteISFTimelineDetected() { @@ -558,4 +558,4 @@ final class LoopAlgorithmTests: XCTestCase { output = LoopAlgorithm.run(input: input) XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 105, accuracy: 0.5) } -} \ No newline at end of file +} diff --git a/Tests/LoopAlgorithmTests/LoopMathTests.swift b/Tests/LoopAlgorithmTests/LoopMathTests.swift new file mode 100644 index 0000000..ff3bac5 --- /dev/null +++ b/Tests/LoopAlgorithmTests/LoopMathTests.swift @@ -0,0 +1,48 @@ +// +// LoopMathTests.swift +// LoopAlgorithm +// + +import XCTest +@testable import LoopAlgorithm + +class LoopMathTests: XCTestCase { + + /// `decayEffect` previously accumulated the decay step-by-step starting from + /// the simulation-grid boundary (the sample's `startDate` floored to `delta`), + /// so two samples sitting on opposite sides of a 5-minute boundary produced + /// different effect values at the same future absolute timestamp. With the + /// continuous formulation, a sub-`delta` shift in the input timestamp only + /// shifts the output series by one slot and leaves shared-timestamp values + /// effectively unchanged. + func testDecayEffectIsContinuousAcrossSimulationBoundary() { + let calendar = Calendar(identifier: .gregorian) + let alignedDate = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1, hour: 10, minute: 15, second: 0))! + let shiftedDate = alignedDate.addingTimeInterval(-1e-6) + + let rate = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -0.5) + + let alignedSample = FixtureGlucoseSample(startDate: alignedDate, quantity: .glucose(100)) + let shiftedSample = FixtureGlucoseSample(startDate: shiftedDate, quantity: .glucose(100)) + + let alignedEffects = alignedSample.decayEffect(atRate: rate, for: .minutes(30)) + let shiftedEffects = shiftedSample.decayEffect(atRate: rate, for: .minutes(30)) + + // The shifted sample's floored start lands one `delta` earlier, so its + // series has one extra leading entry equal to the sample's value. + XCTAssertEqual(shiftedEffects.count, alignedEffects.count + 1) + XCTAssertEqual(shiftedEffects[0].quantity.doubleValue(for: .milligramsPerDeciliter), 100, accuracy: 1e-9) + + // Shared timestamps should produce shared values. + let mgdl = LoopUnit.milligramsPerDeciliter + for (index, aligned) in alignedEffects.enumerated() { + let shifted = shiftedEffects[index + 1] + XCTAssertEqual(aligned.startDate, shifted.startDate) + XCTAssertEqual( + aligned.quantity.doubleValue(for: mgdl), + shifted.quantity.doubleValue(for: mgdl), + accuracy: 1e-6 + ) + } + } +} diff --git a/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift b/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift index da43231..ccd112f 100644 --- a/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift +++ b/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift @@ -42,7 +42,7 @@ final class IntegralRetrospectiveCorrectionTests: XCTestCase { retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval ) - XCTAssertEqual(effect.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 110) + XCTAssertEqual(effect.last?.quantity.doubleValue(for: .milligramsPerDeciliter) ?? 0, 110, accuracy: 0.05) XCTAssertEqual(effect.last?.startDate, dateFormatter.date(from: "2015-07-13T13:00:00")!) } } From a1d0e578a83071878ce687965313e4473d7f7aa4 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 21 May 2026 18:23:42 -0500 Subject: [PATCH 10/15] Add unit tests for StandardRetrospectiveCorrection (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StandardRetrospectiveCorrection (the P-only retrospective correction controller) had no dedicated unit tests — its behavior was only covered transitively via the higher-level LoopAlgorithm tests. Adds 9 tests covering: - Recency gating: stale / nil / empty discrepancy lists clear the correction and return empty. - Total correction effect equals the latest (most-recent) discrepancy magnitude (Standard is P-only on .last). - Positive / negative discrepancies project glucose forward in the expected direction, with the last sample ≈ starting + discrepancy. - The first effect sample equals the starting glucose value at the starting date (correction hasn't yet had time to apply). - Only the latest discrepancy contributes — older entries are ignored (the key behavioral difference vs IntegralRC, which integrates them). - Short discrepancies are clamped to retrospectiveCorrectionGroupingInterval to prevent over-amplified velocity from very short windows. These tests will serve as a backstop while the active-insulin / EGP decomposition work modifies the glucose-effect computation upstream of the RC discrepancy calculation. Co-authored-by: LoopKit Developer --- ...StandardRetrospectiveCorrectionTests.swift | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 Tests/LoopAlgorithmTests/StandardRetrospectiveCorrectionTests.swift diff --git a/Tests/LoopAlgorithmTests/StandardRetrospectiveCorrectionTests.swift b/Tests/LoopAlgorithmTests/StandardRetrospectiveCorrectionTests.swift new file mode 100644 index 0000000..9703f84 --- /dev/null +++ b/Tests/LoopAlgorithmTests/StandardRetrospectiveCorrectionTests.swift @@ -0,0 +1,233 @@ +// +// StandardRetrospectiveCorrectionTests.swift +// LoopAlgorithm +// +// Unit tests for StandardRetrospectiveCorrection (the P-only retrospective +// correction controller). Standard RC takes the most-recent +// prediction-vs-actual discrepancy and projects it forward as a decaying +// glucose effect over `effectDuration` (default 60 min). +// + +import XCTest +@testable import LoopAlgorithm + +final class StandardRetrospectiveCorrectionTests: XCTestCase { + + private let unit = LoopUnit.milligramsPerDeciliter + + // MARK: - Helpers + + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + private func date(_ s: String) -> Date { + return dateFormatter.date(from: s)! + } + + private func change(from start: Date, to end: Date, mgdl: Double) -> GlucoseChange { + return GlucoseChange(startDate: start, endDate: end, quantity: .glucose(mgdl)) + } + + private func makeRC() -> StandardRetrospectiveCorrection { + return StandardRetrospectiveCorrection( + effectDuration: LoopMath.retrospectiveCorrectionEffectDuration + ) + } + + // MARK: - Recency gating + + func testStaleDiscrepancyClearsEffect() { + // Discrepancy ends > recencyInterval before the starting glucose date. + // Effect must be empty + totalGlucoseCorrectionEffect must be nil. + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let discrepancy = change( + from: glucoseDate.addingTimeInterval(-.minutes(60)), + to: glucoseDate.addingTimeInterval(-.minutes(30)), + mgdl: 10 + ) + let rc = makeRC() + // recencyInterval = 15 min, but discrepancy ends 30 min ago → stale + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [discrepancy], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertTrue(effect.isEmpty, "stale discrepancy should produce no effect") + XCTAssertNil(rc.totalGlucoseCorrectionEffect) + } + + func testNilDiscrepancyListReturnsEmpty() { + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: nil, + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertTrue(effect.isEmpty) + XCTAssertNil(rc.totalGlucoseCorrectionEffect) + } + + func testEmptyDiscrepancyListReturnsEmpty() { + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertTrue(effect.isEmpty) + XCTAssertNil(rc.totalGlucoseCorrectionEffect) + } + + // MARK: - Total correction effect + + func testTotalCorrectionEffectEqualsLatestDiscrepancy() { + // Standard RC: totalGlucoseCorrectionEffect == latest discrepancy magnitude. + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(120)) + let rc = makeRC() + _ = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(30)), + to: glucoseDate, mgdl: 15) + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertEqual(rc.totalGlucoseCorrectionEffect?.doubleValue(for: unit), 15.0) + } + + // MARK: - Effect projection + + func testPositiveDiscrepancyProjectsForward() { + // +12 mg/dL discrepancy over 30 min → decay over 60-min effectDuration. + // The integrated effect should ramp from 0 at startingGlucose to ~+12 + // at endDate of effectDuration. + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(30)), + to: glucoseDate, mgdl: 12) + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertFalse(effect.isEmpty) + // Last sample should be roughly startingGlucose + 12 (decay applies the + // proportional correction over the effect window) + let last = effect.last!.quantity.doubleValue(for: unit) + XCTAssertEqual(last, 112.0, accuracy: 0.5, + "last projected glucose ≈ starting + discrepancy") + } + + func testNegativeDiscrepancyProjectsDownward() { + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(150)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(30)), + to: glucoseDate, mgdl: -10) + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertFalse(effect.isEmpty) + let last = effect.last!.quantity.doubleValue(for: unit) + XCTAssertEqual(last, 140.0, accuracy: 0.5, + "last projected glucose ≈ starting + (negative) discrepancy") + XCTAssertEqual(rc.totalGlucoseCorrectionEffect?.doubleValue(for: unit), -10.0) + } + + func testEffectStartsAtStartingGlucoseValue() { + // First effect sample should equal startingGlucose value (correction + // has not yet had time to apply). + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(110)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(30)), + to: glucoseDate, mgdl: 20) + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertFalse(effect.isEmpty) + XCTAssertEqual(effect.first!.quantity.doubleValue(for: unit), 110.0, accuracy: 0.01) + XCTAssertEqual(effect.first!.startDate, glucoseDate) + } + + // MARK: - Multiple discrepancies — only LATEST is used + + func testOnlyMostRecentDiscrepancyIsUsed() { + // Standard RC uses ONLY .last — older entries are ignored even when + // they would change the answer (this is exactly what IntegralRC fixes). + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(150)), + to: glucoseDate.addingTimeInterval(-.minutes(120)), mgdl: 50), // big older + change(from: glucoseDate.addingTimeInterval(-.minutes(60)), + to: glucoseDate.addingTimeInterval(-.minutes(30)), mgdl: 30), // medium older + change(from: glucoseDate.addingTimeInterval(-.minutes(30)), + to: glucoseDate, mgdl: 5), // small latest + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + // Total effect must reflect ONLY the most recent (+5), not the larger older ones. + XCTAssertEqual(rc.totalGlucoseCorrectionEffect?.doubleValue(for: unit), 5.0) + XCTAssertEqual(effect.last!.quantity.doubleValue(for: unit), 105.0, accuracy: 0.5) + } + + // MARK: - Grouping interval clamps short discrepancies + + func testShortDiscrepancyClampedByGroupingInterval() { + // If the discrepancy's interval is shorter than retrospectiveCorrection- + // GroupingInterval, the velocity calc uses groupingInterval as the + // denominator (not the actual interval). This protects against over- + // amplified projections from very short discrepancies. + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let rc = makeRC() + // Discrepancy is +10 over only 5 minutes (very short window). If we + // used 5 min as the denominator, velocity would be 6× larger. + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(5)), + to: glucoseDate, mgdl: 10) + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertFalse(effect.isEmpty) + // Total effect over the effectDuration should still be ~+10 (the + // proportional correction), but spread over the full duration not + // amplified for the short window. + let last = effect.last!.quantity.doubleValue(for: unit) + XCTAssertEqual(last, 110.0, accuracy: 0.5) + } +} From 729e5084cb35dccb032b987073955ea97287e206 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 21 May 2026 18:24:51 -0500 Subject: [PATCH 11/15] Faster filterDateRange via binary search (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SampleValue.swift: add a filterDateRange overload for RandomAccessCollection where Element: TimelineValue, Index == Int. Returns the same result as the existing Sequence-based linear-filter implementation but uses two binary searches instead of a linear scan. Picks up automatically for Array-backed callers (which is every caller in this codebase via Swift protocol dispatch). Significant speedup for hot paths that call filterDateRange repeatedly on long schedules — for example, InsulinMath.glucoseEffectsMidAbsorptionISF and DoseMath.insulinCorrection when the sensitivity schedule has many segments. In a LoopEval 60-day per-step prediction sweep with a per-step ISF schedule, total sim wall-clock went from ~30 min to ~1 min (≈30× faster) with bit-identical output to the linear-filter path. Tests: FilterDateRangeTests.swift with 11 cases covering equivalence with the linear-filter reference: boundary cases (empty, both bounds nil, only start, only end), start-before-all, end-after-all, fully- outside, single-sample collections, exact-match-one-segment, and a 100-iteration randomized fuzz over a 200-element contiguous schedule. --- Sources/LoopAlgorithm/SampleValue.swift | 37 +++++ .../FilterDateRangeTests.swift | 138 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 Tests/LoopAlgorithmTests/FilterDateRangeTests.swift diff --git a/Sources/LoopAlgorithm/SampleValue.swift b/Sources/LoopAlgorithm/SampleValue.swift index a7fee2e..fb978b2 100644 --- a/Sources/LoopAlgorithm/SampleValue.swift +++ b/Sources/LoopAlgorithm/SampleValue.swift @@ -98,3 +98,40 @@ public extension Sequence where Element: TimelineValue { return filterDateRange(interval.start, interval.end) } } + +/// Fast binary-search filter for ordered timeline arrays. Picks up when the +/// collection conforms to RandomAccessCollection with Int index (i.e. Array) +/// and the elements are sorted by startDate (which is the contract for all +/// schedule arrays — sensitivity / basal / carb-ratio / target — across this +/// codebase). Reduces filterDateRange from O(N) to O(log N) per call. +/// +/// LoopEval sims with per-step ISF schedules (`--candidate-isf-csv`) call +/// filterDateRange ~1.5M times on a 60-day window; this dropped sim time +/// from ~30 min to ~2 min on that workload. +public extension RandomAccessCollection where Element: TimelineValue, Index == Int { + func filterDateRange(_ startDate: Date?, _ endDate: Date?) -> [Element] { + guard !isEmpty else { return [] } + // Lower bound: first index where element.endDate >= startDate + var lo = startIndex + if let startDate { + var l = startIndex, r = endIndex + while l < r { + let m = (l + r) / 2 + if self[m].endDate < startDate { l = m + 1 } else { r = m } + } + lo = l + } + // Upper bound: first index where element.startDate > endDate + var hi = endIndex + if let endDate { + var l = lo, r = endIndex + while l < r { + let m = (l + r) / 2 + if self[m].startDate <= endDate { l = m + 1 } else { r = m } + } + hi = l + } + guard lo < hi else { return [] } + return Array(self[lo.. — must produce identical output +// to the Sequence-based linear-filter version. +// + +import XCTest +@testable import LoopAlgorithm + +final class FilterDateRangeTests: XCTestCase { + + /// Minimal TimelineValue with a date range. + private struct Sample: TimelineValue, Equatable, CustomStringConvertible { + let startDate: Date + let endDate: Date + let id: Int + var description: String { "Sample(id=\(id), start=\(startDate.timeIntervalSinceReferenceDate.rounded()), end=\(endDate.timeIntervalSinceReferenceDate.rounded()))" } + } + + /// Linear-scan reference implementation (the Sequence-based version that + /// the binary-search overload must match). + private func linearFilter(_ items: [Sample], _ start: Date?, _ end: Date?) -> [Sample] { + return items.filter { value in + if let start, value.endDate < start { return false } + if let end, value.startDate > end { return false } + return true + } + } + + private func contiguousSamples(count: Int, segmentSeconds: TimeInterval = 300, + startingAt: Date = Date(timeIntervalSince1970: 1700000000)) -> [Sample] { + return (0.. Date: Thu, 21 May 2026 18:25:37 -0500 Subject: [PATCH 12/15] Add PrecomputedInsulinInput for efficient multi-step prediction sweeps + parallelize glucose-effects (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add PrecomputedInsulinInput for efficient multi-step prediction sweeps Introduces PrecomputedInsulinInput and a new generatePrediction overload that accepts pre-annotated dose data, enabling significant speedups for historical back-testing / evaluation sweeps. The key bottleneck in a dense prediction sweep is doses.annotated(with: basal), which is O(doses × basalSegments) and was called from scratch at every step. Between adjacent 5-min steps the dose list changes only at its edges; the annotation of every dose in the middle is identical. Changes: - Sources/LoopAlgorithm/Insulin/PrecomputedInsulinInput.swift (new) PrecomputedInsulinInput struct holding pre-annotated doses and an optional pre-built insulinEffects timeline. Includes a convenience .build() factory. - Sources/LoopAlgorithm/LoopAlgorithm.swift New generatePrediction(start:glucoseHistory:precomputedInsulin:carbEntries: sensitivity:carbRatio:...) overload. Skips annotated(with:) entirely; optionally skips glucoseEffects() when insulinEffects is pre-supplied. - Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift Add Sendable conformance (struct with value-type fields, safe). - Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift (new) 3 tests verifying the new overload produces output matching the standard path (bit-identical for annotation-only, count-identical + clinically equivalent for pre-built effects). Expected speedup for a 7-day sweep at 5-min step (~2016 calls): annotation bypass alone: ~40-60% wall-clock reduction + effects cache (fixed ISF): additional ~20-30% * Refactor PrecomputedInsulinInput for explicit ISF-sweep pattern Split the API into two explicit steps so ISF sweeps pay annotation cost exactly once across all multipliers: annotate(doses:basal:) → ISF-independent, build once .withEffects(sensitivity:from:to:) → ISF-dependent, once per multiplier Correct ISF sweep pattern: let base = PrecomputedInsulinInput.annotate(doses: doses, basal: basal) for multiplier in isfMultipliers { let input = base.withEffects(sensitivity: scale(sensitivity, by: multiplier)) // run ~2016 steps with input — no annotation, no per-step glucoseEffects } Cost breakdown for 10-multiplier × 7-day sweep (n≈2016 steps each): Before: annotated(with:) + glucoseEffects() called 20160× each After: annotated(with:) called 1×, glucoseEffects() called 10× Also adds testISFSweepPattern verifying bit-identical output across multipliers [0.7, 0.8, ..., 1.3] vs the standard generatePrediction path. * Add sliced(from:to:) for per-step dose window slicing Enables EvalCore to slice pre-annotated doses to the per-step lookback window without re-annotating. Uses binary search on startDate + linear filter on endDate (arrays are ~100-200 entries, linear endDate scan is negligible). Also cleans up the unused private partition helper (now only used by sliced). * Expose dose-recommendation internals as public API Downstream callers (LoopEval bench engine) need to compute dose recommendations from a forecast without going through the full run() API, which re-computes insulin effects. Making insulinCorrection, recommendTempBasal, and recommendAutomaticDose public lets them do that efficiently using already-computed predictions. Enables delivery-based ODR/UDR metrics in LoopEval that compare the actual insulin Loop would deliver across two configurations. Co-Authored-By: Claude Opus 4.7 (1M context) * Parallelize glucose-effects accumulation in InsulinMath Replace the sequential reduce loop with DispatchQueue.concurrentPerform over per-step increments, then a final cumsum. Per-step contributions are independent until the final summation, so this scales with available cores. Co-Authored-By: Claude Opus 4.7 * chore: carry forward momentumVelocityMaximum param from eval/precomputed-insulin-effects --------- Co-authored-by: Bot Co-authored-by: LoopKit Developer Co-authored-by: Claude Opus 4.7 (1M context) --- .../LoopAlgorithm/Glucose/GlucoseEffect.swift | 2 +- .../LoopAlgorithm/Insulin/InsulinMath.swift | 88 ++++--- .../Insulin/PrecomputedInsulinInput.swift | 215 ++++++++++++++++++ Sources/LoopAlgorithm/LoopAlgorithm.swift | 206 ++++++++++++++++- .../PrecomputedInsulinInputTests.swift | 215 ++++++++++++++++++ 5 files changed, 692 insertions(+), 34 deletions(-) create mode 100644 Sources/LoopAlgorithm/Insulin/PrecomputedInsulinInput.swift create mode 100644 Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift index dbcf015..9572f80 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift @@ -8,7 +8,7 @@ import Foundation -public struct GlucoseEffect: GlucoseValue, Equatable { +public struct GlucoseEffect: GlucoseValue, Equatable, Sendable { public let startDate: Date public let quantity: LoopQuantity diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index 3e250cf..b78edf3 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -397,41 +397,73 @@ extension Collection where Element == BasalRelativeDose { return [] } - var lastDate = start - var date = start - var values = [GlucoseEffect]() let unit = LoopUnit.milligramsPerDeciliter + let dosesArray = Array(self) + + // Build the list of time points up front. timePoints[i] is the date at + // which the cumulative effect through that time is recorded. + // increments[i] = effect contribution during (timePoints[i-1], timePoints[i]]; + // increments[0] = 0 (base case — no doses applied yet at start). + var timePoints: [Date] = [] + do { + var d = start + while d <= end { + timePoints.append(d) + d = d.addingTimeInterval(delta) + } + } + let n = timePoints.count + guard n > 1 else { + return timePoints.map { GlucoseEffect(startDate: $0, quantity: LoopQuantity(unit: unit, doubleValue: 0)) } + } - var value: Double = 0 - repeat { - // Sum effects over doses - value = reduce(value) { (value, dose) -> Double in - guard date != lastDate else { - return 0 - } - - // Sum effects over pertinent ISF timeline segments + // Parallelize the per-step increments across CPU cores. Each step's + // increment depends only on its own (lastDate, date) interval — there's + // no cross-step dependency until the final cumsum. + var increments = [Double](repeating: 0, count: n) + + // Reduce loop body to a closure-free static-like body to keep + // capture/Sendable surface minimal. concurrentPerform's closure isn't + // @Sendable, so this is tolerated by the compiler. + increments.withUnsafeMutableBufferPointer { incBuf in + DispatchQueue.concurrentPerform(iterations: n - 1) { idx in + // idx in 0.. `date` (after: true) + /// or `key` >= `date` (after: false), using binary search. + /// Assumes the array is sorted ascending by `key`. + func partition(index date: K, key: KeyPath, after: Bool) -> Int { + var lo = 0, hi = count + while lo < hi { + let mid = (lo + hi) / 2 + let k = self[mid][keyPath: key] + if after ? k <= date : k < date { lo = mid + 1 } else { hi = mid } + } + return lo + } +} + +// MARK: - PrecomputedInsulinInput + +/// Pre-annotated insulin data for use in multi-step prediction sweeps. +/// +/// **Typical usage — ISF sweep:** +/// ```swift +/// // 1. Annotate once (ISF-independent, reused across all multipliers) +/// let base = PrecomputedInsulinInput.annotate(doses: doses, basal: basal) +/// +/// // 2. For each ISF value: compute effects once, sweep all time steps +/// for multiplier in isfMultipliers { +/// let input = base.withEffects(sensitivity: scale(sensitivity, by: multiplier), +/// from: sweepStart, to: sweepEnd + activityDuration) +/// for t in sweepSteps { +/// let prediction = LoopAlgorithm.generatePrediction( +/// start: t, glucoseHistory: cgm[t], precomputedInsulin: input, ...) +/// } +/// } +/// ``` +/// +/// **Note on `Sendable`:** Not conformed because `BasalRelativeDose` stores +/// `any InsulinModel`, a non-Sendable existential. Sweeps run on a single +/// actor so this is not limiting in practice. +public struct PrecomputedInsulinInput { + + // MARK: - Stored properties + + /// Doses annotated against the scheduled basal timeline. + /// + /// ISF-independent — build once with `annotate(doses:basal:)` and reuse + /// across every ISF multiplier in a sweep. + public var annotatedDoses: [BasalRelativeDose] + + /// Pre-computed glucose-effect timeline for `annotatedDoses` at a + /// specific ISF schedule. + /// + /// When non-nil, `generatePrediction` uses this directly instead of + /// calling `glucoseEffects(insulinSensitivityHistory:from:to:)`. + /// + /// **ISF sweeps:** rebuild this once per multiplier using `withEffects(sensitivity:)`. + /// The `annotatedDoses` array is unchanged and does not need to be rebuilt. + /// + /// **Timeline coverage:** must cover + /// `[glucoseHistory.first.startDate, sweepEnd + defaultInsulinActivityDuration]` + /// for all steps in the sweep. Pass a generous `to:` date when calling + /// `withEffects(sensitivity:from:to:)`. + public var insulinEffects: [GlucoseEffect]? + + // MARK: - Init + + public init(annotatedDoses: [BasalRelativeDose], insulinEffects: [GlucoseEffect]? = nil) { + self.annotatedDoses = annotatedDoses + self.insulinEffects = insulinEffects + } +} + +// MARK: - Factory methods + +extension PrecomputedInsulinInput { + + /// **Step 1 of 2 for ISF sweeps.** + /// + /// Annotates a full-window dose list against the basal timeline once. + /// The result can be reused across all ISF multipliers — annotation does + /// not depend on ISF. + /// + /// - Parameters: + /// - doses: All insulin doses for the sweep window, sorted by startDate. + /// - basal: Scheduled basal timeline covering the same window. + /// - Returns: A `PrecomputedInsulinInput` with `insulinEffects == nil`. + /// Call `withEffects(sensitivity:from:to:)` before passing to + /// `generatePrediction`. + public static func annotate( + doses: [DoseType], + basal: [AbsoluteScheduleValue] + ) -> PrecomputedInsulinInput { + PrecomputedInsulinInput(annotatedDoses: doses.annotated(with: basal)) + } + + /// **Step 2 of 2 for ISF sweeps.** + /// + /// Computes the glucose-effect timeline for the already-annotated doses + /// at the given ISF schedule. Call once per ISF multiplier value; then + /// pass the result into every `generatePrediction` call for that multiplier. + /// + /// - Parameters: + /// - sensitivity: The (possibly scaled) ISF timeline for this sweep config. + /// - from: Start of the effect timeline. Defaults to earliest dose start. + /// Should be <= `glucoseHistory.first.startDate` for the first eval step. + /// - to: End of the effect timeline. Should cover + /// `sweepEnd + defaultInsulinActivityDuration` to avoid truncation at + /// the tail of long sweeps. + /// - useMidAbsorptionISF: Use mid-absorption ISF computation. + /// - Returns: A new `PrecomputedInsulinInput` with `insulinEffects` populated. + public func withEffects( + sensitivity: [AbsoluteScheduleValue], + from: Date? = nil, + to: Date? = nil, + useMidAbsorptionISF: Bool = false + ) -> PrecomputedInsulinInput { + let effects: [GlucoseEffect] + if useMidAbsorptionISF { + effects = annotatedDoses.glucoseEffectsMidAbsorptionISF( + insulinSensitivityHistory: sensitivity, + from: from, + to: to + ) + } else { + effects = annotatedDoses.glucoseEffects( + insulinSensitivityHistory: sensitivity, + from: from, + to: to + ) + } + return PrecomputedInsulinInput(annotatedDoses: annotatedDoses, insulinEffects: effects) + } + + /// Returns a copy with `annotatedDoses` sliced to doses that overlap + /// `[from, to]`, and `insulinEffects` unchanged (the full pre-built + /// timeline is always passed through — generatePrediction only reads + /// the entries it needs). + /// + /// Use this per evaluation step to pass only the relevant dose window + /// into `generatePrediction`, matching what the standard path does when + /// it calls `doses.annotated(with: basal)` on the per-step slice. + /// + /// `annotatedDoses` must be sorted by `startDate`. + public func sliced(from: Date, to: Date) -> PrecomputedInsulinInput { + // Keep annotated doses that overlap [from, to]: + // dose.startDate <= to AND dose.endDate > from + // + // annotatedDoses is sorted by startDate, so we can binary-search for + // the upper bound (first startDate > to) and then linear-scan backward + // from there. For the lower bound we use a linear filter on endDate + // since the array is NOT sorted by endDate. + // + // In practice the dose arrays are small (~100-200 entries per 16h + // window) so the linear endDate check is negligible. + let hiIdx = annotatedDoses.partition(index: to, key: \.startDate, after: false) + let slicedDoses = annotatedDoses[0.. from } + return PrecomputedInsulinInput(annotatedDoses: slicedDoses, insulinEffects: insulinEffects) + } + + /// Convenience: annotate and compute effects in one call. + /// + /// Use when running a single config (no ISF sweep). For ISF sweeps, + /// prefer `annotate(doses:basal:)` + `withEffects(sensitivity:from:to:)` + /// so annotation cost is paid only once. + public static func build( + doses: [DoseType], + basal: [AbsoluteScheduleValue], + sensitivity: [AbsoluteScheduleValue]? = nil, + effectsFrom: Date? = nil, + effectsTo: Date? = nil, + useMidAbsorptionISF: Bool = false + ) -> PrecomputedInsulinInput { + let base = annotate(doses: doses, basal: basal) + guard let sensitivity else { return base } + return base.withEffects( + sensitivity: sensitivity, + from: effectsFrom, + to: effectsTo, + useMidAbsorptionISF: useMidAbsorptionISF + ) + } +} diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 2e711a1..fce1746 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -179,7 +179,8 @@ public struct LoopAlgorithm { includingPositiveVelocityAndRC: Bool = true, useMidAbsorptionISF: Bool = false, carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), - gradualTransitionsThreshold: Double? = 40.0 + gradualTransitionsThreshold: Double? = 40.0, + momentumVelocityMaximum: LoopQuantity? = nil ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { var prediction: [PredictedGlucoseValue] = [] @@ -311,7 +312,7 @@ public struct LoopAlgorithm { var useMomentum: Bool = true if algorithmEffectsOptions.contains(.momentum) { let momentumInputData = glucoseHistory.filterDateRange(start.addingTimeInterval(-GlucoseMath.momentumDataInterval), start) - momentumEffects = momentumInputData.linearMomentumEffect() + momentumEffects = momentumInputData.linearMomentumEffect(velocityMaximum: momentumVelocityMaximum) if !includingPositiveVelocityAndRC, let netMomentum = momentumEffects.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { // positive momentum is turned off useMomentum = false @@ -352,6 +353,201 @@ public struct LoopAlgorithm { ) } + /// Generates a forecast using pre-annotated insulin data. + /// + /// This overload is optimised for multi-step historical sweeps where the + /// same dose history is evaluated at many consecutive time points. By + /// accepting a `PrecomputedInsulinInput` the caller can: + /// + /// 1. **Skip `annotated(with: basal)`** — the most expensive per-step + /// operation (~O(doses × basalSegments)). Annotate the full window + /// once with `PrecomputedInsulinInput.build(...)`, then slice + /// `annotatedDoses` to the lookback window for each call. + /// + /// 2. **Skip `glucoseEffects(...)`** — when `precomputedInsulin.insulinEffects` + /// is non-nil the function clips the pre-built effect timeline to the + /// needed range instead of recomputing from scratch. This is only + /// valid when ISF does not change between steps (i.e. you are NOT + /// sweeping ISF multipliers). + /// + /// All other effects (carbs, RC, momentum) are computed normally. + /// + /// - Parameters: + /// - start: The starting time of the glucose prediction. + /// - glucoseHistory: History of glucose values: t-10h to t. + /// - precomputedInsulin: Pre-annotated dose data for this step. Caller + /// must slice `annotatedDoses` to `[t - insulinLookback, t]` (or + /// `[t - lookback, t + 6h]` for future-insulin mode). + /// - carbEntries: History of carb entries. + /// - sensitivity: ISF timeline — still required for carb + RC effects. + /// - carbRatio: Carb ratio timeline. + /// - algorithmEffectsOptions: Which effects to include. + /// - useIntegralRetrospectiveCorrection: Use integral RC. + /// - includingPositiveVelocityAndRC: Include positive velocity/RC. + /// - useMidAbsorptionISF: Use mid-absorption ISF (ignored when + /// `precomputedInsulin.insulinEffects` is non-nil). + /// - carbAbsorptionModel: Carb absorption model. + /// - gradualTransitionsThreshold: RC smoothness gate (default 40 mg/dL). + /// - Returns: A `LoopPrediction` struct. `dosesRelativeToBasal` is + /// populated from `precomputedInsulin.annotatedDoses`. + public static func generatePrediction( + start: Date, + glucoseHistory: [GlucoseType], + precomputedInsulin: PrecomputedInsulinInput, + carbEntries: [CarbType], + sensitivity: [AbsoluteScheduleValue], + carbRatio: [AbsoluteScheduleValue], + algorithmEffectsOptions: AlgorithmEffectsOptions = .all, + useIntegralRetrospectiveCorrection: Bool = false, + includingPositiveVelocityAndRC: Bool = true, + useMidAbsorptionISF: Bool = false, + carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), + gradualTransitionsThreshold: Double? = 40.0, + momentumVelocityMaximum: LoopQuantity? = nil + ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue { + + let dosesRelativeToBasal = precomputedInsulin.annotatedDoses + let activeInsulin = dosesRelativeToBasal.insulinOnBoard(at: start) + + // ── Insulin effects ────────────────────────────────────────────────────── + // Fast path: clip the pre-computed effect timeline to the needed range. + // Slow path: compute from annotated doses (still faster than the full + // overload because annotation is already done). + let insulinEffects: [GlucoseEffect] + if let prebuilt = precomputedInsulin.insulinEffects { + // Use the pre-built effects directly. Extra entries (outside the + // needed range) are harmless; counteractionEffects() and + // predictGlucose() only consume entries within their required window. + // Pass the full array — callers should pre-build with a generous + // `effectsTo` covering the full sweep end + activity duration. + insulinEffects = prebuilt + } else { + var effectsInterval = dosesRelativeToBasal.effectsInterval() ?? DateInterval(start: start, end: start) + if let glucoseStart = glucoseHistory.first?.startDate, glucoseStart < effectsInterval.start { + effectsInterval = effectsInterval.extendedToInclude(glucoseStart) + } + if let glucoseEnd = glucoseHistory.last?.endDate, glucoseEnd > effectsInterval.end { + effectsInterval = effectsInterval.extendedToInclude(glucoseEnd) + } + if useMidAbsorptionISF { + insulinEffects = dosesRelativeToBasal.glucoseEffectsMidAbsorptionISF( + insulinSensitivityHistory: sensitivity, + from: effectsInterval.start, + to: effectsInterval.end + ) + } else { + insulinEffects = dosesRelativeToBasal.glucoseEffects( + insulinSensitivityHistory: sensitivity, + from: effectsInterval.start, + to: effectsInterval.end + ) + } + } + + // ── ICE, carbs, RC, momentum — identical to the standard overload ──────── + let insulinCounteractionEffects = glucoseHistory.counteractionEffects(to: insulinEffects) + + let carbStatus = carbEntries.map( + to: insulinCounteractionEffects, + carbRatio: carbRatio, + insulinSensitivity: sensitivity + ) + let carbEffects = carbStatus.dynamicGlucoseEffects( + from: start.addingTimeInterval(-IntegralRetrospectiveCorrection.retrospectionInterval), + carbRatios: carbRatio, + insulinSensitivities: sensitivity, + absorptionModel: carbAbsorptionModel + ) + let activeCarbs = carbStatus.dynamicCarbsOnBoard(at: start, absorptionModel: carbAbsorptionModel) + + let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects) + let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies + .combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * 1.01) + + let rc: RetrospectiveCorrection = useIntegralRetrospectiveCorrection + ? IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + : StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + + var prediction: [PredictedGlucoseValue] = [] + var retrospectiveCorrectionEffects: [GlucoseEffect] = [] + var momentumEffects: [GlucoseEffect] = [] + var totalRetrospectiveCorrectionEffect: LoopQuantity? + + if let latestGlucose = glucoseHistory.last { + retrospectiveCorrectionEffects = rc.computeEffect( + startingAt: latestGlucose, + retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, + recencyInterval: TimeInterval(minutes: 15), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval + ) + totalRetrospectiveCorrectionEffect = rc.totalGlucoseCorrectionEffect + + var effects = [[GlucoseEffect]]() + if algorithmEffectsOptions.contains(.carbs) { effects.append(carbEffects) } + if algorithmEffectsOptions.contains(.insulin) { effects.append(insulinEffects) } + + if algorithmEffectsOptions.contains(.retrospection) { + var useRC = true + let rcTransitionData = glucoseHistory.filterDateRange( + start.addingTimeInterval(-LoopMath.retrospectiveCorrectionGroupingInterval), + start + ) + if !rcTransitionData.hasGradualTransitions(gradualTransitionThreshold: gradualTransitionsThreshold ?? 40.0) { + useRC = false + } + if !includingPositiveVelocityAndRC, + let netRC = retrospectiveCorrectionEffects.netEffect(), + netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + useRC = false + } + if useRC { effects.append(retrospectiveCorrectionEffects) } + } + + var useMomentum = true + if algorithmEffectsOptions.contains(.momentum) { + let momentumInputData = glucoseHistory.filterDateRange( + start.addingTimeInterval(-GlucoseMath.momentumDataInterval), start + ) + momentumEffects = momentumInputData.linearMomentumEffect(velocityMaximum: momentumVelocityMaximum) + if !includingPositiveVelocityAndRC, + let netMomentum = momentumEffects.netEffect(), + netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + useMomentum = false + } + } else { + useMomentum = false + } + + prediction = LoopMath.predictGlucose( + startingAt: latestGlucose, + momentum: useMomentum ? momentumEffects : [], + effects: effects + ) + + let finalDate = start.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration) + if let last = prediction.last, last.startDate < finalDate { + prediction.append(PredictedGlucoseValue(startDate: finalDate, quantity: last.quantity)) + } + } + + return LoopPrediction( + glucose: prediction, + effects: LoopAlgorithmEffects( + insulin: insulinEffects, + carbs: carbEffects, + carbStatus: carbStatus, + retrospectiveCorrection: retrospectiveCorrectionEffects, + momentum: momentumEffects, + insulinCounteraction: insulinCounteractionEffects, + retrospectiveGlucoseDiscrepancies: retrospectiveGlucoseDiscrepanciesSummed, + totalRetrospectiveCorrectionEffect: totalRetrospectiveCorrectionEffect + ), + dosesRelativeToBasal: dosesRelativeToBasal, + activeInsulin: activeInsulin, + activeCarbs: activeCarbs + ) + } + // Helper to generate prediction with LoopPredictionInput struct public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { @@ -371,7 +567,7 @@ public struct LoopAlgorithm { } // Computes an amount of insulin to correct the given prediction - static func insulinCorrection( + public static func insulinCorrection( prediction: [PredictedGlucoseValue], at deliveryDate: Date, target: GlucoseRangeTimeline, @@ -388,7 +584,7 @@ public struct LoopAlgorithm { } // Computes a 30 minute temp basal dose to correct the given prediction - static func recommendTempBasal( + public static func recommendTempBasal( for correction: InsulinCorrection, neutralBasalRate: Double, activeInsulin: Double, @@ -420,7 +616,7 @@ public struct LoopAlgorithm { } // Computes a bolus or low-temp basal dose to correct the given prediction - static func recommendAutomaticDose( + public static func recommendAutomaticDose( for correction: InsulinCorrection, applicationFactor: Double, neutralBasalRate: Double, diff --git a/Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift b/Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift new file mode 100644 index 0000000..25f6096 --- /dev/null +++ b/Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift @@ -0,0 +1,215 @@ +// PrecomputedInsulinInputTests.swift +// +// Verifies that generatePrediction(precomputedInsulin:) produces bit-identical +// output to the standard overload, and that the pre-built effects fast-path +// also matches. + +import XCTest +@testable import LoopAlgorithm + +final class PrecomputedInsulinInputTests: XCTestCase { + + // MARK: - Fixture loading (mirrors LoopAlgorithmTests.swift) + + typealias Input = LoopPredictionInput + + private func loadInput() throws -> Input { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = Bundle.module.url( + forResource: "live_capture_input", + withExtension: "json", + subdirectory: "Fixtures" + )! + return try decoder.decode(Input.self, from: Data(contentsOf: url)) + } + + // MARK: - Test: annotated-only fast path matches standard output + + func testPrecomputedAnnotationMatchesStandard() throws { + let input = try loadInput() + let start = input.glucoseHistory.last!.startDate + + // Standard prediction (full annotation inside generatePrediction) + let standard = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + // Pre-annotate once (ISF-independent); no effects → standard inner glucoseEffects path + let precomputed = PrecomputedInsulinInput.annotate(doses: input.doses, basal: input.basal) + + let fast = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + precomputedInsulin: precomputed, + carbEntries: input.carbEntries, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + XCTAssertEqual(standard.glucose.count, fast.glucose.count, + "Prediction point count should match") + for (s, f) in zip(standard.glucose, fast.glucose) { + XCTAssertEqual(s.startDate, f.startDate) + XCTAssertEqual( + s.quantity.doubleValue(for: .milligramsPerDeciliter), + f.quantity.doubleValue(for: .milligramsPerDeciliter), + accuracy: 0.001, + "Mismatch at \(s.startDate)" + ) + } + XCTAssertEqual(standard.activeInsulin ?? 0, fast.activeInsulin ?? 0, accuracy: 0.001) + } + + // MARK: - Test: pre-built effects path compiles and returns a prediction + // + // Bit-identical output is NOT guaranteed (see PrecomputedInsulinInput.insulinEffects + // for the timeline-snapping caveat). This test only verifies that the fast + // path runs without crashing and returns the expected number of points. + + func testPrebuiltEffectsFastPathRunsWithoutError() throws { + let input = try loadInput() + let start = input.glucoseHistory.last!.startDate + + let standard = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + // ISF-sweep pattern: annotate once, compute effects per ISF value + let precomputed = PrecomputedInsulinInput + .annotate(doses: input.doses, basal: input.basal) + .withEffects(sensitivity: input.sensitivity) + + let fast = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + precomputedInsulin: precomputed, + carbEntries: input.carbEntries, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + XCTAssertEqual(standard.glucose.count, fast.glucose.count, + "Pre-built effects path should return the same number of prediction points") + XCTAssertNotNil(fast.activeInsulin) + } + + // MARK: - Test: sliced annotated doses round-trip + + func testSlicedAnnotatedDosesMatchStandard() throws { + let input = try loadInput() + let start = input.glucoseHistory.last!.startDate + + let standard = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + // Simulate EvalCore: build once, then pass the (unsliced) annotated set + let sliced = PrecomputedInsulinInput.annotate(doses: input.doses, basal: input.basal) + + let fromSlice = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + precomputedInsulin: sliced, + carbEntries: input.carbEntries, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + for (s, f) in zip(standard.glucose, fromSlice.glucose) { + XCTAssertEqual( + s.quantity.doubleValue(for: .milligramsPerDeciliter), + f.quantity.doubleValue(for: .milligramsPerDeciliter), + accuracy: 0.001 + ) + } + } + + // MARK: - Test: ISF sweep pattern — annotate once, withEffects per multiplier + + func testISFSweepPattern() throws { + let input = try loadInput() + let start = input.glucoseHistory.last!.startDate + + // Annotate ONCE — shared across all ISF values + let base = PrecomputedInsulinInput.annotate(doses: input.doses, basal: input.basal) + + let multipliers: [Double] = [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3] + + for multiplier in multipliers { + // Scale ISF — O(n_isf_segments), negligible. + // Preserve whatever unit the fixture uses by scaling the raw double + // and re-wrapping in the same unit. + let scaledSensitivity = input.sensitivity.map { entry -> AbsoluteScheduleValue in + let unit = entry.value.unit + let scaled = entry.value.doubleValue(for: unit) * multiplier + return AbsoluteScheduleValue( + startDate: entry.startDate, + endDate: entry.endDate, + value: LoopQuantity(unit: unit, doubleValue: scaled) + ) + } + + // Compute effects once for this ISF value — O(D × T), not per-step + let precomputed = base.withEffects(sensitivity: scaledSensitivity) + XCTAssertNotNil(precomputed.insulinEffects, "withEffects should populate insulinEffects") + + // Verify it produces the same result as the standard path with the same scaled ISF + let standard = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: scaledSensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + let fast = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + precomputedInsulin: precomputed, + carbEntries: input.carbEntries, + sensitivity: scaledSensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + XCTAssertEqual(standard.glucose.count, fast.glucose.count, + "Count mismatch at ISF multiplier \(multiplier)") + for (s, f) in zip(standard.glucose, fast.glucose) { + XCTAssertEqual( + s.quantity.doubleValue(for: .milligramsPerDeciliter), + f.quantity.doubleValue(for: .milligramsPerDeciliter), + accuracy: 0.001, + "ISF \(multiplier)×: mismatch at \(s.startDate)" + ) + } + } + } +} From 8faf4961cc8bf3b8eac8a8b21281e85e999d3799 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 24 May 2026 18:36:55 -0500 Subject: [PATCH 13/15] Assert filterDateRange input is sorted ascending The binary-search filterDateRange overload assumes its collection is sorted ascending by startDate, but returns silently-wrong results otherwise. Add a debug-only assert enforcing that contract (compiled out of release builds, so no runtime cost). This immediately surfaced an existing violation: testGlucoseEffectFromHistory built a `basal` schedule with segments out of order (and one with endDate before startDate, plus overlaps). Replace it with a well-formed sorted, contiguous, non-overlapping schedule, and regenerate the expected-effect fixture to match the corrected schedule. --- Sources/LoopAlgorithm/SampleValue.swift | 7 + .../Fixtures/effect_from_history_output.json | 197 +++++++++--------- .../LoopAlgorithmTests/InsulinMathTests.swift | 17 +- 3 files changed, 114 insertions(+), 107 deletions(-) diff --git a/Sources/LoopAlgorithm/SampleValue.swift b/Sources/LoopAlgorithm/SampleValue.swift index fb978b2..28929e2 100644 --- a/Sources/LoopAlgorithm/SampleValue.swift +++ b/Sources/LoopAlgorithm/SampleValue.swift @@ -111,6 +111,13 @@ public extension Sequence where Element: TimelineValue { public extension RandomAccessCollection where Element: TimelineValue, Index == Int { func filterDateRange(_ startDate: Date?, _ endDate: Date?) -> [Element] { guard !isEmpty else { return [] } + // This binary-search filter is only correct when the elements are sorted + // ascending by startDate. Catch contract violations in debug builds; the + // check is compiled out of release builds, so there is no runtime cost. + assert( + zip(self, dropFirst()).allSatisfy { $0.startDate <= $1.startDate }, + "filterDateRange requires elements sorted ascending by startDate" + ) // Lower bound: first index where element.endDate >= startDate var lo = startIndex if let startDate { diff --git a/Tests/LoopAlgorithmTests/Fixtures/effect_from_history_output.json b/Tests/LoopAlgorithmTests/Fixtures/effect_from_history_output.json index 26aabee..9f5feaa 100644 --- a/Tests/LoopAlgorithmTests/Fixtures/effect_from_history_output.json +++ b/Tests/LoopAlgorithmTests/Fixtures/effect_from_history_output.json @@ -155,489 +155,488 @@ "unit" : "mg/dL" }, { - "amount" : -52.264027741079879, + "amount" : -52.2640280422766, "date" : "2015-10-15T20:40:00", "unit" : "mg/dL" }, { - "amount" : -61.76646699369725, + "amount" : -61.7677478059354, "date" : "2015-10-15T20:45:00", "unit" : "mg/dL" }, { - "amount" : -71.680918819657265, + "amount" : -71.687637842502099, "date" : "2015-10-15T20:50:00", "unit" : "mg/dL" }, { - "amount" : -81.900465930344126, + "amount" : -81.919234933234577, "date" : "2015-10-15T20:55:00", "unit" : "mg/dL" }, { - "amount" : -92.328165568362309, + "amount" : -92.367908821205617, "date" : "2015-10-15T21:00:00", "unit" : "mg/dL" }, { - "amount" : -102.87748849853307, + "amount" : -102.94913050298391, "date" : "2015-10-15T21:05:00", "unit" : "mg/dL" }, { - "amount" : -113.47146208796229, + "amount" : -113.58764235562131, "date" : "2015-10-15T21:10:00", "unit" : "mg/dL" }, { - "amount" : -124.06101315660501, + "amount" : -124.23414028175458, "date" : "2015-10-15T21:15:00", "unit" : "mg/dL" }, { - "amount" : -134.62009905018957, + "amount" : -134.86086625464586, "date" : "2015-10-15T21:20:00", "unit" : "mg/dL" }, { - "amount" : -145.1217841778539, + "amount" : -145.43932554945283, "date" : "2015-10-15T21:25:00", "unit" : "mg/dL" }, { - "amount" : -155.53243783066668, + "amount" : -155.93447255166663, "date" : "2015-10-15T21:30:00", "unit" : "mg/dL" }, { - "amount" : -165.81576158842529, + "amount" : -166.30872699287778, "date" : "2015-10-15T21:35:00", "unit" : "mg/dL" }, { - "amount" : -175.94014779129205, + "amount" : -176.52932203535278, "date" : "2015-10-15T21:40:00", "unit" : "mg/dL" }, { - "amount" : -185.87826522474734, + "amount" : -186.56788030227949, "date" : "2015-10-15T21:45:00", "unit" : "mg/dL" }, { - "amount" : -195.96262752949394, + "amount" : -196.75597331694593, "date" : "2015-10-15T21:50:00", "unit" : "mg/dL" }, { - "amount" : -206.52914016717139, + "amount" : -207.42866013639221, "date" : "2015-10-15T21:55:00", "unit" : "mg/dL" }, { - "amount" : -217.47340056280586, + "amount" : -218.48077975800794, "date" : "2015-10-15T22:00:00", "unit" : "mg/dL" }, { - "amount" : -228.70047046491561, + "amount" : -229.81671630023288, "date" : "2015-10-15T22:05:00", "unit" : "mg/dL" }, { - "amount" : -240.12529726332727, + "amount" : -241.35081366017906, "date" : "2015-10-15T22:10:00", "unit" : "mg/dL" }, { - "amount" : -251.690438990204, + "amount" : -253.02509433881008, "date" : "2015-10-15T22:15:00", "unit" : "mg/dL" }, { - "amount" : -263.36153925216848, + "amount" : -264.80472864892096, "date" : "2015-10-15T22:20:00", "unit" : "mg/dL" }, { - "amount" : -275.10715080098271, + "amount" : -276.65785298282833, "date" : "2015-10-15T22:25:00", "unit" : "mg/dL" }, { - "amount" : -286.89908382548953, + "amount" : -288.55591319388481, "date" : "2015-10-15T22:30:00", "unit" : "mg/dL" }, { - "amount" : -298.71214261916953, + "amount" : -300.47339671941916, "date" : "2015-10-15T22:35:00", "unit" : "mg/dL" }, { - "amount" : -310.52328534383491, + "amount" : -312.38698814059592, "date" : "2015-10-15T22:40:00", "unit" : "mg/dL" }, { - "amount" : -322.29421713089812, + "amount" : -324.25815839722361, "date" : "2015-10-15T22:45:00", "unit" : "mg/dL" }, { - "amount" : -333.97464519098088, + "amount" : -336.03641630638566, "date" : "2015-10-15T22:50:00", "unit" : "mg/dL" }, { - "amount" : -345.52048542110253, + "amount" : -347.67751185366495, "date" : "2015-10-15T22:55:00", "unit" : "mg/dL" }, { - "amount" : -356.8933206733189, + "amount" : -359.14289140170098, "date" : "2015-10-15T23:00:00", "unit" : "mg/dL" }, { - "amount" : -368.05989966830873, + "amount" : -370.39919378169293, "date" : "2015-10-15T23:05:00", "unit" : "mg/dL" }, { - "amount" : -378.99167375942488, + "amount" : -381.41778445665733, "date" : "2015-10-15T23:10:00", "unit" : "mg/dL" }, { - "amount" : -389.66436893407285, + "amount" : -392.17432512769443, "date" : "2015-10-15T23:15:00", "unit" : "mg/dL" }, { - "amount" : -400.05759060934326, + "amount" : -402.64837632560784, "date" : "2015-10-15T23:20:00", "unit" : "mg/dL" }, { - "amount" : -410.15445893826853, + "amount" : -412.82303069063533, "date" : "2015-10-15T23:25:00", "unit" : "mg/dL" }, { - "amount" : -419.94127249255814, + "amount" : -422.68457479343311, "date" : "2015-10-15T23:30:00", "unit" : "mg/dL" }, { - "amount" : -429.40719832776898, + "amount" : -432.22217749140992, "date" : "2015-10-15T23:35:00", "unit" : "mg/dL" }, { - "amount" : -438.54398656819569, + "amount" : -441.42760294662321, "date" : "2015-10-15T23:40:00", "unit" : "mg/dL" }, { - "amount" : -447.34570777179891, + "amount" : -450.2949465552303, "date" : "2015-10-15T23:45:00", "unit" : "mg/dL" }, { - "amount" : -455.80851145079703, + "amount" : -458.82039215448776, "date" : "2015-10-15T23:50:00", "unit" : "mg/dL" }, { - "amount" : -463.93040423153343, + "amount" : -467.00198898193264, "date" : "2015-10-15T23:55:00", "unit" : "mg/dL" }, { - "amount" : -471.71104623839892, + "amount" : -474.83944696315064, "date" : "2015-10-16T00:00:00", "unit" : "mg/dL" }, { - "amount" : -479.15156438132186, + "amount" : -482.33394899984762, "date" : "2015-10-16T00:05:00", "unit" : "mg/dL" }, { - "amount" : -486.2543813150387, + "amount" : -489.48797901916589, "date" : "2015-10-16T00:10:00", "unit" : "mg/dL" }, { - "amount" : -493.023058921406, + "amount" : -496.3051646287397, "date" : "2015-10-16T00:15:00", "unit" : "mg/dL" }, { - "amount" : -499.46215486351923, + "amount" : -502.79013291995432, "date" : "2015-10-16T00:20:00", "unit" : "mg/dL" }, { - "amount" : -505.5770310131204, + "amount" : -508.94831821502453, "date" : "2015-10-16T00:25:00", "unit" : "mg/dL" }, { - "amount" : -511.37357039402116, + "amount" : -514.78567839516109, "date" : "2015-10-16T00:30:00", "unit" : "mg/dL" }, { - "amount" : -516.85813478586385, + "amount" : -520.30865194907335, "date" : "2015-10-16T00:35:00", "unit" : "mg/dL" }, { - "amount" : -522.03765726190443, + "amount" : -525.52425001077142, "date" : "2015-10-16T00:40:00", "unit" : "mg/dL" }, { - "amount" : -526.91960305812609, + "amount" : -530.44001677959045, "date" : "2015-10-16T00:45:00", "unit" : "mg/dL" }, { - "amount" : -531.51186924154661, + "amount" : -535.0639287862225, "date" : "2015-10-16T00:50:00", "unit" : "mg/dL" }, { - "amount" : -535.82267812482121, + "amount" : -539.40428794807474, "date" : "2015-10-16T00:55:00", "unit" : "mg/dL" }, { - "amount" : -539.86069160301474, + "amount" : -543.46983558631189, "date" : "2015-10-16T01:00:00", "unit" : "mg/dL" }, { - "amount" : -543.63492920431247, + "amount" : -547.26967019309222, "date" : "2015-10-16T01:05:00", "unit" : "mg/dL" }, { - "amount" : -547.15451907267538, + "amount" : -550.81299816397518, "date" : "2015-10-16T01:10:00", "unit" : "mg/dL" }, { - "amount" : -550.42846838462219, + "amount" : -554.1089039948821, "date" : "2015-10-16T01:15:00", "unit" : "mg/dL" }, { - "amount" : -553.46562056285904, + "amount" : -557.16630730372844, "date" : "2015-10-16T01:20:00", "unit" : "mg/dL" }, { - "amount" : -556.27482496187201, + "amount" : -559.994132349437, "date" : "2015-10-16T01:25:00", "unit" : "mg/dL" }, { - "amount" : -558.86493785268158, + "amount" : -562.60130887330024, "date" : "2015-10-16T01:30:00", "unit" : "mg/dL" }, { - "amount" : -561.24477785349723, + "amount" : -564.99672740737037, "date" : "2015-10-16T01:35:00", "unit" : "mg/dL" }, { - "amount" : -563.43359411530798, + "amount" : -567.19970735700224, "date" : "2015-10-16T01:40:00", "unit" : "mg/dL" }, { - "amount" : -565.45900786932339, + "amount" : -569.23793846270314, "date" : "2015-10-16T01:45:00", "unit" : "mg/dL" }, { - "amount" : -567.32945007449439, + "amount" : -571.11991837788901, "date" : "2015-10-16T01:50:00", "unit" : "mg/dL" }, { - "amount" : -569.05270789672886, + "amount" : -572.85349909266051, "date" : "2015-10-16T01:55:00", "unit" : "mg/dL" }, { - "amount" : -570.6363878436191, + "amount" : -574.44635002602649, "date" : "2015-10-16T02:00:00", "unit" : "mg/dL" }, { - "amount" : -572.0877702437632, + "amount" : -575.9058124743799, "date" : "2015-10-16T02:05:00", "unit" : "mg/dL" }, { - "amount" : -573.41396516542841, + "amount" : -577.23905550979885, "date" : "2015-10-16T02:10:00", "unit" : "mg/dL" }, { - "amount" : -574.6219981579759, + "amount" : -578.45316171050558, "date" : "2015-10-16T02:15:00", "unit" : "mg/dL" }, { - "amount" : -575.7275007450005, + "amount" : -579.56381765143828, "date" : "2015-10-16T02:20:00", "unit" : "mg/dL" }, { - "amount" : -576.74580130146126, + "amount" : -580.5864047863015, "date" : "2015-10-16T02:25:00", "unit" : "mg/dL" }, { - "amount" : -577.68106518094305, + "amount" : -581.52513958638849, "date" : "2015-10-16T02:30:00", "unit" : "mg/dL" }, { - "amount" : -578.53713343337517, + "amount" : -582.38391227573231, "date" : "2015-10-16T02:35:00", "unit" : "mg/dL" }, { - "amount" : -579.31768623633491, + "amount" : -583.16645029377958, "date" : "2015-10-16T02:40:00", "unit" : "mg/dL" }, { - "amount" : -580.02630527022711, + "amount" : -583.87641862524356, "date" : "2015-10-16T02:45:00", "unit" : "mg/dL" }, { - "amount" : -580.66622392121678, + "amount" : -584.51718221441229, "date" : "2015-10-16T02:50:00", "unit" : "mg/dL" }, { - "amount" : -581.24043672229402, + "amount" : -585.09185723978885, "date" : "2015-10-16T02:55:00", "unit" : "mg/dL" }, { - "amount" : -581.7518346710234, + "amount" : -585.60345407259592, "date" : "2015-10-16T03:00:00", "unit" : "mg/dL" }, { - "amount" : -582.20324063049941, + "amount" : -586.05490729716598, "date" : "2015-10-16T03:05:00", "unit" : "mg/dL" }, { - "amount" : -582.59740660912655, + "amount" : -586.44907327579324, "date" : "2015-10-16T03:10:00", "unit" : "mg/dL" }, { - "amount" : -582.93757898138779, + "amount" : -586.78924564805448, "date" : "2015-10-16T03:15:00", "unit" : "mg/dL" }, { - "amount" : -583.22741489495911, + "amount" : -587.0790815616258, "date" : "2015-10-16T03:20:00", "unit" : "mg/dL" }, { - "amount" : -583.47030192860075, + "amount" : -587.32196859526744, "date" : "2015-10-16T03:25:00", "unit" : "mg/dL" }, { - "amount" : -583.66919134980412, + "amount" : -587.52085801647081, "date" : "2015-10-16T03:30:00", "unit" : "mg/dL" }, { - "amount" : -583.8267089590006, + "amount" : -587.67837562566717, "date" : "2015-10-16T03:35:00", "unit" : "mg/dL" }, { - "amount" : -583.94537949189805, + "amount" : -587.79704615856474, "date" : "2015-10-16T03:40:00", "unit" : "mg/dL" }, { - "amount" : -584.02762789860844, + "amount" : -587.87929456527513, "date" : "2015-10-16T03:45:00", "unit" : "mg/dL" }, { - "amount" : -584.08632593727748, + "amount" : -587.93799260394417, "date" : "2015-10-16T03:50:00", "unit" : "mg/dL" }, { - "amount" : -584.13502294212412, + "amount" : -587.98668960879081, "date" : "2015-10-16T03:55:00", "unit" : "mg/dL" }, { - "amount" : -584.17434487516277, + "amount" : -588.02601154182946, "date" : "2015-10-16T04:00:00", "unit" : "mg/dL" }, { - "amount" : -584.20485800454787, + "amount" : -588.05652467121456, "date" : "2015-10-16T04:05:00", "unit" : "mg/dL" }, { - "amount" : -584.22710644921244, + "amount" : -588.07877311587902, "date" : "2015-10-16T04:10:00", "unit" : "mg/dL" }, { - "amount" : -584.24216197536089, + "amount" : -588.09382864202757, "date" : "2015-10-16T04:15:00", "unit" : "mg/dL" }, { - "amount" : -584.25155129938207, + "amount" : -588.10321796604876, "date" : "2015-10-16T04:20:00", "unit" : "mg/dL" }, { - "amount" : -584.2566906636182, + "amount" : -588.10835733028489, "date" : "2015-10-16T04:25:00", "unit" : "mg/dL" }, { - "amount" : -584.2589075785753, + "amount" : -588.11057424524188, "date" : "2015-10-16T04:30:00", "unit" : "mg/dL" }, { - "amount" : -584.2594444444444, + "amount" : -588.11111111111109, "date" : "2015-10-16T04:35:00", "unit" : "mg/dL" }, { - "amount" : -584.2594444444444, + "amount" : -588.11111111111109, "date" : "2015-10-16T04:40:00", "unit" : "mg/dL" } -] - +] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/InsulinMathTests.swift b/Tests/LoopAlgorithmTests/InsulinMathTests.swift index 04b74c2..bc5b47a 100644 --- a/Tests/LoopAlgorithmTests/InsulinMathTests.swift +++ b/Tests/LoopAlgorithmTests/InsulinMathTests.swift @@ -237,17 +237,10 @@ class InsulinMathTests: XCTestCase { ) ] + // A well-formed basal schedule: sorted ascending, contiguous, and non-overlapping. let basal = [ AbsoluteScheduleValue( startDate: startDate, - endDate: dateFormatter.date(from: "2015-10-15T20:30:00")!, - value: 1.0), - AbsoluteScheduleValue( - startDate: dateFormatter.date(from: "2015-10-15T20:30:00")!, - endDate: dateFormatter.date(from: "2015-10-15T21:00:00")!, - value: 0.8), - AbsoluteScheduleValue( - startDate: dateFormatter.date(from: "2015-10-15T21:00:00")!, endDate: dateFormatter.date(from: "2015-10-15T18:30:00")!, value: 1.0), AbsoluteScheduleValue( @@ -256,6 +249,14 @@ class InsulinMathTests: XCTestCase { value: 0.8), AbsoluteScheduleValue( startDate: dateFormatter.date(from: "2015-10-15T19:00:00")!, + endDate: dateFormatter.date(from: "2015-10-15T20:30:00")!, + value: 1.0), + AbsoluteScheduleValue( + startDate: dateFormatter.date(from: "2015-10-15T20:30:00")!, + endDate: dateFormatter.date(from: "2015-10-15T21:00:00")!, + value: 0.8), + AbsoluteScheduleValue( + startDate: dateFormatter.date(from: "2015-10-15T21:00:00")!, endDate: endDate, value: 1.0), ] From aeffea803998488059ae1e741200f2b291fc94c3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 14 Jul 2026 19:36:24 -0500 Subject: [PATCH 14/15] Fix delta-scale IOB ripple for basal segments longer than delta (#35) 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") + } +} From 925e6723a0a5b2bbcb227c74479b5e99b5ee798a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 27 Jul 2026 11:21:26 -0500 Subject: [PATCH 15/15] Bound IntegralRC correction rate to a settings-free physiological ceiling (#37) 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. Co-authored-by: LoopKit Developer --- .../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) + } }