Browse Source

insulin delivery HUD fixes/improvements for suspends and scheduled basals

Joe Moran 6 months ago
parent
commit
4c35a9d09b

+ 21 - 1
Trio/Sources/APS/APSManager.swift

@@ -19,6 +19,8 @@ protocol APSManager {
     var bolusProgress: CurrentValueSubject<Decimal?, Never> { get }
     var pumpExpiresAtDate: CurrentValueSubject<Date?, Never> { get }
     var isManualTempBasal: Bool { get }
+    var isScheduledBasal: Bool? { get }
+    var isSuspended: Bool { get }
     func enactTempBasal(rate: Double, duration: TimeInterval) async
     func determineBasal() async throws
     func determineBasalSync() async throws
@@ -105,6 +107,10 @@ final class BaseAPSManager: APSManager, Injectable {
 
     @Persisted(key: "isManualTempBasal") var isManualTempBasal: Bool = false
 
+    @Persisted(key: "isScheduledBasal") var isScheduledBasal: Bool? = false
+
+    @Persisted(key: "isSuspended") var isSuspended: Bool = false
+
     let isLooping = CurrentValueSubject<Bool, Never>(false)
     let lastLoopDateSubject = PassthroughSubject<Date, Never>()
     let lastError = CurrentValueSubject<Error?, Never>(nil)
@@ -184,7 +190,21 @@ final class BaseAPSManager: APSManager, Injectable {
             }
             .store(in: &lifetime)
 
-        // manage a manual Temp Basal from OmniPod - Force loop() after stop a temp basal or finished
+        deviceDataManager.scheduledBasal
+            .receive(on: processQueue)
+            .sink { scheduledBasal in
+                self.isScheduledBasal = scheduledBasal
+            }
+            .store(in: &lifetime)
+
+        deviceDataManager.suspended
+            .receive(on: processQueue)
+            .sink { suspended in
+                self.isSuspended = suspended
+            }
+            .store(in: &lifetime)
+
+        // manage a manual Temp Basal from PumpManager - force loop() after temp basal is cancelled or finishes
         deviceDataManager.manualTempBasal
             .receive(on: processQueue)
             .sink { manualBasal in

+ 30 - 0
Trio/Sources/APS/DeviceDataManager.swift

@@ -14,6 +14,8 @@ import SwiftDate
 import Swinject
 import UserNotifications
 
+var fakePumpUnavailable = false
+
 protocol DeviceDataManager: GlucoseSource {
     var pumpManager: PumpManagerUI? { get set }
     var bluetoothManager: BluetoothStateManager { get }
@@ -22,6 +24,8 @@ protocol DeviceDataManager: GlucoseSource {
     var recommendsLoop: PassthroughSubject<Void, Never> { get }
     var bolusTrigger: PassthroughSubject<Bool, Never> { get }
     var manualTempBasal: PassthroughSubject<Bool, Never> { get }
+    var scheduledBasal: PassthroughSubject<Bool?, Never> { get }
+    var suspended: PassthroughSubject<Bool, Never> { get }
     var errorSubject: PassthroughSubject<Error, Never> { get }
     var pumpName: CurrentValueSubject<String, Never> { get }
     var pumpExpiresAtDate: CurrentValueSubject<Date?, Never> { get }
@@ -68,6 +72,8 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     let errorSubject = PassthroughSubject<Error, Never>()
     let pumpNewStatus = PassthroughSubject<Void, Never>()
     let manualTempBasal = PassthroughSubject<Bool, Never>()
+    let scheduledBasal = PassthroughSubject<Bool?, Never>()
+    let suspended = PassthroughSubject<Bool, Never>()
 
     private let router = TrioApp.resolver.resolve(Router.self)!
     @SyncAccess private var pumpUpdateCancellable: AnyCancellable?
@@ -411,6 +417,30 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
             bolusTrigger.send(false)
         }
 
+        // New code to set pumpSuspended variable here instead
+        // in Modules/Home/HomeStateModel+Setup/PumpHistorySetup.
+        // Works well with new scheduleBasal state variable.
+
+        switch status.basalDeliveryState {
+        case let .active(at):
+            if at == .distantPast || fakePumpUnavailable {
+                print("@@@ scheduledBasal.send(nil)")
+                scheduledBasal.send(nil) // pump is not currently available
+            } else {
+                print("@@@ basalDeliveryState active: suspended.send(false) & scheduledBasal.send(true)")
+                suspended.send(false)
+                scheduledBasal.send(true)
+            }
+        case .suspended:
+            print("@@@ basalDeliveryState suspended: suspended.send(true), scheduledBasal.send(false)")
+            suspended.send(true)
+            scheduledBasal.send(false)
+        default:
+            print("@@@ basalDeliveryState default: suspended.send(false) & scheduledBasal.send(false)")
+            suspended.send(false)
+            scheduledBasal.send(false)
+        }
+
         if status.insulinType != oldStatus.insulinType {
             settingsManager.updateInsulinCurve(status.insulinType)
         }

+ 47 - 34
Trio/Sources/APS/Storage/TDDStorage.swift

@@ -545,41 +545,8 @@ final class BaseTDDStorage: TDDStorage, Injectable {
               let minutes = Int(timeComponents[1])
         else { return nil }
 
-        // Convert time to total minutes since midnight for easier comparison
         let totalMinutes = hours * 60 + minutes
-
-        // Special case: If profile has only one entry, it applies for full 24 hours
-        // Return its rate immediately without searching
-        if profile.count == 1 {
-            return profile[0].rate
-        }
-
-        // Use binary search to efficiently find the applicable basal rate
-        // Profile entries are sorted by minutes, so we can divide and conquer
-        var left = 0
-        var right = profile.count - 1
-
-        while left <= right {
-            let mid = (left + right) / 2
-            let entry = profile[mid]
-            // Get end time for current entry - either next entry's start time or end of day (1440 mins)
-            let nextMinutes = mid + 1 < profile.count ? profile[mid + 1].minutes : 1440
-
-            // Check if target time falls within current entry's time range
-            if totalMinutes >= entry.minutes, totalMinutes < nextMinutes {
-                return entry.rate
-            }
-
-            // Adjust search range based on comparison
-            if totalMinutes < entry.minutes {
-                right = mid - 1 // Search in left half if target time is earlier
-            } else {
-                left = mid + 1 // Search in right half if target time is later
-            }
-        }
-
-        // No applicable rate found for the given time
-        return nil
+        return findBasalRateForOffset(for: totalMinutes, in: profile)
     }
 
     /// Calculates a weighted average of Total Daily Dose (TDD) based on recent and historical data
@@ -692,3 +659,49 @@ extension Decimal {
         return result
     }
 }
+
+/// XXX Will be moving this utility function to a new file
+///
+/// Finds the basal rate for a specific offset in a day using binary search
+/// - Parameters:
+///   - totalMinutes: minute offset into a 24 hour day
+///   - profile: Array of basal profile entries sorted by time
+/// - Returns: Basal rate in units per hour, or nil if not found
+func findBasalRateForOffset(for totalMinutes: Int, in profile: [BasalProfileEntry]) -> Decimal? {
+    if profile.isEmpty {
+        return nil // not yet initalized
+    }
+
+    // Special case: If profile has only one entry, it applies for full 24 hours
+    // Return its rate immediately without searching
+    if profile.count == 1 {
+        return profile[0].rate
+    }
+
+    // Use binary search to efficiently find the applicable basal rate
+    // Profile entries are sorted by minutes, so we can divide and conquer
+    var left = 0
+    var right = profile.count - 1
+
+    while left <= right {
+        let mid = (left + right) / 2
+        let entry = profile[mid]
+        // Get end time for current entry - either next entry's start time or end of day (24 * 60 mins)
+        let nextMinutes = mid + 1 < profile.count ? profile[mid + 1].minutes : 24 * 60
+
+        // Check if target time falls within current entry's time range
+        if totalMinutes >= entry.minutes, totalMinutes < nextMinutes {
+            return entry.rate
+        }
+
+        // Adjust search range based on comparison
+        if totalMinutes < entry.minutes {
+            right = mid - 1 // Search in left half if target time is earlier
+        } else {
+            left = mid + 1 // Search in right half if target time is later
+        }
+    }
+
+    // No applicable rate found for the given time
+    return nil
+}

+ 7 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -10034,6 +10034,7 @@
       }
     },
     "%lld h" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -188838,6 +188839,9 @@
         }
       }
     },
+    "Pump suspended [History]" : {
+
+    },
     "Pump Suspension" : {
       "localizations" : {
         "bg" : {
@@ -199129,6 +199133,9 @@
         }
       }
     },
+    "Scheduled basal" : {
+
+    },
     "Scheduled Basal Rate" : {
       "localizations" : {
         "bg" : {

+ 24 - 3
Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift

@@ -43,13 +43,34 @@ extension Home.StateModel {
         manualTempBasal = apsManager.isManualTempBasal
         tempBasals = insulinFromPersistence.filter({ $0.tempBasal != nil })
 
+        /// suspensions is a list of pump suspend and resume events
         suspensions = insulinFromPersistence.filter {
             $0.type == EventType.pumpSuspend.rawValue || $0.type == EventType.pumpResume.rawValue
         }
-        let lastSuspension = suspensions.last
 
-        pumpSuspended = tempBasals.last?.timestamp ?? Date() > lastSuspension?.timestamp ?? .distantPast && lastSuspension?
-            .type == EventType.pumpSuspend.rawValue
+        let lastSuspendResume = suspensions.last
+        let lastSuspendResumeWasSuspend = lastSuspendResume?.type == EventType.pumpSuspend.rawValue
+
+        print(
+            "@@@ tempBasals.last time=\(String(describing: tempBasals.last?.timestamp)), lastSuspendResume time=\(String(describing: lastSuspendResume?.timestamp)), lastSuspendResumeWasSuspend=\(lastSuspendResumeWasSuspend)"
+        )
+
+        /// This test fails to properly set pumpSuspended to true when a pump is suspended (at least for pods).
+        /// Will only set pumpSuspended to true if there was a TB operation done after the pump suspend/resume event.
+        pumpSuspended = tempBasals.last?.timestamp ?? Date() > lastSuspendResume?
+            .timestamp ?? .distantPast && lastSuspendResumeWasSuspend
+        print(
+            "@@@ original calculation would have set pumpSuspended to \(pumpSuspended)"
+        )
+
+        /// Maybe this tempBasalPostSuspendResume test from Open-APS was to deal with old PM's that might allow a temp basal on
+        /// a suspended pump &/or perhaps something related to traditional insulin pumps that can be suspended/resumed on the pump?
+        let tempBasalPostSuspendResume = tempBasals
+            .last { $0.timestamp ?? .distantPast > (lastSuspendResume?.timestamp ?? .distantPast) }
+        pumpSuspended = tempBasalPostSuspendResume == nil && lastSuspendResumeWasSuspend
+        print(
+            "@@@ new calculation sets pumpSuspended to \(pumpSuspended)"
+        )
     }
 
     // Setup Last Bolus to display the bolus progress bar

+ 79 - 28
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -4,6 +4,9 @@ import SwiftDate
 import SwiftUI
 import Swinject
 
+private var origPumpSuspendedFromHistory = false
+private var skipScheduledBasalRate = false
+
 struct TimePicker: Identifiable {
     var active: Bool
     let hours: Int16
@@ -162,22 +165,53 @@ extension Home {
             }
         }
 
-        var tempBasalString: String? {
-            guard let lastTempBasal = state.tempBasals.last?.tempBasal, let tempRate = lastTempBasal.rate else {
+        var basalString: String? {
+            var rate: NSNumber = 0
+            var scheduledBasalPrefix = ""
+            var manualBasalString = ""
+
+            guard let apsManager = state.apsManager else {
                 return nil
             }
-            let rateString = Formatter.decimalFormatterWithTwoFractionDigits.string(from: tempRate as NSNumber) ?? "0"
-            var manualBasalString = ""
 
-            if let apsManager = state.apsManager, apsManager.isManualTempBasal {
-                manualBasalString = String(
-                    localized:
-                    " - Manual Basal ⚠️",
-                    comment: "Manual Temp basal"
-                )
+            if apsManager.isScheduledBasal == true {
+                guard let scheduledRate = scheduledBasalDeliveryRate(at: Date()) else {
+                    return nil
+                }
+                // scheduledBasalPrefix = "SB "
+                rate = scheduledRate
+            } else {
+                guard let lastTempBasal = state.tempBasals.last?.tempBasal, let tempRate = lastTempBasal.rate else {
+                    return nil
+                }
+                if apsManager.isManualTempBasal {
+                    manualBasalString = String(
+                        localized: " - Manual Basal ⚠️",
+                        comment: "Manual Temp basal"
+                    )
+                }
+                rate = tempRate
             }
 
-            return rateString + String(localized: " U/hr", comment: "Unit per hour with space") + manualBasalString
+            let rateString = Formatter.decimalFormatterWithTwoFractionDigits.string(from: rate) ?? "0"
+            return scheduledBasalPrefix + rateString + String(localized: " U/hr", comment: "Unit per hour with space") +
+                manualBasalString
+        }
+
+        // Returns the scheduled basal rate for the current time based on the saved basal scheduled.
+        // Would be better if in the future BasalDeliveryStatus could be updated to include this info.
+        func scheduledBasalDeliveryRate(at when: Date) -> NSNumber? {
+            let calendar = Calendar(identifier: .gregorian)
+            // calendar.timeZone = timeZone /// should come from pumpManager in case it's different!
+
+            let hours = calendar.component(.hour, from: when)
+            let minutes = calendar.component(.minute, from: when)
+            let totalMinutes = hours * 60 + minutes
+
+            if let rate = findBasalRateForOffset(for: totalMinutes, in: state.basalProfile) {
+                return NSDecimalNumber(decimal: rate)
+            }
+            return nil
         }
 
         var overrideString: String? {
@@ -467,31 +501,48 @@ extension Home {
                         .font(.callout)
                 } else {
                     HStack {
-                        if state.pumpSuspended {
+                        if state.apsManager?.isScheduledBasal == nil {
+                            /// The pump not currently available (e.g., no pod)
+                            /// display no insulin delivery info rather than "Pump suspended"
+                        } else if origPumpSuspendedFromHistory && state.pumpSuspended {
+                            Text("Pump suspended [History]")
+                                .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                                .foregroundColor(.loopGray)
+                        } else if !origPumpSuspendedFromHistory && state.apsManager.isSuspended {
                             Text("Pump suspended")
                                 .font(.callout).fontWeight(.bold).fontDesign(.rounded)
                                 .foregroundColor(.loopGray)
-                        } else if let tempBasalString = tempBasalString {
+                        } else {
                             Image(systemName: "drop.circle")
                                 .font(.callout)
                                 .foregroundColor(.insulinTintColor)
-                            if tempBasalString.count > 5 {
-                                Text(tempBasalString)
-                                    .font(.callout).fontWeight(.bold).fontDesign(.rounded)
-                                    .lineLimit(1)
-                                    .minimumScaleFactor(0.85)
-                                    .truncationMode(.tail)
-                                    .allowsTightening(true)
+                            if skipScheduledBasalRate && state.apsManager?.isScheduledBasal == true {
+                                if state.apsManager?.isScheduledBasal == true {
+                                    Text("Scheduled basal")
+                                        .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                                        .foregroundColor(.loopGray)
+                                }
+                            } else if let basalString = basalString {
+                                // If running a scheduled basal, display basalString in blue instead of black
+                                let color: Color = state.apsManager?.isScheduledBasal == true ? .blue : .black
+                                if basalString.count > 5 {
+                                    Text(basalString)
+                                        .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                                        .foregroundColor(color)
+                                        .lineLimit(1)
+                                        .minimumScaleFactor(0.85)
+                                        .truncationMode(.tail)
+                                        .allowsTightening(true)
+                                } else {
+                                    // Short strings can just display normally
+                                    Text(basalString)
+                                        .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                                        .foregroundColor(color)
+                                }
                             } else {
-                                // Short strings can just display normally
-                                Text(tempBasalString).font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                                Text("No Data")
+                                    .font(.callout).fontWeight(.bold).fontDesign(.rounded)
                             }
-                        } else {
-                            Image(systemName: "drop.circle")
-                                .font(.callout)
-                                .foregroundColor(.insulinTintColor)
-                            Text("No Data")
-                                .font(.callout).fontWeight(.bold).fontDesign(.rounded)
                         }
                     }
                 }