Преглед изворни кода

Refactor and add suspension logic WIP

Deniz Cengiz пре 1 година
родитељ
комит
3e47cdc2b2
1 измењених фајлова са 242 додато и 86 уклоњено
  1. 242 86
      FreeAPS/Sources/APS/Storage/TDDStorage.swift

+ 242 - 86
FreeAPS/Sources/APS/Storage/TDDStorage.swift

@@ -45,6 +45,12 @@ final class BaseTDDStorage: TDDStorage, Injectable {
         let groupedEvents = Dictionary(grouping: pumpHistory, by: { $0.type })
         let bolusEvents = groupedEvents[.bolus] ?? []
         let tempBasalEvents = groupedEvents[.tempBasal] ?? []
+        // Extract pumpSuspend and pumpResume events, then create pairs of suspend + resume events
+        let pumpSuspendEvents = groupedEvents[.pumpSuspend] ?? []
+        let pumpResumeEvents = groupedEvents[.pumpResume] ?? []
+        let suspendResumePairs = zip(pumpSuspendEvents, pumpResumeEvents).filter { suspend, resume in
+            resume.timestamp > suspend.timestamp
+        }
 
         // Calculate all components concurrently
         async let pumpDataHours = calculatePumpDataHours(pumpHistory)
@@ -58,7 +64,7 @@ final class BaseTDDStorage: TDDStorage, Injectable {
         ) : 0
 
         async let tempBasalInsulin = calculateTempBasalInsulin(
-            tempBasalEvents,
+            tempBasalEvents, suspendResumePairs: suspendResumePairs,
             roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
         )
 
@@ -95,58 +101,6 @@ final class BaseTDDStorage: TDDStorage, Injectable {
         )
     }
 
-    /// Finds gaps between tempBasal events where scheduled basal ran
-    /// - Parameter tempBasalEvents: Array of pump history events of type tempBasal
-    /// - Returns: Array of gaps, where each gap has a start and end time
-    private func findBasalGaps(in tempBasalEvents: [PumpHistoryEvent]) -> [(start: Date, end: Date)] {
-        guard !tempBasalEvents.isEmpty else {
-            let startOfDay = Calendar.current.startOfDay(for: Date())
-            return [(start: startOfDay, end: startOfDay.addingTimeInterval(24 * 60 * 60 - 1))]
-        }
-
-        // Pre-sort events and create array with capacity
-        let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
-        var gaps = [(start: Date, end: Date)]()
-        gaps.reserveCapacity(sortedEvents.count + 1)
-
-        // Use first event's date for calendar operations
-        let startOfDay = Calendar.current.startOfDay(for: sortedEvents.first!.timestamp)
-        let endOfDay = startOfDay.addingTimeInterval(24 * 60 * 60 - 1)
-
-        // Process events in a single pass
-        var lastEndTime = sortedEvents.first!.timestamp
-
-        for i in 0 ..< sortedEvents.count {
-            let event = sortedEvents[i]
-            guard let duration = event.duration else { continue }
-
-            // Calculate end time for current event
-            var currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
-
-            // Check for cancellation by next event
-            if i < sortedEvents.count - 1 {
-                let nextEvent = sortedEvents[i + 1]
-                if nextEvent.timestamp < currentEndTime {
-                    currentEndTime = nextEvent.timestamp
-                }
-            }
-
-            // Record gap if exists
-            if event.timestamp > lastEndTime {
-                gaps.append((start: lastEndTime, end: event.timestamp))
-            }
-
-            lastEndTime = currentEndTime
-        }
-
-        // Add final gap if needed
-        if lastEndTime < endOfDay {
-            gaps.append((start: lastEndTime, end: endOfDay))
-        }
-
-        return gaps
-    }
-
     /// Stores the Total Daily Dose (TDD) result in Core Data
     /// - Parameter tddResult: The TDD result to store, containing total insulin, bolus, temp basal, scheduled basal and weighted average
     func storeTDD(_ tddResult: TDDResult) async {
@@ -201,53 +155,85 @@ final class BaseTDDStorage: TDDStorage, Injectable {
             }
     }
 
-    /// Calculates temporary basal insulin delivery for a given time period
+    /// Calculates temporary basal insulin delivery for a given time period, accounting for interruptions and suspensions
     /// - Parameters:
-    ///   - tempBasalEvents: Array of temporary basal events sorted by timestamp
+    ///   - tempBasalEvents: Array of temporary basal events
+    ///   - suspendResumePairs: Array of suspend and resume event pairs
     ///   - roundToSupportedBasalRate: Closure to round rates to pump-supported values
     /// - Returns: Total insulin delivered via temporary basal rates in units
     private func calculateTempBasalInsulin(
         _ tempBasalEvents: [PumpHistoryEvent],
+        suspendResumePairs: [(suspend: PumpHistoryEvent, resume: PumpHistoryEvent)],
         roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
     ) -> Decimal {
         guard !tempBasalEvents.isEmpty else { return 0 }
 
-        let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
-        let currentDate = Date()
+        // Merge temp basal events and suspend-resume pairs into a single timeline
+        var timeline = [(start: Date, end: Date, type: EventType, rate: Decimal?)]()
 
-        return sortedEvents.enumerated().reduce(into: Decimal(0)) { totalInsulin, currentEvent in
-            let (index, event) = currentEvent
-
-            // Validate required event data (rate and duration)
-            guard let rate = event.amount,
-                  let durationMinutes = event.duration,
-                  rate > 0, durationMinutes > 0
-            else { return }
+        // Add temp basal events to the timeline
+        for event in tempBasalEvents {
+            guard let duration = event.duration, let rate = event.amount, rate > 0 else { continue }
+            let end = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
+            timeline.append((start: event.timestamp, end: end, type: .tempBasal, rate: rate))
+        }
 
-            let actualDurationMinutes: Int
+        // Add suspend-resume events to the timeline
+        for suspendResume in suspendResumePairs {
+            timeline.append((
+                start: suspendResume.suspend.timestamp,
+                end: suspendResume.resume.timestamp,
+                type: .pumpSuspend,
+                rate: nil
+            ))
+        }
 
-            if index < sortedEvents.count - 1 {
-                // Handle interruption by subsequent temp basal
-                let nextEvent = sortedEvents[index + 1]
-                let currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(durationMinutes * 60))
+        // Sort the timeline by start time
+        timeline.sort { $0.start < $1.start }
 
-                actualDurationMinutes = nextEvent.timestamp.addingTimeInterval(-1) < currentEndTime
-                    ? max(0, Int(nextEvent.timestamp.timeIntervalSince(event.timestamp) / 60))
-                    : durationMinutes
-            } else {
-                // Handle currently running temp basal
-                let currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(durationMinutes * 60))
-                actualDurationMinutes = currentEndTime > currentDate.addingTimeInterval(-1)
-                    ? max(0, Int(currentDate.timeIntervalSince(event.timestamp) / 60))
-                    : durationMinutes
-            }
+        // Calculate insulin delivery while accounting for suspensions and premature interruptions
+        var totalInsulin: Decimal = 0
+        let currentDate = Date()
+        var lastEndTime: Date?
+
+        for (index, event) in timeline.enumerated() {
+            if event.type == .tempBasal {
+                let effectiveEnd = min(event.end, currentDate) // Adjust for ongoing temp basals
+                var actualStart = event.start
+                var actualEnd = effectiveEnd
+
+                // Check for interruption by the next event
+                if index < timeline.count - 1 {
+                    let nextEvent = timeline[index + 1]
+                    if nextEvent.start < actualEnd, nextEvent.type != .pumpSuspend {
+                        actualEnd = nextEvent.start
+                    }
+                }
 
-            // Calculate and accumulate insulin delivery
-            let durationHours = Decimal(actualDurationMinutes) / 60
-            let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
+                // Adjust for overlapping suspensions
+                if let lastSuspendEnd = lastEndTime, lastSuspendEnd > actualStart {
+                    actualStart = lastSuspendEnd
+                }
 
-            totalInsulin += insulin
+                // Calculate insulin if the duration is valid
+                let durationMinutes = max(0, actualEnd.timeIntervalSince(actualStart) / 60)
+                if durationMinutes > 0, let rate = event.rate {
+                    let durationHours = Decimal(durationMinutes) / 60
+                    let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
+                    totalInsulin += insulin
+
+                    debug(
+                        .apsManager,
+                        "Temp basal: \(rate) U/hr for \(durationHours) hr (from \(actualStart) until \(actualEnd)) = \(insulin) U"
+                    )
+                }
+            } else if event.type == .pumpSuspend {
+                // Update the last suspend end time to adjust future temp basal durations
+                lastEndTime = event.end
+            }
         }
+
+        return totalInsulin
     }
 
     /// Calculates scheduled basal insulin delivery during gaps between temporary basals
@@ -289,14 +275,184 @@ final class BaseTDDStorage: TDDStorage, Injectable {
                 ) ?? gap.end
 
                 let endTime = min(nextSwitchTime, gap.end)
-                let duration = Decimal(endTime.timeIntervalSince(currentTime)) / 3600
+                let durationHours = Decimal(endTime.timeIntervalSince(currentTime)) / 3600
+                let insulin = rate * durationHours
+                totalInsulin += Decimal(roundToSupportedBasalRate(Double(insulin)))
+
+                debug(
+                    .apsManager,
+                    "Scheduled Insulin added: \(insulin) U. Duration: \(durationHours) hrs (\(currentTime)-\(endTime))"
+                )
 
-                totalInsulin += Decimal(roundToSupportedBasalRate(Double(rate * duration)))
                 currentTime = endTime
             }
         }
     }
 
+    /// Finds gaps between tempBasal events where scheduled basal ran
+    /// - Parameter tempBasalEvents: Array of pump history events of type tempBasal
+    /// - Returns: Array of gaps, where each gap has a start and end time
+    private func findBasalGaps(in tempBasalEvents: [PumpHistoryEvent]) -> [(start: Date, end: Date)] {
+        guard !tempBasalEvents.isEmpty else {
+            let startOfDay = Calendar.current.startOfDay(for: Date())
+            return [(start: startOfDay, end: startOfDay.addingTimeInterval(24 * 60 * 60 - 1))]
+        }
+
+        // Pre-sort events and create array with capacity
+        let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
+        var gaps = [(start: Date, end: Date)]()
+        gaps.reserveCapacity(sortedEvents.count + 1)
+
+        // Use first event's date for calendar operations
+        let startOfDay = Calendar.current.startOfDay(for: sortedEvents.first!.timestamp)
+        let endOfDay = startOfDay.addingTimeInterval(24 * 60 * 60 - 1)
+
+        // Process events in a single pass
+        var lastEndTime = sortedEvents.first!.timestamp
+
+        for i in 0 ..< sortedEvents.count {
+            let event = sortedEvents[i]
+            guard let duration = event.duration else { continue }
+
+            // Calculate end time for current event
+            var currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
+
+            // Check for cancellation by next event
+            if i < sortedEvents.count - 1 {
+                let nextEvent = sortedEvents[i + 1]
+                if nextEvent.timestamp < currentEndTime {
+                    currentEndTime = nextEvent.timestamp
+                }
+            }
+
+            // Record gap if exists
+            if event.timestamp > lastEndTime {
+                gaps.append((start: lastEndTime, end: event.timestamp))
+            }
+
+            lastEndTime = currentEndTime
+        }
+
+        // Add final gap if needed
+        if lastEndTime < endOfDay {
+            gaps.append((start: lastEndTime, end: endOfDay))
+        }
+
+        return gaps
+    }
+
+//    /// Finds gaps between tempBasal events where scheduled basal ran, excluding suspend-resume periods
+//    /// - Parameters:
+//    ///   - tempBasalEvents: Array of pump history events of type tempBasal
+//    ///   - suspendResumePairs: Array of suspend and resume event pairs
+//    /// - Returns: Array of gaps, where each gap has a start and end time
+//    private func findBasalGaps(
+//        in tempBasalEvents: [PumpHistoryEvent],
+//        excluding suspendResumePairs: [(suspend: PumpHistoryEvent, resume: PumpHistoryEvent)]
+//    ) -> [(start: Date, end: Date)] {
+//        guard !tempBasalEvents.isEmpty else {
+//            let startOfDay = Calendar.current.startOfDay(for: Date())
+//            return [(start: startOfDay, end: startOfDay.addingTimeInterval(24 * 60 * 60 - 1))]
+//        }
+//
+//        // Merge temp basal and suspend-resume events into a unified timeline
+//        var timeline = [(start: Date, end: Date, type: EventType)]()
+//
+//        for event in tempBasalEvents {
+//            guard let duration = event.duration else { continue }
+//            let eventEnd = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
+//            timeline.append((start: event.timestamp, end: eventEnd, type: .tempBasal))
+//        }
+//
+//        for suspendResume in suspendResumePairs {
+//            timeline.append((start: suspendResume.suspend.timestamp, end: suspendResume.resume.timestamp, type: .pumpSuspend))
+//        }
+//
+//        // Sort the timeline by start time
+//        timeline.sort { $0.start < $1.start }
+//
+//        // Process the timeline to calculate gaps
+//        var gaps = [(start: Date, end: Date)]()
+//        var lastEndTime = Calendar.current.startOfDay(for: timeline.first!.start)
+//        let endOfDay = lastEndTime.addingTimeInterval(24 * 60 * 60 - 1)
+//
+//        for interval in timeline {
+//            if interval.type == .pumpSuspend {
+//                // Extend lastEndTime for suspend periods
+//                lastEndTime = max(lastEndTime, interval.end)
+//                continue
+//            }
+//
+//            if interval.start > lastEndTime {
+//                // Add a gap if there is a gap between lastEndTime and interval.start
+//                gaps.append((start: lastEndTime, end: interval.start))
+//            }
+//
+//            // Update lastEndTime to the maximum end time encountered
+//            lastEndTime = max(lastEndTime, interval.end)
+//        }
+//
+//        if lastEndTime < endOfDay {
+//            // Add a final gap if the lastEndTime is before the end of the day
+//            gaps.append((start: lastEndTime, end: endOfDay))
+//        }
+//
+//        return gaps
+//    }
+
+//    /// Calculates scheduled basal insulin delivery during gaps between temporary basals
+//    /// - Parameters:
+//    ///   - gaps: Array of time periods where scheduled basal was active
+//    ///   - profile: Basal profile entries defining rates throughout the day
+//    ///   - roundToSupportedBasalRate: Closure to round rates to pump-supported values
+//    /// - Returns: Total insulin delivered via scheduled basal in units
+//    private func calculateScheduledBasalInsulin(
+//        gaps: [(start: Date, end: Date)],
+//        profile: [BasalProfileEntry],
+//        roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
+//    ) -> Decimal {
+//        // Initialize cached formatter for time string conversion
+//        let timeFormatter: DateFormatter = {
+//            let formatter = DateFormatter()
+//            formatter.dateFormat = "HH:mm:ss"
+//            return formatter
+//        }()
+//
+//        // Pre-calculate profile switch times for efficient lookup
+//        let profileSwitches = profile.map(\.minutes)
+//
+//        return gaps.reduce(into: Decimal(0)) { totalInsulin, gap in
+//            var currentTime = gap.start
+//
+//            while currentTime < gap.end {
+//                // Find applicable basal rate for the current time
+//                guard let rate = findBasalRate(
+//                    for: timeFormatter.string(from: currentTime),
+//                    in: profile
+//                ) else { break }
+//
+//                // Determine when the rate changes (profile switch or gap end)
+//                let nextSwitchTime = getNextBasalRateSwitch(
+//                    after: currentTime,
+//                    switches: profileSwitches,
+//                    calendar: Calendar.current
+//                ) ?? gap.end
+//                let endTime = min(nextSwitchTime, gap.end)
+//                let durationHours = Decimal(endTime.timeIntervalSince(currentTime)) / 3600
+//
+//                let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
+//                totalInsulin += insulin
+//
+//                debug(
+//                    .apsManager,
+//                    "Scheduled Insulin added: \(insulin) U. Duration: \(durationHours) hrs (\(currentTime)-\(endTime))"
+//                )
+//
+//                currentTime = endTime
+//            }
+//        }
+//    }
+
     /// Finds the next basal rate switch time after a given time
     /// - Parameters:
     ///   - time: Reference time to find next switch after