Skip to content

Commit 84a099f

Browse files
authored
Fix issue with mid-absorption isf calculation (tidepool-org#19)
1 parent 2eeb7eb commit 84a099f

5 files changed

Lines changed: 296 additions & 2 deletions

File tree

Sources/LoopAlgorithm/AlgorithmInputFixture.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,62 @@ extension AlgorithmInputFixture: Codable {
197197
}
198198
}
199199

200+
extension AlgorithmInputFixture {
201+
static public func printFixture(_ input: any AlgorithmInput) {
202+
let fixture = AlgorithmInputFixture(
203+
predictionStart: input.predictionStart,
204+
glucoseHistory: input.glucoseHistory.map(\.asFixtureGlucoseSample),
205+
doses: input.doses.map(\.asFixtureInsulinDose),
206+
carbEntries: input.carbEntries.map(\.asFixtureCarbEntry),
207+
basal: input.basal,
208+
sensitivity: input.sensitivity,
209+
carbRatio: input.carbRatio,
210+
target: input.target,
211+
suspendThreshold: input.suspendThreshold,
212+
maxBolus: input.maxBolus,
213+
maxBasalRate: input.maxBasalRate,
214+
useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection,
215+
useMidAbsorptionISF: input.useMidAbsorptionISF,
216+
includePositiveVelocityAndRC: input.includePositiveVelocityAndRC,
217+
carbAbsorptionModel: input.carbAbsorptionModel,
218+
recommendationInsulinType: .novolog,
219+
recommendationType: input.recommendationType,
220+
automaticBolusApplicationFactor: input.automaticBolusApplicationFactor
221+
)
222+
223+
let encoder = JSONEncoder()
224+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
225+
encoder.dateEncodingStrategy = .iso8601
226+
227+
do {
228+
let encoded = try encoder.encode(fixture)
229+
print(String(data: encoded , encoding: .utf8)!)
230+
} catch {
231+
print("Error encoding fixture: \(error)")
232+
}
233+
}
234+
}
235+
236+
extension GlucoseSampleValue {
237+
var asFixtureGlucoseSample: FixtureGlucoseSample {
238+
return .init(startDate: startDate, quantity: quantity)
239+
}
240+
}
241+
242+
extension InsulinDose {
243+
var asFixtureInsulinDose: FixtureInsulinDose {
244+
return .init(deliveryType: deliveryType, startDate: startDate, endDate: endDate, volume: volume)
245+
}
246+
}
247+
248+
extension CarbEntry {
249+
var asFixtureCarbEntry: FixtureCarbEntry {
250+
return FixtureCarbEntry(
251+
absorptionTime: absorptionTime,
252+
startDate: startDate,
253+
quantity: quantity,
254+
foodType: nil
255+
)
256+
}
257+
}
258+

Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ public struct FixtureCarbEntry: CarbEntry {
1212
public var startDate: Date
1313
public var quantity: LoopQuantity
1414
public var foodType: String?
15+
16+
public init(
17+
absorptionTime: TimeInterval? = nil,
18+
startDate: Date,
19+
quantity: LoopQuantity,
20+
foodType: String? = nil
21+
) {
22+
self.absorptionTime = absorptionTime
23+
self.startDate = startDate
24+
self.quantity = quantity
25+
self.foodType = foodType
26+
}
1527
}
1628

1729
extension FixtureCarbEntry: Codable {

Sources/LoopAlgorithm/Insulin/DoseMath.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ extension Array where Element: GlucoseValue {
232232
let end = Swift.min(prediction.startDate, segment.endDate).timeIntervalSince(date)
233233
let percentEffected = model.percentEffectRemaining(at: start) - model.percentEffectRemaining(at: end)
234234
isfEnd = end
235-
return percentEffected * segment.value.doubleValue(for: unit)
235+
return partialResult + percentEffected * segment.value.doubleValue(for: unit)
236236
}
237237

238238
guard let isfEnd, isfEnd >= prediction.startDate.timeIntervalSince(date) else {
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
{
2+
"automaticBolusApplicationFactor" : 0.4,
3+
"basal" : [
4+
{
5+
"endDate" : "2025-07-29T05:00:00Z",
6+
"startDate" : "2025-07-28T20:00:00Z",
7+
"value" : 0.85
8+
},
9+
{
10+
"endDate" : "2025-07-29T14:49:56Z",
11+
"startDate" : "2025-07-29T05:00:00Z",
12+
"value" : 1
13+
},
14+
{
15+
"endDate" : "2025-07-29T16:12:36Z",
16+
"startDate" : "2025-07-29T14:49:56Z",
17+
"value" : 1
18+
},
19+
{
20+
"endDate" : "2025-07-29T16:12:44Z",
21+
"startDate" : "2025-07-29T16:12:36Z",
22+
"value" : 1
23+
},
24+
{
25+
"endDate" : "2025-07-29T16:39:30Z",
26+
"startDate" : "2025-07-29T16:12:44Z",
27+
"value" : 1
28+
}
29+
],
30+
"carbEntries" : [
31+
{
32+
"absorptionTime" : 10800,
33+
"date" : "2025-07-29T16:12:48Z",
34+
"grams" : 20
35+
}
36+
],
37+
"carbRatio" : [
38+
{
39+
"endDate" : "2025-07-29T05:00:00Z",
40+
"startDate" : "2025-07-29T04:11:52Z",
41+
"value" : 10
42+
},
43+
{
44+
"endDate" : "2025-07-29T14:49:56Z",
45+
"startDate" : "2025-07-29T05:00:00Z",
46+
"value" : 10
47+
},
48+
{
49+
"endDate" : "2025-07-29T16:12:36Z",
50+
"startDate" : "2025-07-29T14:49:56Z",
51+
"value" : 10
52+
},
53+
{
54+
"endDate" : "2025-07-29T16:12:44Z",
55+
"startDate" : "2025-07-29T16:12:36Z",
56+
"value" : 10
57+
},
58+
{
59+
"endDate" : "2025-07-29T18:12:44Z",
60+
"startDate" : "2025-07-29T16:12:44Z",
61+
"value" : 10
62+
},
63+
{
64+
"endDate" : "2025-07-29T22:25:00Z",
65+
"startDate" : "2025-07-29T18:12:44Z",
66+
"value" : 10
67+
}
68+
],
69+
"doses" : [
70+
{
71+
"endDate" : "2025-07-29T05:00:00Z",
72+
"startDate" : "2025-07-28T20:00:00Z",
73+
"type" : "basal",
74+
"volume" : 7.65
75+
},
76+
{
77+
"endDate" : "2025-07-29T14:49:01Z",
78+
"startDate" : "2025-07-29T05:00:00Z",
79+
"type" : "basal",
80+
"volume" : 9.8
81+
},
82+
{
83+
"endDate" : "2025-07-29T15:07:36Z",
84+
"startDate" : "2025-07-29T14:49:01Z",
85+
"type" : "basal",
86+
"volume" : 0.3
87+
},
88+
{
89+
"endDate" : "2025-07-29T15:57:58Z",
90+
"startDate" : "2025-07-29T15:07:36Z",
91+
"type" : "basal",
92+
"volume" : 0.85
93+
},
94+
{
95+
"endDate" : "2025-07-29T16:01:05Z",
96+
"startDate" : "2025-07-29T15:57:58Z",
97+
"type" : "basal",
98+
"volume" : 0.05
99+
},
100+
{
101+
"endDate" : "2025-07-29T16:07:02Z",
102+
"startDate" : "2025-07-29T16:01:05Z",
103+
"type" : "basal",
104+
"volume" : 0.1
105+
},
106+
{
107+
"endDate" : "2025-07-29T16:09:30Z",
108+
"startDate" : "2025-07-29T16:07:02Z",
109+
"type" : "basal",
110+
"volume" : 0.04098938193586137
111+
},
112+
{
113+
"endDate" : "2025-07-29T16:39:30Z",
114+
"startDate" : "2025-07-29T16:09:30Z",
115+
"type" : "basal",
116+
"volume" : 0
117+
}
118+
],
119+
"glucoseHistory" : [
120+
{
121+
"date" : "2025-07-29T14:49:01Z",
122+
"value" : 110
123+
},
124+
{
125+
"date" : "2025-07-29T14:54:01Z",
126+
"value" : 112
127+
},
128+
{
129+
"date" : "2025-07-29T14:59:01Z",
130+
"value" : 113
131+
},
132+
{
133+
"date" : "2025-07-29T15:04:37Z",
134+
"value" : 115
135+
},
136+
{
137+
"date" : "2025-07-29T15:07:36Z",
138+
"value" : 116
139+
},
140+
{
141+
"date" : "2025-07-29T15:14:11Z",
142+
"value" : 119
143+
},
144+
{
145+
"date" : "2025-07-29T15:57:58Z",
146+
"value" : 129
147+
},
148+
{
149+
"date" : "2025-07-29T16:01:05Z",
150+
"value" : 129
151+
},
152+
{
153+
"date" : "2025-07-29T16:06:05Z",
154+
"value" : 129
155+
},
156+
{
157+
"date" : "2025-07-29T16:07:02Z",
158+
"value" : 130
159+
},
160+
{
161+
"date" : "2025-07-29T16:09:30Z",
162+
"value" : 130
163+
}
164+
],
165+
"maxBasalRate" : 5,
166+
"maxBolus" : 10,
167+
"predictionStart" : "2025-07-29T16:12:52Z",
168+
"recommendationInsulinType" : "novolog",
169+
"recommendationType" : "manualBolus",
170+
"sensitivity" : [
171+
{
172+
"endDate" : "2025-07-29T16:30:00Z",
173+
"startDate" : "2025-07-28T20:00:00Z",
174+
"value" : 55
175+
},
176+
{
177+
"endDate" : "2025-07-29T22:50:00Z",
178+
"startDate" : "2025-07-29T16:30:00Z",
179+
"value" : 65
180+
}
181+
],
182+
"suspendThreshold" : 75,
183+
"target" : [
184+
{
185+
"endDate" : "2025-07-29T22:25:00Z",
186+
"lowerBound" : 140,
187+
"startDate" : "2025-07-29T16:12:44Z",
188+
"upperBound" : 160
189+
}
190+
],
191+
"useMidAbsorptionISF" : true
192+
}

Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,37 @@ final class LoopAlgorithmTests: XCTestCase {
176176
}
177177
}
178178

179+
func testMealBolusScenario() {
180+
let decoder = JSONDecoder()
181+
decoder.dateDecodingStrategy = .iso8601
182+
183+
let url = Bundle.module.url(forResource: "meal-bolus", withExtension: "json", subdirectory: "Fixtures")!
184+
var input = try! decoder.decode(AlgorithmInputFixture.self, from: try! Data(contentsOf: url))
185+
186+
let output = LoopAlgorithm.run(input: input)
187+
188+
// Should recommend bolus to cover meal
189+
XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 274, accuracy: 0.1)
190+
XCTAssertEqual(output.recommendation!.manual!.amount, 1.9, accuracy: 0.01)
191+
192+
// Now check forecast if bolus recommendation is accepted and delivered.
193+
input.doses.append(
194+
.init(
195+
deliveryType: .bolus,
196+
startDate: input.predictionStart,
197+
endDate: input.predictionStart.addingTimeInterval(30),
198+
volume: output.recommendation!.manual!.amount
199+
)
200+
)
201+
202+
let output2 = LoopAlgorithm.run(input: input)
203+
204+
// 150 mg/dL is the middle of the target range
205+
XCTAssertEqual(output2.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 150, accuracy: 0.1)
206+
207+
}
208+
209+
179210
func testMidAborptionISFFlag() {
180211
let now = ISO8601DateFormatter().date(from: "2024-01-03T00:00:00+0000")!
181212
var input = AlgorithmInputFixture.mock(for: now)
@@ -300,7 +331,7 @@ final class LoopAlgorithmTests: XCTestCase {
300331
// With mid-absorption ISF
301332
input.useMidAbsorptionISF = true
302333
output = LoopAlgorithm.run(input: input)
303-
XCTAssertEqual(1.55, output.recommendation!.manual!.amount, accuracy: 0.01)
334+
XCTAssertEqual(1.41, output.recommendation!.manual!.amount, accuracy: 0.01)
304335
}
305336

306337
func testIncompleteISFTimelineDetected() {

0 commit comments

Comments
 (0)