Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ extension DeviceDataManager: BolusEntryViewModelDelegate, ManualDoseViewModelDel
return pumpManager != nil
}

var shouldModelAsNoDelivery: Bool {
return pumpManager?.status.shouldModelAsNoDelivery ?? false
}

var preferredGlucoseUnit: HKUnit {
return displayGlucosePreference.unit
}
Expand Down
13 changes: 13 additions & 0 deletions Loop/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -6453,6 +6453,9 @@
}
}
},
"Always active when no pump is connected" : {
"comment" : "Subtitle for suspend prediction input when no pump is connected"
},
"Amount Consumed" : {
"comment" : "Label for carb quantity entry row on carb entry screen",
"localizations" : {
Expand Down Expand Up @@ -27636,6 +27639,12 @@
}
}
},
"No Pump Connected" : {
"comment" : "Title for bolus screen notice when no pump is connected"
},
"No pump is connected. Bolus delivery is unavailable." : {
"comment" : "Caption for bolus screen notice when no pump is connected"
},
"No Recent Glucose" : {
"comment" : "The title of the cell indicating that there is no recent glucose",
"localizations" : {
Expand Down Expand Up @@ -41319,6 +41328,7 @@
},
"Your pump data is stale. %1$@ cannot recommend a bolus amount." : {
"comment" : "Caption for bolus screen notice when pump data is missing or stale",
"extractionState" : "stale",
"localizations" : {
"da" : {
"stringUnit" : {
Expand Down Expand Up @@ -41400,6 +41410,9 @@
}
}
},
"Your pump data is stale. Bolus delivery may be unavailable." : {
"comment" : "Caption for bolus screen notice when pump data is stale"
},
"Your pump is delivering a manual temporary basal rate." : {
"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.",
"localizations" : {
Expand Down
83 changes: 66 additions & 17 deletions Loop/Managers/LoopDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1235,7 +1235,8 @@ extension LoopDataManager {
potentialCarbEntry: NewCarbEntry? = nil,
replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil,
includingPendingInsulin: Bool = false,
includingPositiveVelocityAndRC: Bool = true
includingPositiveVelocityAndRC: Bool = true,
requireRecentPumpData: Bool = true
) throws -> [PredictedGlucoseValue] {
dispatchPrecondition(condition: .onQueue(dataAccessQueue))

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

guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
if requireRecentPumpData {
guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
}
}

var momentum: [GlucoseEffect] = []
Expand Down Expand Up @@ -1487,8 +1490,16 @@ extension LoopDataManager {

let pendingInsulin = try getPendingInsulin()
let shouldIncludePendingInsulin = pendingInsulin > 0
let prediction = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC)
return try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry)

var effectsToUse = PredictionInputEffect.all
var requireRecentPumpData = true // default to true to gate existing behavior on other pump types
if self.shouldModelAsNoDelivery {
// No active pod connected means no basal delivery — model it as suspension
effectsToUse.insert(.suspend)
requireRecentPumpData = false
}
let prediction = try predictGlucose(using: effectsToUse, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC, requireRecentPumpData: requireRecentPumpData)
return try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, requireRecentPumpData: requireRecentPumpData)
}

/// - Throws:
Expand All @@ -1498,7 +1509,8 @@ extension LoopDataManager {
/// - LoopError.pumpDataTooOld
/// - LoopError.configurationError
fileprivate func recommendBolusValidatingDataRecency<Sample: GlucoseValue>(forPrediction predictedGlucose: [Sample],
consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? {
consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?,
requireRecentPumpData: Bool = true) throws -> ManualBolusRecommendation? {
guard let glucose = glucoseStore.latestGlucose else {
throw LoopError.missingDataError(.glucose)
}
Expand All @@ -1513,9 +1525,10 @@ extension LoopDataManager {
guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.inputDataRecencyInterval else {
throw LoopError.invalidFutureGlucose(date: lastGlucoseDate)
}

guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
if requireRecentPumpData {
guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
}
}

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

private var shouldModelAsNoDelivery: Bool {
// Model as no delivery if the delegate or status is not present.
// If both are present, use the pumpManagerStatus field directly
return delegate?.pumpManagerStatus?.shouldModelAsNoDelivery ?? true
}

/// - Throws: LoopError.configurationError
private func recommendManualBolus<Sample: GlucoseValue>(forPrediction predictedGlucose: [Sample],
consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? {
Expand Down Expand Up @@ -1719,7 +1738,8 @@ extension LoopDataManager {

let pumpStatusDate = doseStore.lastAddedPumpData

if startDate.timeIntervalSince(pumpStatusDate) > LoopCoreConstants.inputDataRecencyInterval {
let pumpDataTooOld = startDate.timeIntervalSince(pumpStatusDate) > LoopCoreConstants.inputDataRecencyInterval
if pumpDataTooOld {
errors.append(.pumpDataTooOld(date: pumpStatusDate))
}

Expand Down Expand Up @@ -1773,18 +1793,34 @@ extension LoopDataManager {
}

dosingDecision.appendErrors(errors)
if let error = errors.first {
let errorsExcludingPumpDataTooOld = errors.filter {
if case .pumpDataTooOld = $0 { return false }
return true
}
if let error = errorsExcludingPumpDataTooOld.first {
logger.error("%{public}@", String(describing: error))
return (dosingDecision, error)
}

var loopError: LoopError?
do {
let predictedGlucose = try predictGlucose(using: settings.enabledEffects)
var effectsToUse = settings.enabledEffects
if self.shouldModelAsNoDelivery {
effectsToUse.insert(.suspend) // no pump = no basal delivery, model it as suspension
}
let predictedGlucose = try predictGlucose(using: effectsToUse, requireRecentPumpData: false)
self.predictedGlucose = predictedGlucose
let predictedGlucoseIncludingPendingInsulin = try predictGlucose(using: settings.enabledEffects, includingPendingInsulin: true)
// Prediction is shown regardless of pump state, while automated dosing still requires fresh pump data (below via pumpDataTooOld)
let predictedGlucoseIncludingPendingInsulin = try predictGlucose(using: effectsToUse, includingPendingInsulin: true, requireRecentPumpData: false)
self.predictedGlucoseIncludingPendingInsulin = predictedGlucoseIncludingPendingInsulin

if pumpDataTooOld {
self.logger.debug("Skipping automatic dose recommendation due to stale pump data.")
recommendedAutomaticDose = nil
dosingDecision.automaticDoseRecommendation = nil
return (dosingDecision, .pumpDataTooOld(date: pumpStatusDate))
}

dosingDecision.predictedGlucose = predictedGlucose

guard lastRequestedBolus == nil
Expand Down Expand Up @@ -1944,6 +1980,17 @@ extension LoopDataManager {
}
}
}


}

extension PumpManagerStatus {
var shouldModelAsNoDelivery: Bool {
// Treat a non-active (faulted or setup incomplete) pod just like no pod
// OmniBLE reports no active pod as .active(.distantPast)
// See both OmniBLEPumpManager.basalDeliveryState(for:) and OmnipodPumpManager.basalDeliveryState(for:)
return basalDeliveryState == .active(.distantPast)
}
}

/// Describes a view into the loop state
Expand Down Expand Up @@ -1985,9 +2032,10 @@ protocol LoopState {
/// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry`
/// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin
/// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false.
/// - 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.
/// - Returns: An timeline of predicted glucose values
/// - Throws: LoopError.missingDataError if prediction cannot be computed
func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue]
func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool, requireRecentPumpData: Bool) throws -> [PredictedGlucoseValue]

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

Expand Down Expand Up @@ -2099,9 +2148,9 @@ extension LoopDataManager {
return loopDataManager.retrospectiveCorrection.totalGlucoseCorrectionEffect
}

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

func predictGlucoseFromManualGlucose(
Expand Down
43 changes: 43 additions & 0 deletions Loop/View Controllers/PredictionTableViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable
tableView.rowHeight = UITableView.automaticDimension
tableView.cellLayoutMarginsFollowReadableWidth = true

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

let notificationCenter = NotificationCenter.default
Expand Down Expand Up @@ -87,6 +91,11 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable
}
}

private var defaultPredictionShouldIncludeNoDelivery: Bool {
guard let pumpManager = self.deviceManager.pumpManager else { return true }
return pumpManager.status.shouldModelAsNoDelivery
}

let glucoseChart = PredictedGlucoseChart(yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil)

override func createChartsManager() -> ChartsManager {
Expand Down Expand Up @@ -148,6 +157,17 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable
self.eventualGlucoseDescription = nil
}

if self.defaultPredictionShouldIncludeNoDelivery {
var baseEffects = PredictionInputEffect(self.availableInputs)
baseEffects.insert(.suspend)
let selectionMatchesBase = self.selectedInputs == baseEffects
if selectionMatchesBase {
// We auto-apply the .suspend prediction in this condition, so we need to suppress the alternate predicted
// glucose line that would've shown if the user had clicked .supress, since it this situation is just a redundant line.
self.glucoseChart.setAlternatePredictedGlucoseValues([])
}
}

if self.refreshContext.remove(.targets) != nil {
self.glucoseChart.targetGlucoseSchedule = manager.settings.glucoseTargetRangeSchedule
}
Expand Down Expand Up @@ -233,6 +253,18 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable
case .inputs:
let cell = tableView.dequeueReusableCell(withIdentifier: PredictionInputEffectTableViewCell.className, for: indexPath) as! PredictionInputEffectTableViewCell
self.tableView(tableView, updateTextFor: cell, at: indexPath)
let input = availableInputs[indexPath.row]
if input == .suspend && self.defaultPredictionShouldIncludeNoDelivery {
// When no pump connected, suspend effect is marked as active, so we show it as permanently selected and non-interactive
cell.contentView.alpha = 0.5
cell.selectionStyle = .none
let checkmark = UIImageView(image: UIImage(systemName: "checkmark"))
checkmark.tintColor = .systemGray
cell.accessoryView = checkmark
} else {
cell.contentView.alpha = 1.0
cell.selectionStyle = .default
}
return cell
}
}
Expand Down Expand Up @@ -297,6 +329,10 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable

}

if input == .suspend && self.defaultPredictionShouldIncludeNoDelivery {
subtitleText = NSLocalizedString("Always active when no pump is connected", comment: "Subtitle for suspend prediction input when no pump is connected")
}

cell.subtitleLabel?.text = subtitleText
}

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

let input = availableInputs[indexPath.row]

// When no pump connected, suspend effect is permanently active — ignore taps
if input == .suspend && self.defaultPredictionShouldIncludeNoDelivery {
tableView.deselectRow(at: indexPath, animated: true)
return
}

let isSelected = selectedInputs.contains(input)

if let cell = tableView.cellForRow(at: indexPath) {
Expand Down
Loading