Skip to content

Commit 8093b57

Browse files
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 <pete@schwamb.net>
1 parent 89dd58a commit 8093b57

8 files changed

Lines changed: 297 additions & 21 deletions

File tree

.circleci/config.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,26 @@ version: 2.1
77
jobs:
88
test:
99
macos:
10-
xcode: 16.0.0
10+
xcode: 26.1.0
11+
resource_class: m4pro.medium
1112
steps:
1213
- checkout
1314
- run:
1415
name: Test
1516
command: |
16-
set -o pipefail && xcodebuild -scheme LoopAlgorithm test -destination "platform=iOS Simulator,name=iPhone 16,OS=latest" | xcpretty
17+
set -o pipefail && xcodebuild -scheme LoopAlgorithm test -destination "platform=iOS Simulator,name=iPhone 17,OS=latest" | xcpretty
1718
- store_test_results:
1819
path: test_output
1920
package:
2021
macos:
21-
xcode: 16.0.0
22+
xcode: 26.1.0
23+
resource_class: m4pro.medium
2224
steps:
2325
- checkout
2426
- run:
2527
name: Build LoopAlgorithmPackage
2628
command: |
27-
set -o pipefail && xcodebuild build -scheme LoopAlgorithm -destination "platform=iOS Simulator,name=iPhone 16,OS=latest" | xcpretty
29+
set -o pipefail && xcodebuild build -scheme LoopAlgorithm -destination "platform=iOS Simulator,name=iPhone 17,OS=latest" | xcpretty
2830
#
2931
# Workflows
3032
#

Sources/LoopAlgorithm/AlgorithmInput.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,5 @@ public protocol AlgorithmInput {
3333
var recommendationInsulinModel: InsulinModel { get }
3434
var recommendationType: DoseRecommendationType { get }
3535
var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4
36+
var gradualTransitionsThreshold: Double? { get }
3637
}
37-
38-

Sources/LoopAlgorithm/AlgorithmInputFixture.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public struct AlgorithmInputFixture: AlgorithmInput {
3434
public var recommendationInsulinType: FixtureInsulinType = .novolog
3535
public var recommendationType: DoseRecommendationType = .automaticBolus
3636
public var automaticBolusApplicationFactor: Double?
37+
public var gradualTransitionsThreshold: Double?
3738

3839
public var recommendationInsulinModel: InsulinModel {
3940
recommendationInsulinType.insulinModel
@@ -71,7 +72,8 @@ public struct AlgorithmInputFixture: AlgorithmInput {
7172
carbAbsorptionModel: CarbAbsorptionModel = .piecewiseLinear,
7273
recommendationInsulinType: FixtureInsulinType,
7374
recommendationType: DoseRecommendationType,
74-
automaticBolusApplicationFactor: Double? = nil
75+
automaticBolusApplicationFactor: Double? = nil,
76+
gradualTransitionsThreshold: Double? = 40.0
7577
) {
7678
self.predictionStart = predictionStart
7779
self.glucoseHistory = glucoseHistory
@@ -92,6 +94,7 @@ public struct AlgorithmInputFixture: AlgorithmInput {
9294
self.recommendationInsulinType = recommendationInsulinType
9395
self.recommendationType = recommendationType
9496
self.automaticBolusApplicationFactor = automaticBolusApplicationFactor
97+
self.gradualTransitionsThreshold = gradualTransitionsThreshold
9598
}
9699
}
97100

@@ -144,6 +147,7 @@ extension AlgorithmInputFixture: Codable {
144147
}
145148

146149
self.automaticBolusApplicationFactor = try container.decodeIfPresent(Double.self, forKey: .automaticBolusApplicationFactor)
150+
self.gradualTransitionsThreshold = try container.decodeIfPresent(Double.self, forKey: .gradualTransitionsThreshold) ?? 40.0
147151

148152
}
149153

@@ -179,6 +183,7 @@ extension AlgorithmInputFixture: Codable {
179183
try container.encode(recommendationInsulinType.rawValue, forKey: .recommendationInsulinType)
180184
try container.encode(recommendationType.rawValue, forKey: .recommendationType)
181185
try container.encode(automaticBolusApplicationFactor, forKey: .automaticBolusApplicationFactor)
186+
try container.encode(gradualTransitionsThreshold, forKey: .gradualTransitionsThreshold)
182187
}
183188

184189
private enum CodingKeys: String, CodingKey {
@@ -200,6 +205,7 @@ extension AlgorithmInputFixture: Codable {
200205
case recommendationInsulinType
201206
case recommendationType
202207
case automaticBolusApplicationFactor
208+
case gradualTransitionsThreshold
203209
}
204210
}
205211

@@ -223,7 +229,8 @@ extension AlgorithmInputFixture {
223229
carbAbsorptionModel: input.carbAbsorptionModel,
224230
recommendationInsulinType: .novolog,
225231
recommendationType: input.recommendationType,
226-
automaticBolusApplicationFactor: input.automaticBolusApplicationFactor
232+
automaticBolusApplicationFactor: input.automaticBolusApplicationFactor,
233+
gradualTransitionsThreshold: input.gradualTransitionsThreshold
227234
)
228235

229236
let encoder = JSONEncoder()
@@ -261,4 +268,3 @@ extension CarbEntry {
261268
)
262269
}
263270
}
264-

Sources/LoopAlgorithm/Glucose/GlucoseMath.swift

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In
6161
/// - Parameters:
6262
/// - interval: The interval between readings, on average, used to determine if we have a contiguous set of values
6363
/// - Returns: True if the samples are continuous
64-
public func isContinuous(within interval: TimeInterval = TimeInterval(5 * 60)) -> Bool {
64+
func isContinuous(within interval: TimeInterval = TimeInterval(minutes: 5)) -> Bool {
6565
if let first = first,
6666
let last = last,
6767
// Ensure that the entries are contiguous
@@ -73,6 +73,34 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In
7373
return false
7474
}
7575

76+
/// Whether the collection has gradual transitions (no large glucose jumps between consecutive readings)
77+
///
78+
/// - Parameters:
79+
/// - gradualTransitionThreshold: Maximum allowed difference between consecutive readings in mg/dL (default 40.0)
80+
/// - Returns: True if all consecutive differences are within the threshold
81+
public func hasGradualTransitions(gradualTransitionThreshold: Double = 40.0) -> Bool {
82+
guard count > 1 else {
83+
return false // A single point could be a spike and should not be used for momentum calculation
84+
}
85+
86+
// Check glucose value continuity (no large transitions)
87+
let unit = LoopUnit.milligramsPerDeciliter
88+
for i in 0..<(count - 1) {
89+
let current = self[self.index(self.startIndex, offsetBy: i)]
90+
let next = self[self.index(self.startIndex, offsetBy: i + 1)]
91+
92+
let currentValue = current.quantity.doubleValue(for: unit)
93+
let nextValue = next.quantity.doubleValue(for: unit)
94+
let difference = abs(nextValue - currentValue)
95+
96+
if difference > gradualTransitionThreshold {
97+
return false
98+
}
99+
}
100+
101+
return true
102+
}
103+
76104
/// Calculates the short-term predicted momentum effect using linear regression
77105
///
78106
/// - Parameters:
@@ -90,7 +118,7 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In
90118

91119
guard
92120
self.count > 2, // Linear regression isn't much use without 3 or more entries.
93-
isContinuous() && !containsCalibrations() && hasSingleProvenance,
121+
hasGradualTransitions() && isContinuous() && !containsCalibrations() && hasSingleProvenance,
94122
let firstSample = self.first,
95123
let lastSample = self.last,
96124
let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([lastSample], duration: duration, delta: delta)

Sources/LoopAlgorithm/LoopAlgorithm.swift

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ public struct LoopAlgorithm {
178178
useIntegralRetrospectiveCorrection: Bool = false,
179179
includingPositiveVelocityAndRC: Bool = true,
180180
useMidAbsorptionISF: Bool = false,
181-
carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption()
181+
carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(),
182+
gradualTransitionsThreshold: Double? = 40.0
182183
) -> LoopPrediction<CarbType> where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose {
183184

184185
var prediction: [PredictedGlucoseValue] = []
@@ -259,8 +260,6 @@ public struct LoopAlgorithm {
259260
rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
260261
}
261262

262-
263-
264263
if let latestGlucose = glucoseHistory.last {
265264
retrospectiveCorrectionEffects = rc.computeEffect(
266265
startingAt: latestGlucose,
@@ -282,9 +281,28 @@ public struct LoopAlgorithm {
282281
}
283282

284283
if algorithmEffectsOptions.contains(.retrospection) {
285-
if !includingPositiveVelocityAndRC, let netRC = retrospectiveCorrectionEffects.netEffect(), netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 {
286-
// positive RC is turned off
287-
} else {
284+
// Check if glucose data is smooth enough for RC
285+
// Use the same input window as retrospective correction
286+
var useRC: Bool = true
287+
288+
// Don't apply RC if glucose has large jumps
289+
let rcTransitionData = glucoseHistory.filterDateRange(
290+
start.addingTimeInterval(-LoopMath.retrospectiveCorrectionGroupingInterval),
291+
start
292+
)
293+
294+
if !rcTransitionData.hasGradualTransitions(gradualTransitionThreshold: gradualTransitionsThreshold ?? 40.0) {
295+
useRC = false
296+
}
297+
298+
// Don't apply positive RC if that setting is disabled
299+
if !includingPositiveVelocityAndRC,
300+
let netRC = retrospectiveCorrectionEffects.netEffect(),
301+
netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 {
302+
useRC = false
303+
}
304+
305+
if useRC {
288306
effects.append(retrospectiveCorrectionEffects)
289307
}
290308
}
@@ -347,7 +365,8 @@ public struct LoopAlgorithm {
347365
carbRatio: input.carbRatio,
348366
algorithmEffectsOptions: input.algorithmEffectsOptions,
349367
useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection,
350-
carbAbsorptionModel: input.carbAbsorptionModel.model
368+
carbAbsorptionModel: input.carbAbsorptionModel.model,
369+
gradualTransitionsThreshold: input.gradualTransitionsThreshold
351370
)
352371
}
353372

@@ -530,7 +549,8 @@ public struct LoopAlgorithm {
530549
useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection,
531550
includingPositiveVelocityAndRC: input.includePositiveVelocityAndRC,
532551
useMidAbsorptionISF: input.useMidAbsorptionISF,
533-
carbAbsorptionModel: input.carbAbsorptionModel.model
552+
carbAbsorptionModel: input.carbAbsorptionModel.model,
553+
gradualTransitionsThreshold: input.gradualTransitionsThreshold
534554
)
535555

536556
let sensitivityForDosing: [AbsoluteScheduleValue<LoopQuantity>]
@@ -600,4 +620,3 @@ public struct LoopAlgorithm {
600620
)
601621
}
602622
}
603-

Sources/LoopAlgorithm/LoopPredictionInput.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ public struct LoopPredictionInput<CarbType: CarbEntry, GlucoseType: GlucoseSampl
3434
public var includePositiveVelocityAndRC: Bool = true
3535

3636
public var carbAbsorptionModel: CarbAbsorptionModel = .piecewiseLinear
37+
38+
public var gradualTransitionsThreshold: Double? = 40.0
3739

3840
public init(
3941
glucoseHistory: [GlucoseType],
@@ -45,7 +47,8 @@ public struct LoopPredictionInput<CarbType: CarbEntry, GlucoseType: GlucoseSampl
4547
algorithmEffectsOptions: AlgorithmEffectsOptions,
4648
useIntegralRetrospectiveCorrection: Bool,
4749
includePositiveVelocityAndRC: Bool,
48-
carbAbsorptionModel: CarbAbsorptionModel
50+
carbAbsorptionModel: CarbAbsorptionModel,
51+
gradualTransitionsThreshold: Double? = 40.0
4952
)
5053
{
5154
self.glucoseHistory = glucoseHistory
@@ -58,6 +61,7 @@ public struct LoopPredictionInput<CarbType: CarbEntry, GlucoseType: GlucoseSampl
5861
self.useIntegralRetrospectiveCorrection = useIntegralRetrospectiveCorrection
5962
self.includePositiveVelocityAndRC = includePositiveVelocityAndRC
6063
self.carbAbsorptionModel = carbAbsorptionModel
64+
self.gradualTransitionsThreshold = gradualTransitionsThreshold
6165
}
6266
}
6367

@@ -81,6 +85,7 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco
8185
self.useIntegralRetrospectiveCorrection = try container.decodeIfPresent(Bool.self, forKey: .useIntegralRetrospectiveCorrection) ?? false
8286
self.includePositiveVelocityAndRC = try container.decodeIfPresent(Bool.self, forKey: .includePositiveVelocityAndRC) ?? true
8387
self.carbAbsorptionModel = try container.decodeIfPresent(CarbAbsorptionModel.self, forKey: .carbAbsorptionModel) ?? .piecewiseLinear
88+
self.gradualTransitionsThreshold = try container.decodeIfPresent(Double.self, forKey: .gradualTransitionsThreshold) ?? 40.0
8489

8590
}
8691

@@ -103,6 +108,7 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco
103108
try container.encode(includePositiveVelocityAndRC, forKey: .includePositiveVelocityAndRC)
104109
}
105110
try container.encode(carbAbsorptionModel, forKey: .carbAbsorptionModel)
111+
try container.encode(gradualTransitionsThreshold, forKey: .gradualTransitionsThreshold)
106112
}
107113

108114
private enum CodingKeys: String, CodingKey {
@@ -116,5 +122,6 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco
116122
case useIntegralRetrospectiveCorrection
117123
case includePositiveVelocityAndRC
118124
case carbAbsorptionModel
125+
case gradualTransitionsThreshold
119126
}
120127
}

0 commit comments

Comments
 (0)