Просмотр исходного кода

Take suspension events into account

polscm32 1 год назад
Родитель
Сommit
fb2f9cf7a6
1 измененных файлов с 169 добавлено и 10 удалено
  1. 169 10
      Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift

+ 169 - 10
Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift

@@ -40,7 +40,7 @@ extension Stat.StateModel {
         let tddResults = try await fetchTDDStoredRecords()
 
         // Fetch data for hourly statistics (BolusStored and TempBasalStored for day view)
-        let (bolusResults, tempBasalResults) = try await fetchHourlyInsulinRecords()
+        let (bolusResults, tempBasalResults, suspendEvents, resumeEvents) = try await fetchHourlyInsulinRecords()
 
         // MARK: - Process Data on Background Context
 
@@ -57,11 +57,15 @@ extension Stat.StateModel {
 
             // Process hourly statistics from BolusStored and TempBasalStored
             if let fetchedBoluses = bolusResults as? [BolusStored],
-               let fetchedTempBasals = tempBasalResults as? [TempBasalStored]
+               let fetchedTempBasals = tempBasalResults as? [TempBasalStored],
+               let fetchedSuspendEvents = suspendEvents as? [PumpEventStored],
+               let fetchedResumeEvents = resumeEvents as? [PumpEventStored]
             {
                 hourlyStats = self.processHourlyInsulinData(
                     boluses: fetchedBoluses,
                     tempBasals: fetchedTempBasals,
+                    suspendEvents: fetchedSuspendEvents,
+                    resumeEvents: fetchedResumeEvents,
                     calendar: calendar
                 )
             }
@@ -92,7 +96,7 @@ extension Stat.StateModel {
     /// Fetches BolusStored and TempBasalStored records from CoreData for hourly statistics
     /// - Returns: A tuple containing the results of both fetch requests
     /// - Note: Fetches records from the last 20 days for detailed hourly view
-    private func fetchHourlyInsulinRecords() async throws -> (bolus: Any, tempBasal: Any) {
+    private func fetchHourlyInsulinRecords() async throws -> (bolus: Any, tempBasal: Any, suspendEvents: Any, resumeEvents: Any) {
         // Calculate date range for hourly statistics (last 20 days)
         let now = Date()
         let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -20, to: now) ?? now
@@ -124,21 +128,67 @@ extension Stat.StateModel {
             batchSize: 100
         )
 
-        return (bolusResults, tempBasalResults)
+        // Create a combined predicate for suspension and resume events
+        let suspendResumeTypes = [
+            PumpEventStored.EventType.pumpSuspend.rawValue,
+            PumpEventStored.EventType.pumpResume.rawValue
+        ]
+
+        let suspendResumePredicate = NSPredicate(
+            format: "timestamp >= %@ AND timestamp <= %@ AND type IN %@",
+            twentyDaysAgo as NSDate,
+            now as NSDate,
+            suspendResumeTypes
+        )
+
+        // Fetch both suspension and resume events in a single query
+        let suspendResumeResults = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: tddTaskContext,
+            predicate: suspendResumePredicate,
+            key: "timestamp",
+            ascending: true,
+            batchSize: 100
+        )
+
+        // Filter the results within the context's perform closure to ensure thread safety
+        let (suspendEvents, resumeEvents) = await tddTaskContext.perform {
+            var suspendEventsArray: [PumpEventStored] = []
+            var resumeEventsArray: [PumpEventStored] = []
+
+            if let pumpEvents = suspendResumeResults as? [PumpEventStored] {
+                for event in pumpEvents {
+                    if event.type == PumpEventStored.EventType.pumpSuspend.rawValue {
+                        suspendEventsArray.append(event)
+                    } else if event.type == PumpEventStored.EventType.pumpResume.rawValue {
+                        resumeEventsArray.append(event)
+                    }
+                }
+            }
+
+            return (suspendEventsArray, resumeEventsArray)
+        }
+
+        return (bolusResults, tempBasalResults, suspendEvents, resumeEvents)
     }
 
     /// Processes bolus and temporary basal data to create hourly insulin statistics
     /// - Parameters:
     ///   - boluses: Array of BolusStored objects containing bolus insulin data
     ///   - tempBasals: Array of TempBasalStored objects containing temporary basal rate data
+    ///   - suspendEvents: Array of PumpEventStored objects with type pumpSuspend
+    ///   - resumeEvents: Array of PumpEventStored objects with type pumpResume
     ///   - calendar: Calendar instance used for date calculations and grouping
     /// - Returns: Array of TDDStats objects representing hourly insulin amounts
     /// - Note: This method calculates the actual duration of temporary basal rates by using the time
     ///         difference between consecutive events, rather than relying on the planned duration.
     ///         It also properly distributes insulin amounts across hour boundaries for accurate hourly statistics.
+    ///         Suspension events are taken into account to prevent counting insulin during pump suspensions.
     private func processHourlyInsulinData(
         boluses: [BolusStored],
         tempBasals: [TempBasalStored],
+        suspendEvents: [PumpEventStored],
+        resumeEvents: [PumpEventStored],
         calendar: Calendar
     ) -> [TDDStats] {
         // Dictionary to store insulin amounts indexed by hour
@@ -162,6 +212,11 @@ extension Stat.StateModel {
             insulinByHour[hourDate, default: 0] += amount
         }
 
+        // MARK: - Create Suspend-Resume Pairs
+
+        // Create pairs of suspend and resume events
+        let suspendResumePairs = createSuspendResumePairs(suspendEvents: suspendEvents, resumeEvents: resumeEvents)
+
         // MARK: - Process Temporary Basal Insulin
 
         // Sort temp basals chronologically for accurate duration calculation
@@ -202,10 +257,12 @@ extension Stat.StateModel {
             // MARK: Distribute Insulin Across Hours
 
             // Handle temp basals that span multiple hours by distributing insulin appropriately
+            // taking into account suspension periods
             distributeInsulinAcrossHours(
                 startTime: timestamp,
                 durationInHours: durationInHours,
                 rate: rate,
+                suspendResumePairs: suspendResumePairs,
                 insulinByHour: &insulinByHour,
                 calendar: calendar
             )
@@ -222,20 +279,57 @@ extension Stat.StateModel {
         }
     }
 
+    /// Creates pairs of suspend and resume events
+    /// - Parameters:
+    ///   - suspendEvents: Array of PumpEventStored objects with type pumpSuspend
+    ///   - resumeEvents: Array of PumpEventStored objects with type pumpResume
+    /// - Returns: Array of tuples containing suspend and resume event pairs
+    /// - Note: This method pairs suspend events with the next resume event chronologically
+    private func createSuspendResumePairs(
+        suspendEvents: [PumpEventStored],
+        resumeEvents: [PumpEventStored]
+    ) -> [(suspend: PumpEventStored, resume: PumpEventStored)] {
+        // Sort events chronologically
+        let sortedSuspendEvents = suspendEvents.sorted { ($0.timestamp ?? Date.distantPast) < ($1.timestamp ?? Date.distantPast) }
+        let sortedResumeEvents = resumeEvents.sorted { ($0.timestamp ?? Date.distantPast) < ($1.timestamp ?? Date.distantPast) }
+
+        // Create pairs of suspend + resume events
+        var pairs: [(suspend: PumpEventStored, resume: PumpEventStored)] = []
+
+        // Iterate through suspend events and find matching resume events
+        for suspendEvent in sortedSuspendEvents {
+            guard let suspendTime = suspendEvent.timestamp else { continue }
+
+            // Find the first resume event that occurs after this suspend event
+            if let resumeEvent = sortedResumeEvents.first(where: {
+                guard let resumeTime = $0.timestamp else { return false }
+                return resumeTime > suspendTime
+            }) {
+                // Create a pair and add it to the array
+                pairs.append((suspend: suspendEvent, resume: resumeEvent))
+            }
+        }
+
+        return pairs
+    }
+
     /// Distributes insulin from a temporary basal rate across multiple hours
     /// - Parameters:
     ///   - startTime: The start time of the temporary basal rate
     ///   - durationInHours: The duration of the temporary basal rate in hours
     ///   - rate: The insulin rate in units per hour (U/h)
+    ///   - suspendResumePairs: Array of suspend-resume event pairs to account for suspension periods
     ///   - insulinByHour: Dictionary to store insulin amounts by hour (modified in-place)
     ///   - calendar: Calendar instance used for date calculations
     /// - Note: This method handles the case where a temporary basal spans multiple hours by
     ///         calculating the exact amount of insulin delivered in each hour. It accounts for
-    ///         partial hours at the beginning and end of the temporary basal period.
+    ///         partial hours at the beginning and end of the temporary basal period, as well as
+    ///         suspension periods where no insulin is delivered.
     private func distributeInsulinAcrossHours(
         startTime: Date,
         durationInHours: Double,
         rate: Double,
+        suspendResumePairs: [(suspend: PumpEventStored, resume: PumpEventStored)],
         insulinByHour: inout [Date: Double],
         calendar: Calendar
     ) {
@@ -249,6 +343,9 @@ extension Stat.StateModel {
             return // Exit if we can't create a valid hour date
         }
 
+        // Calculate end time of the temp basal
+        let endTime = startTime.addingTimeInterval(durationInHours * 3600)
+
         // MARK: - Handle First Hour (Partial)
 
         // Calculate how many minutes remain in the first hour after the start time
@@ -257,10 +354,20 @@ extension Stat.StateModel {
         // Calculate how many hours of the temp basal occur in the first hour (capped at remaining time)
         let hoursInFirstHour = min(durationInHours, minutesInFirstHour / 60.0)
 
-        // Add insulin for the first partial hour
+        // Add insulin for the first partial hour, accounting for any suspensions
         if hoursInFirstHour > 0 {
-            // Insulin = rate (U/h) * fraction of hour
-            insulinByHour[startHourDate, default: 0] += rate * hoursInFirstHour
+            // Calculate the end time of the first hour segment
+            let firstHourEndTime = startTime.addingTimeInterval(hoursInFirstHour * 3600)
+
+            // Calculate effective duration excluding suspension periods
+            let effectiveDuration = calculateEffectiveDuration(
+                from: startTime,
+                to: firstHourEndTime,
+                suspendResumePairs: suspendResumePairs
+            )
+
+            // Insulin = rate (U/h) * effective duration (h)
+            insulinByHour[startHourDate, default: 0] += rate * effectiveDuration
         }
 
         // MARK: - Handle Subsequent Hours
@@ -276,8 +383,20 @@ extension Stat.StateModel {
             // Calculate how much of this hour is covered (max 1 hour)
             let hoursToAdd = min(remainingDuration, 1.0)
 
-            // Add insulin for this hour: rate (U/h) * fraction of hour
-            insulinByHour[currentHourDate, default: 0] += rate * hoursToAdd
+            // Calculate the start and end times for this hour segment
+            let hourStartTime = calendar
+                .date(from: calendar.dateComponents([.year, .month, .day, .hour], from: currentHourDate)) ?? currentHourDate
+            let hourEndTime = hourStartTime.addingTimeInterval(hoursToAdd * 3600)
+
+            // Calculate effective duration excluding suspension periods
+            let effectiveDuration = calculateEffectiveDuration(
+                from: hourStartTime,
+                to: hourEndTime,
+                suspendResumePairs: suspendResumePairs
+            )
+
+            // Add insulin for this hour: rate (U/h) * effective duration (h)
+            insulinByHour[currentHourDate, default: 0] += rate * effectiveDuration
 
             // Reduce remaining duration and move to next hour
             remainingDuration -= hoursToAdd
@@ -285,6 +404,46 @@ extension Stat.StateModel {
         }
     }
 
+    /// Calculates the effective duration of insulin delivery, excluding suspension periods
+    /// - Parameters:
+    ///   - startTime: The start time of the period
+    ///   - endTime: The end time of the period
+    ///   - suspendResumePairs: Array of suspend-resume event pairs
+    /// - Returns: The effective duration in hours, excluding suspension periods
+    /// - Note: This method calculates how much of a time period was not affected by pump suspensions
+    private func calculateEffectiveDuration(
+        from startTime: Date,
+        to endTime: Date,
+        suspendResumePairs: [(suspend: PumpEventStored, resume: PumpEventStored)]
+    ) -> Double {
+        // Total duration in hours
+        let totalDuration = endTime.timeIntervalSince(startTime) / 3600.0
+
+        // Calculate total suspended time within this period
+        var suspendedDuration = 0.0
+
+        for pair in suspendResumePairs {
+            guard let suspendTime = pair.suspend.timestamp,
+                  let resumeTime = pair.resume.timestamp
+            else {
+                continue
+            }
+
+            // Check if this suspension overlaps with our period
+            if suspendTime < endTime, resumeTime > startTime {
+                // Calculate overlap start and end
+                let overlapStart = max(startTime, suspendTime)
+                let overlapEnd = min(endTime, resumeTime)
+
+                // Add the overlapping duration to our suspended time
+                suspendedDuration += overlapEnd.timeIntervalSince(overlapStart) / 3600.0
+            }
+        }
+
+        // Return effective duration (total minus suspended)
+        return max(0.0, totalDuration - suspendedDuration)
+    }
+
     /// Processes TDDStored records to create daily Total Daily Dose statistics
     /// - Parameters:
     ///   - tdds: Array of TDDStored objects containing daily insulin data