Skip to content

Commit c517c16

Browse files
committed
Show glucose predictions and allow bolus recommendations without pod connected. #2445
1 parent 1f71ec4 commit c517c16

8 files changed

Lines changed: 170 additions & 27 deletions

Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ extension DeviceDataManager: BolusEntryViewModelDelegate, ManualDoseViewModelDel
7575
return pumpManager != nil
7676
}
7777

78+
var shouldModelAsNoDelivery: Bool {
79+
return pumpManager?.status.shouldModelAsNoDelivery ?? false
80+
}
81+
7882
var preferredGlucoseUnit: HKUnit {
7983
return displayGlucosePreference.unit
8084
}

Loop/Localizable.xcstrings

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6453,6 +6453,9 @@
64536453
}
64546454
}
64556455
},
6456+
"Always active when no pump is connected" : {
6457+
"comment" : "Subtitle for suspend prediction input when no pump is connected"
6458+
},
64566459
"Amount Consumed" : {
64576460
"comment" : "Label for carb quantity entry row on carb entry screen",
64586461
"localizations" : {
@@ -27636,6 +27639,12 @@
2763627639
}
2763727640
}
2763827641
},
27642+
"No Pump Connected" : {
27643+
"comment" : "Title for bolus screen notice when no pump is connected"
27644+
},
27645+
"No pump is connected. Bolus delivery is unavailable." : {
27646+
"comment" : "Caption for bolus screen notice when no pump is connected"
27647+
},
2763927648
"No Recent Glucose" : {
2764027649
"comment" : "The title of the cell indicating that there is no recent glucose",
2764127650
"localizations" : {
@@ -41319,6 +41328,7 @@
4131941328
},
4132041329
"Your pump data is stale. %1$@ cannot recommend a bolus amount." : {
4132141330
"comment" : "Caption for bolus screen notice when pump data is missing or stale",
41331+
"extractionState" : "stale",
4132241332
"localizations" : {
4132341333
"da" : {
4132441334
"stringUnit" : {
@@ -41400,6 +41410,9 @@
4140041410
}
4140141411
}
4140241412
},
41413+
"Your pump data is stale. Bolus delivery may be unavailable." : {
41414+
"comment" : "Caption for bolus screen notice when pump data is stale"
41415+
},
4140341416
"Your pump is delivering a manual temporary basal rate." : {
4140441417
"comment" : "The description text for the looping enabled switch cell when closed loop is not allowed because the pump is delivering a manual temp basal.",
4140541418
"localizations" : {

Loop/Managers/LoopDataManager.swift

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,7 +1235,8 @@ extension LoopDataManager {
12351235
potentialCarbEntry: NewCarbEntry? = nil,
12361236
replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil,
12371237
includingPendingInsulin: Bool = false,
1238-
includingPositiveVelocityAndRC: Bool = true
1238+
includingPositiveVelocityAndRC: Bool = true,
1239+
requireRecentPumpData: Bool = true
12391240
) throws -> [PredictedGlucoseValue] {
12401241
dispatchPrecondition(condition: .onQueue(dataAccessQueue))
12411242

@@ -1254,8 +1255,10 @@ extension LoopDataManager {
12541255
throw LoopError.invalidFutureGlucose(date: lastGlucoseDate)
12551256
}
12561257

1257-
guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
1258-
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
1258+
if requireRecentPumpData {
1259+
guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
1260+
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
1261+
}
12591262
}
12601263

12611264
var momentum: [GlucoseEffect] = []
@@ -1487,8 +1490,16 @@ extension LoopDataManager {
14871490

14881491
let pendingInsulin = try getPendingInsulin()
14891492
let shouldIncludePendingInsulin = pendingInsulin > 0
1490-
let prediction = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC)
1491-
return try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry)
1493+
1494+
var effectsToUse = PredictionInputEffect.all
1495+
var requireRecentPumpData = true // default to true to gate existing behavior on other pump types
1496+
if self.shouldModelAsNoDelivery {
1497+
// No active pod connected means no basal delivery — model it as suspension
1498+
effectsToUse.insert(.suspend)
1499+
requireRecentPumpData = false
1500+
}
1501+
let prediction = try predictGlucose(using: effectsToUse, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC, requireRecentPumpData: requireRecentPumpData)
1502+
return try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, requireRecentPumpData: requireRecentPumpData)
14921503
}
14931504

14941505
/// - Throws:
@@ -1498,7 +1509,8 @@ extension LoopDataManager {
14981509
/// - LoopError.pumpDataTooOld
14991510
/// - LoopError.configurationError
15001511
fileprivate func recommendBolusValidatingDataRecency<Sample: GlucoseValue>(forPrediction predictedGlucose: [Sample],
1501-
consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? {
1512+
consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?,
1513+
requireRecentPumpData: Bool = true) throws -> ManualBolusRecommendation? {
15021514
guard let glucose = glucoseStore.latestGlucose else {
15031515
throw LoopError.missingDataError(.glucose)
15041516
}
@@ -1513,9 +1525,10 @@ extension LoopDataManager {
15131525
guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.inputDataRecencyInterval else {
15141526
throw LoopError.invalidFutureGlucose(date: lastGlucoseDate)
15151527
}
1516-
1517-
guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
1518-
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
1528+
if requireRecentPumpData {
1529+
guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
1530+
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
1531+
}
15191532
}
15201533

15211534
guard glucoseMomentumEffect != nil else {
@@ -1533,6 +1546,12 @@ extension LoopDataManager {
15331546
return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry)
15341547
}
15351548

1549+
private var shouldModelAsNoDelivery: Bool {
1550+
// Model as no delivery if the delegate or status is not present.
1551+
// If both are present, use the pumpManagerStatus field directly
1552+
return delegate?.pumpManagerStatus?.shouldModelAsNoDelivery ?? true
1553+
}
1554+
15361555
/// - Throws: LoopError.configurationError
15371556
private func recommendManualBolus<Sample: GlucoseValue>(forPrediction predictedGlucose: [Sample],
15381557
consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? {
@@ -1719,7 +1738,8 @@ extension LoopDataManager {
17191738

17201739
let pumpStatusDate = doseStore.lastAddedPumpData
17211740

1722-
if startDate.timeIntervalSince(pumpStatusDate) > LoopCoreConstants.inputDataRecencyInterval {
1741+
let pumpDataTooOld = startDate.timeIntervalSince(pumpStatusDate) > LoopCoreConstants.inputDataRecencyInterval
1742+
if pumpDataTooOld {
17231743
errors.append(.pumpDataTooOld(date: pumpStatusDate))
17241744
}
17251745

@@ -1773,18 +1793,34 @@ extension LoopDataManager {
17731793
}
17741794

17751795
dosingDecision.appendErrors(errors)
1776-
if let error = errors.first {
1796+
let errorsExcludingPumpDataTooOld = errors.filter {
1797+
if case .pumpDataTooOld = $0 { return false }
1798+
return true
1799+
}
1800+
if let error = errorsExcludingPumpDataTooOld.first {
17771801
logger.error("%{public}@", String(describing: error))
17781802
return (dosingDecision, error)
17791803
}
17801804

17811805
var loopError: LoopError?
17821806
do {
1783-
let predictedGlucose = try predictGlucose(using: settings.enabledEffects)
1807+
var effectsToUse = settings.enabledEffects
1808+
if self.shouldModelAsNoDelivery {
1809+
effectsToUse.insert(.suspend) // no pump = no basal delivery, model it as suspension
1810+
}
1811+
let predictedGlucose = try predictGlucose(using: effectsToUse, requireRecentPumpData: false)
17841812
self.predictedGlucose = predictedGlucose
1785-
let predictedGlucoseIncludingPendingInsulin = try predictGlucose(using: settings.enabledEffects, includingPendingInsulin: true)
1813+
// Prediction is shown regardless of pump state, while automated dosing still requires fresh pump data (below via pumpDataTooOld)
1814+
let predictedGlucoseIncludingPendingInsulin = try predictGlucose(using: effectsToUse, includingPendingInsulin: true, requireRecentPumpData: false)
17861815
self.predictedGlucoseIncludingPendingInsulin = predictedGlucoseIncludingPendingInsulin
17871816

1817+
if pumpDataTooOld {
1818+
self.logger.debug("Skipping automatic dose recommendation due to stale pump data.")
1819+
recommendedAutomaticDose = nil
1820+
dosingDecision.automaticDoseRecommendation = nil
1821+
return (dosingDecision, .pumpDataTooOld(date: pumpStatusDate))
1822+
}
1823+
17881824
dosingDecision.predictedGlucose = predictedGlucose
17891825

17901826
guard lastRequestedBolus == nil
@@ -1944,6 +1980,17 @@ extension LoopDataManager {
19441980
}
19451981
}
19461982
}
1983+
1984+
1985+
}
1986+
1987+
extension PumpManagerStatus {
1988+
var shouldModelAsNoDelivery: Bool {
1989+
// Treat a non-active (faulted or setup incomplete) pod just like no pod
1990+
// OmniBLE reports no active pod as .active(.distantPast)
1991+
// See both OmniBLEPumpManager.basalDeliveryState(for:) and OmnipodPumpManager.basalDeliveryState(for:)
1992+
return basalDeliveryState == .active(.distantPast)
1993+
}
19471994
}
19481995

19491996
/// Describes a view into the loop state
@@ -1985,9 +2032,10 @@ protocol LoopState {
19852032
/// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry`
19862033
/// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin
19872034
/// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false.
2035+
/// - Parameter requireRecentPumpData: Age of pump data will not be evaluated (and not throw LoopError.pumpDataTooOld) if this is `false`. Set to `false` for predicting insulin without a pump connected.
19882036
/// - Returns: An timeline of predicted glucose values
19892037
/// - Throws: LoopError.missingDataError if prediction cannot be computed
1990-
func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue]
2038+
func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool, requireRecentPumpData: Bool) throws -> [PredictedGlucoseValue]
19912039

19922040
/// Calculates a new prediction from a manual glucose entry in the context of a meal entry
19932041
///
@@ -2035,7 +2083,8 @@ extension LoopState {
20352083
/// - Returns: An timeline of predicted glucose values
20362084
/// - Throws: LoopError.missingDataError if prediction cannot be computed
20372085
func predictGlucose(using inputs: PredictionInputEffect, includingPendingInsulin: Bool = false) throws -> [GlucoseValue] {
2038-
try predictGlucose(using: inputs, potentialBolus: nil, potentialCarbEntry: nil, replacingCarbEntry: nil, includingPendingInsulin: includingPendingInsulin, considerPositiveVelocityAndRC: true)
2086+
// `requireRecentPumpData` set to false as this method is for visualization purposes
2087+
try predictGlucose(using: inputs, potentialBolus: nil, potentialCarbEntry: nil, replacingCarbEntry: nil, includingPendingInsulin: includingPendingInsulin, considerPositiveVelocityAndRC: true, requireRecentPumpData: false)
20392088
}
20402089
}
20412090

@@ -2099,9 +2148,9 @@ extension LoopDataManager {
20992148
return loopDataManager.retrospectiveCorrection.totalGlucoseCorrectionEffect
21002149
}
21012150

2102-
func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] {
2151+
func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool, requireRecentPumpData: Bool) throws -> [PredictedGlucoseValue] {
21032152
dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue))
2104-
return try loopDataManager.predictGlucose(using: inputs, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC)
2153+
return try loopDataManager.predictGlucose(using: inputs, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC, requireRecentPumpData: requireRecentPumpData)
21052154
}
21062155

21072156
func predictGlucoseFromManualGlucose(

Loop/View Controllers/PredictionTableViewController.swift

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable
2929
tableView.rowHeight = UITableView.automaticDimension
3030
tableView.cellLayoutMarginsFollowReadableWidth = true
3131

32+
// No pump connected means no basal delivery — default prediction to suspension effect
33+
if self.defaultPredictionShouldIncludeNoDelivery {
34+
self.selectedInputs.insert(.suspend)
35+
}
3236
glucoseChart.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayRangeWide
3337

3438
let notificationCenter = NotificationCenter.default
@@ -87,6 +91,19 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable
8791
}
8892
}
8993

94+
private func selectedEffectsMatchBasePrediction() -> Bool {
95+
var baseEffects = PredictionInputEffect.all
96+
if self.defaultPredictionShouldIncludeNoDelivery {
97+
baseEffects.insert(.suspend)
98+
}
99+
return selectedInputs == baseEffects
100+
}
101+
102+
private var defaultPredictionShouldIncludeNoDelivery: Bool {
103+
guard let pumpManager = self.deviceManager.pumpManager else { return true }
104+
return pumpManager.status.shouldModelAsNoDelivery
105+
}
106+
90107
let glucoseChart = PredictedGlucoseChart(yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil)
91108

92109
override func createChartsManager() -> ChartsManager {
@@ -135,14 +152,20 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable
135152
self.glucoseChart.setPredictedGlucoseValues(state.predictedGlucoseIncludingPendingInsulin ?? [])
136153

137154
do {
138-
let glucose = try state.predictGlucose(using: self.selectedInputs, includingPendingInsulin: true)
139-
self.glucoseChart.setAlternatePredictedGlucoseValues(glucose)
155+
if self.selectedEffectsMatchBasePrediction() {
156+
self.glucoseChart.setAlternatePredictedGlucoseValues([])
157+
} else {
158+
let glucose = try state.predictGlucose(using: self.selectedInputs, includingPendingInsulin: true)
159+
self.glucoseChart.setAlternatePredictedGlucoseValues(glucose)
160+
}
140161
} catch {
141162
self.refreshContext.update(with: .status)
142163
self.glucoseChart.setAlternatePredictedGlucoseValues([])
143164
}
144165

145-
if let lastPoint = self.glucoseChart.alternatePredictedGlucosePoints?.last?.y {
166+
if let lastPoint = (self.selectedEffectsMatchBasePrediction() ?
167+
self.glucoseChart.predictedGlucosePoints.last?.y :
168+
self.glucoseChart.alternatePredictedGlucosePoints?.last?.y) {
146169
self.eventualGlucoseDescription = String(describing: lastPoint)
147170
} else {
148171
self.eventualGlucoseDescription = nil
@@ -233,6 +256,18 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable
233256
case .inputs:
234257
let cell = tableView.dequeueReusableCell(withIdentifier: PredictionInputEffectTableViewCell.className, for: indexPath) as! PredictionInputEffectTableViewCell
235258
self.tableView(tableView, updateTextFor: cell, at: indexPath)
259+
let input = availableInputs[indexPath.row]
260+
if input == .suspend && self.defaultPredictionShouldIncludeNoDelivery {
261+
// When no pump connected, suspend effect is marked as active, so we show it as permanently selected and non-interactive
262+
cell.contentView.alpha = 0.5
263+
cell.selectionStyle = .none
264+
let checkmark = UIImageView(image: UIImage(systemName: "checkmark"))
265+
checkmark.tintColor = .systemGray
266+
cell.accessoryView = checkmark
267+
} else {
268+
cell.contentView.alpha = 1.0
269+
cell.selectionStyle = .default
270+
}
236271
return cell
237272
}
238273
}
@@ -297,6 +332,10 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable
297332

298333
}
299334

335+
if input == .suspend && self.defaultPredictionShouldIncludeNoDelivery {
336+
subtitleText = NSLocalizedString("Always active when no pump is connected", comment: "Subtitle for suspend prediction input when no pump is connected")
337+
}
338+
300339
cell.subtitleLabel?.text = subtitleText
301340
}
302341

@@ -315,6 +354,13 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable
315354
guard Section(rawValue: indexPath.section) == .inputs else { return }
316355

317356
let input = availableInputs[indexPath.row]
357+
358+
// When no pump connected, suspend effect is permanently active — ignore taps
359+
if input == .suspend && self.defaultPredictionShouldIncludeNoDelivery {
360+
tableView.deselectRow(at: indexPath, animated: true)
361+
return
362+
}
363+
318364
let isSelected = selectedInputs.contains(input)
319365

320366
if let cell = tableView.cellForRow(at: indexPath) {

0 commit comments

Comments
 (0)