From 772ed07c83c7cf60ebfc310aff0a090013440196 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 19 May 2026 12:21:42 -0500 Subject: [PATCH] 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. --- Sources/LoopAlgorithm/LoopMath.swift | 22 ++- .../carbs_with_isf_change_recommendation.json | 4 +- .../live_capture_predicted_glucose.json | 154 +++++++++--------- .../LoopAlgorithmTests.swift | 18 +- Tests/LoopAlgorithmTests/LoopMathTests.swift | 48 ++++++ ...IntegralRetrospectiveCorrectionTests.swift | 2 +- 6 files changed, 151 insertions(+), 97 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..47b8dd9 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() { 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")!) } }