Skip to content

Commit 0d9ed87

Browse files
authored
Prevent Automatic Dosing with Future Glucose (#1894)
* Prevents auto dosing when glucose is in the future * update per requested changes * add tests for glucoseTooOld and glucoseInFuture * Fix typo * name change for case to invalidFutureGlucose
1 parent ca39fc3 commit 0d9ed87

File tree

6 files changed

+56
-0
lines changed

6 files changed

+56
-0
lines changed

Loop/Managers/LoopDataManager.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,7 @@ extension LoopDataManager {
11301130
/// - LoopError.missingDataError
11311131
/// - LoopError.configurationError
11321132
/// - LoopError.glucoseTooOld
1133+
/// - LoopError.invalidFutureGlucose
11331134
/// - LoopError.pumpDataTooOld
11341135
fileprivate func predictGlucose(
11351136
startingAt startingGlucoseOverride: GlucoseValue? = nil,
@@ -1156,6 +1157,10 @@ extension LoopDataManager {
11561157
throw LoopError.glucoseTooOld(date: glucose.startDate)
11571158
}
11581159

1160+
guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.futureGlucoseDataInterval else {
1161+
throw LoopError.invalidFutureGlucose(date: lastGlucoseDate)
1162+
}
1163+
11591164
guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
11601165
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
11611166
}
@@ -1390,6 +1395,7 @@ extension LoopDataManager {
13901395
/// - Throws:
13911396
/// - LoopError.missingDataError
13921397
/// - LoopError.glucoseTooOld
1398+
/// - LoopError.invalidFutureGlucose
13931399
/// - LoopError.pumpDataTooOld
13941400
/// - LoopError.configurationError
13951401
fileprivate func recommendBolusValidatingDataRecency<Sample: GlucoseValue>(forPrediction predictedGlucose: [Sample],
@@ -1405,6 +1411,10 @@ extension LoopDataManager {
14051411
throw LoopError.glucoseTooOld(date: glucose.startDate)
14061412
}
14071413

1414+
guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.inputDataRecencyInterval else {
1415+
throw LoopError.invalidFutureGlucose(date: lastGlucoseDate)
1416+
}
1417+
14081418
guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
14091419
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
14101420
}
@@ -1516,6 +1526,7 @@ extension LoopDataManager {
15161526
/// - Throws:
15171527
/// - LoopError.configurationError
15181528
/// - LoopError.glucoseTooOld
1529+
/// - LoopError.invalidFutureGlucose
15191530
/// - LoopError.missingDataError
15201531
/// - LoopError.pumpDataTooOld
15211532
private func updatePredictedGlucoseAndRecommendedDose(with dosingDecision: StoredDosingDecision) -> (StoredDosingDecision, LoopError?) {
@@ -1539,6 +1550,10 @@ extension LoopDataManager {
15391550
errors.append(.glucoseTooOld(date: glucose.startDate))
15401551
}
15411552

1553+
if glucose.startDate.timeIntervalSince(startDate) > LoopCoreConstants.inputDataRecencyInterval {
1554+
errors.append(.invalidFutureGlucose(date: glucose.startDate))
1555+
}
1556+
15421557
let pumpStatusDate = doseStore.lastAddedPumpData
15431558

15441559
if startDate.timeIntervalSince(pumpStatusDate) > LoopCoreConstants.inputDataRecencyInterval {

Loop/Models/LoopError.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ enum LoopError: Error {
8989
// Glucose data is too old to perform action
9090
case glucoseTooOld(date: Date)
9191

92+
// Glucose data is in the future
93+
case invalidFutureGlucose(date: Date)
94+
9295
// Pump data is too old to perform action
9396
case pumpDataTooOld(date: Date)
9497

@@ -120,6 +123,8 @@ extension LoopError {
120123
return "missingDataError"
121124
case .glucoseTooOld:
122125
return "glucoseTooOld"
126+
case .invalidFutureGlucose:
127+
return "invalidFutureGlucose"
123128
case .pumpDataTooOld:
124129
return "pumpDataTooOld"
125130
case .recommendationExpired:
@@ -142,6 +147,8 @@ extension LoopError {
142147
details["detail"] = detail.rawValue
143148
case .glucoseTooOld(let date):
144149
details["date"] = StoredDosingDecisionIssue.description(for: date)
150+
case .invalidFutureGlucose(let date):
151+
details["date"] = StoredDosingDecisionIssue.description(for: date)
145152
case .pumpDataTooOld(let date):
146153
details["date"] = StoredDosingDecisionIssue.description(for: date)
147154
case .recommendationExpired(let date):
@@ -183,6 +190,9 @@ extension LoopError: LocalizedError {
183190
case .glucoseTooOld(let date):
184191
let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? ""
185192
return String(format: NSLocalizedString("Glucose data is %1$@ old", comment: "The error message when glucose data is too old to be used. (1: glucose data age in minutes)"), minutes)
193+
case .invalidFutureGlucose(let date):
194+
let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? ""
195+
return String(format: NSLocalizedString("Invalid glucose reading with a timestamp that is %1$@ in the future", comment: "The error message when glucose data is in the future. (1: glucose data time in future in minutes)"), minutes)
186196
case .pumpDataTooOld(let date):
187197
let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? ""
188198
return String(format: NSLocalizedString("Pump data is %1$@ old", comment: "The error message when pump data is too old to be used. (1: pump data age in minutes)"), minutes)

Loop/View Models/BolusEntryViewModel.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ final class BolusEntryViewModel: ObservableObject {
7676
case predictedGlucoseBelowSuspendThreshold(suspendThreshold: HKQuantity)
7777
case glucoseBelowTarget
7878
case staleGlucoseData
79+
case futureGlucoseData
7980
case stalePumpData
8081
}
8182

@@ -673,6 +674,8 @@ final class BolusEntryViewModel: ObservableObject {
673674
switch error {
674675
case LoopError.missingDataError(.glucose), LoopError.glucoseTooOld:
675676
notice = .staleGlucoseData
677+
case LoopError.invalidFutureGlucose:
678+
notice = .futureGlucoseData
676679
case LoopError.pumpDataTooOld:
677680
notice = .stalePumpData
678681
default:

Loop/Views/BolusEntryView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,11 @@ struct BolusEntryView: View {
334334
title: Text("No Recent Glucose Data", comment: "Title for bolus screen notice when glucose data is missing or stale"),
335335
caption: Text("Enter a blood glucose from a meter for a recommended bolus amount.", comment: "Caption for bolus screen notice when glucose data is missing or stale")
336336
)
337+
case .futureGlucoseData:
338+
return WarningView(
339+
title: Text("Invalid Future Glucose", comment: "Title for bolus screen notice when glucose data is in the future"),
340+
caption: Text("Check your device time and/or remove any invalid data from Apple Health.", comment: "Caption for bolus screen notice when glucose data is in the future")
341+
)
337342
case .stalePumpData:
338343
return WarningView(
339344
title: Text("No Recent Pump Data", comment: "Title for bolus screen notice when pump data is missing or stale"),

LoopCore/LoopCoreConstants.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ public enum LoopCoreConstants {
1313
/// The amount of time since a given date that input data should be considered valid
1414
public static let inputDataRecencyInterval = TimeInterval(minutes: 15)
1515

16+
/// The amount of time in the future a glucose value should be considered valid
17+
public static let futureGlucoseDataInterval = TimeInterval(minutes: 5)
18+
1619
public static let defaultCarbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5))
1720

1821
/// How much historical glucose to include in a dosing decision

LoopTests/ViewModels/BolusEntryViewModelTests.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,26 @@ class BolusEntryViewModelTests: XCTestCase {
348348
XCTAssertEqual(.stalePumpData, bolusEntryViewModel.activeNotice)
349349
}
350350

351+
func testUpdateRecommendedBolusThrowsGlucoseTooOld() async throws {
352+
XCTAssertFalse(bolusEntryViewModel.isBolusRecommended)
353+
delegate.loopState.bolusRecommendationError = LoopError.glucoseTooOld(date: now)
354+
await bolusEntryViewModel.update()
355+
XCTAssertFalse(bolusEntryViewModel.isBolusRecommended)
356+
let recommendedBolus = bolusEntryViewModel.recommendedBolus
357+
XCTAssertNil(recommendedBolus)
358+
XCTAssertEqual(.staleGlucoseData, bolusEntryViewModel.activeNotice)
359+
}
360+
361+
func testUpdateRecommendedBolusThrowsInvalidFutureGlucose() async throws {
362+
XCTAssertFalse(bolusEntryViewModel.isBolusRecommended)
363+
delegate.loopState.bolusRecommendationError = LoopError.invalidFutureGlucose(date: now)
364+
await bolusEntryViewModel.update()
365+
XCTAssertFalse(bolusEntryViewModel.isBolusRecommended)
366+
let recommendedBolus = bolusEntryViewModel.recommendedBolus
367+
XCTAssertNil(recommendedBolus)
368+
XCTAssertEqual(.futureGlucoseData, bolusEntryViewModel.activeNotice)
369+
}
370+
351371
func testUpdateRecommendedBolusThrowsOtherError() async throws {
352372
XCTAssertFalse(bolusEntryViewModel.isBolusRecommended)
353373
delegate.loopState.bolusRecommendationError = LoopError.pumpSuspended

0 commit comments

Comments
 (0)