瀏覽代碼

Merge pull request #849 from itsmojo/HUD-insulin-delivery-fixes-and-improvements

HUD insulin delivery display changes for Trio issue #847
Deniz Cengiz 6 月之前
父節點
當前提交
3d15b24792

+ 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
@@ -104,6 +106,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)
@@ -183,7 +189,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 manual temp basal is cancelled or finishes
         deviceDataManager.manualTempBasal
             .receive(on: processQueue)
             .sink { manualBasal in

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

@@ -22,6 +22,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 +70,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 +415,22 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
             bolusTrigger.send(false)
         }
 
+        switch status.basalDeliveryState {
+        case let .active(at):
+            if at == .distantPast {
+                scheduledBasal.send(nil) // pump is not currently available
+            } else {
+                suspended.send(false)
+                scheduledBasal.send(true)
+            }
+        case .suspended:
+            suspended.send(true)
+            scheduledBasal.send(false)
+        default:
+            suspended.send(false)
+            scheduledBasal.send(false)
+        }
+
         if status.insulinType != oldStatus.insulinType {
             settingsManager.updateInsulinCurve(status.insulinType)
         }

+ 45 - 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,47 @@ extension Decimal {
         return result
     }
 }
+
+/// Finds the basal rate at the specified minute offset 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
+}

+ 2 - 6
Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift

@@ -41,15 +41,11 @@ extension Home.StateModel {
         insulinFromPersistence = insulinObjects
 
         manualTempBasal = apsManager.isManualTempBasal
-        tempBasals = insulinFromPersistence.filter({ $0.tempBasal != nil })
+        tempBasals = insulinFromPersistence.filter { $0.tempBasal != nil }
 
-        suspensions = insulinFromPersistence.filter {
+        suspendAndResumeEvents = 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
     }
 
     // Setup Last Bolus to display the bolus progress bar

+ 1 - 2
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -40,7 +40,6 @@ extension Home {
         var targetProfiles: [TargetProfile] = []
         var timerDate = Date()
         var closedLoop = false
-        var pumpSuspended = false
         var isLooping = false
         var statusTitle = ""
         var lastLoopDate: Date = .distantPast
@@ -92,7 +91,7 @@ extension Home {
         var fetchedTDDs: [TDD] = []
         var insulinFromPersistence: [PumpEventStored] = []
         var tempBasals: [PumpEventStored] = []
-        var suspensions: [PumpEventStored] = []
+        var suspendAndResumeEvents: [PumpEventStored] = []
         var batteryFromPersistence: [OpenAPS_Battery] = []
         var lastPumpBolus: PumpEventStored?
         var overrides: [OverrideStored] = []

+ 3 - 2
Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift

@@ -104,7 +104,7 @@ extension MainChartView {
     }
 
     func drawSuspensions() -> some ChartContent {
-        let suspensions = state.suspensions
+        let suspensions = state.suspendAndResumeEvents
         return ForEach(suspensions) { suspension in
             let now = Date()
 
@@ -154,7 +154,8 @@ extension MainChartView {
             let duration = temp.tempBasal?.duration ?? 0
             let timestamp = temp.timestamp ?? Date()
             let end = timestamp + duration.minutes
-            let isInsulinSuspended = state.suspensions.contains { $0.timestamp ?? now >= timestamp && $0.timestamp ?? now <= end }
+            let isInsulinSuspended = state.suspendAndResumeEvents
+                .contains { $0.timestamp ?? now >= timestamp && $0.timestamp ?? now <= end }
 
             let rate = Double(truncating: temp.tempBasal?.rate ?? Decimal.zero as NSDecimalNumber) * (isInsulinSuspended ? 0 : 1)
 

+ 63 - 31
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -162,22 +162,51 @@ 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 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
+                }
+                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 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 +496,34 @@ extension Home {
                         .font(.callout)
                 } else {
                     HStack {
-                        if state.pumpSuspended {
-                            Text("Pump suspended")
-                                .font(.callout).fontWeight(.bold).fontDesign(.rounded)
-                                .foregroundColor(.loopGray)
-                        } else if let tempBasalString = tempBasalString {
+                        /// Only display the insulin delivery rate info if the pump is not
+                        /// suspended and is available (e.g., pod is paired & not faulted).
+                        let pumpAvailable = state.apsManager.isScheduledBasal != nil
+                        if !state.apsManager.isSuspended && pumpAvailable {
                             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 let basalString = self.basalString {
+                                /// Adjust opacity when displaying a scheduled basal rate
+                                let opacity = state.apsManager?.isScheduledBasal == true ? 0.6 : 1.0
+                                if basalString.count > 5 {
+                                    Text(basalString)
+                                        .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                                        .lineLimit(1)
+                                        .minimumScaleFactor(0.85)
+                                        .truncationMode(.tail)
+                                        .allowsTightening(true)
+                                        .opacity(opacity)
+                                } else {
+                                    // Short strings can just display normally
+                                    Text(basalString)
+                                        .font(.callout).fontWeight(.bold).fontDesign(.rounded)
+                                        .opacity(opacity)
+                                }
                             } 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)
                         }
                     }
                 }