Browse Source

Fix TDD calculation

polscm32 1 year ago
parent
commit
e46de3ee01
1 changed files with 279 additions and 47 deletions
  1. 279 47
      Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift

+ 279 - 47
Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift

@@ -34,74 +34,306 @@ extension Stat.StateModel {
     /// - Returns: A tuple containing hourly and daily TDD statistics arrays
     /// - Note: Processes both hourly statistics for the last 10 days and complete daily statistics
     private func fetchTDDStats() async throws -> (hourly: [TDDStats], daily: [TDDStats]) {
-        // Fetch temp basal records from CoreData
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+        // MARK: - Fetch Required Data
+
+        // Fetch data for daily statistics (TDDStored for week, month, total views)
+        let tddResults = try await fetchTDDStoredRecords()
+
+        // Fetch data for hourly statistics (BolusStored and TempBasalStored for day view)
+        let (bolusResults, tempBasalResults) = try await fetchHourlyInsulinRecords()
+
+        // MARK: - Process Data on Background Context
+
+        var hourlyStats: [TDDStats] = []
+        var dailyStats: [TDDStats] = []
+
+        await tddTaskContext.perform {
+            let calendar = Calendar.current
+
+            // Process daily statistics from TDDStored
+            if let fetchedTDDs = tddResults as? [TDDStored] {
+                dailyStats = self.processDailyTDDs(fetchedTDDs, calendar: calendar)
+            }
+
+            // Process hourly statistics from BolusStored and TempBasalStored
+            if let fetchedBoluses = bolusResults as? [BolusStored],
+               let fetchedTempBasals = tempBasalResults as? [TempBasalStored]
+            {
+                hourlyStats = self.processHourlyInsulinData(
+                    boluses: fetchedBoluses,
+                    tempBasals: fetchedTempBasals,
+                    calendar: calendar
+                )
+            }
+        }
+
+        return (hourlyStats, dailyStats)
+    }
+
+    /// Fetches TDDStored records from CoreData for daily statistics
+    /// - Returns: The results of the fetch request containing TDDStored records
+    /// - Note: Fetches records from the last 3 months for week, month, and total views
+    private func fetchTDDStoredRecords() async throws -> Any {
+        // Create a predicate to fetch TDD records from the last 3 months
+        let threeMonthsAgo = Date().addingTimeInterval(-3.months.timeInterval)
+        let predicate = NSPredicate(format: "date >= %@", threeMonthsAgo as NSDate)
+
+        // Fetch TDD records from CoreData
+        return try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: TDDStored.self,
+            onContext: tddTaskContext,
+            predicate: predicate,
+            key: "date",
+            ascending: true,
+            batchSize: 100
+        )
+    }
+
+    /// 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) {
+        // Calculate date range for hourly statistics (last 20 days)
+        let now = Date()
+        let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -20, to: now) ?? now
+
+        // Create a predicate for the date range
+        let datePredicate = NSPredicate(
+            format: "pumpEvent.timestamp >= %@ AND pumpEvent.timestamp <= %@",
+            twentyDaysAgo as NSDate,
+            now as NSDate
+        )
+
+        // Fetch bolus records for hourly stats
+        let bolusResults = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: BolusStored.self,
+            onContext: tddTaskContext,
+            predicate: datePredicate,
+            key: "pumpEvent.timestamp",
+            ascending: true,
+            batchSize: 100
+        )
+
+        // Fetch temp basal records for hourly stats
+        let tempBasalResults = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempBasalStored.self,
             onContext: tddTaskContext,
-            predicate: NSPredicate.pumpHistoryForStats,
+            predicate: datePredicate,
             key: "pumpEvent.timestamp",
             ascending: true,
             batchSize: 100
         )
 
-        var hourlyStats: [TDDStats] = []
-        var dailyStats: [TDDStats] = []
+        return (bolusResults, tempBasalResults)
+    }
 
-        await tddTaskContext.perform {
-            guard let fetchedResults = results as? [TempBasalStored] else {
-                return
+    /// 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
+    ///   - 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.
+    private func processHourlyInsulinData(
+        boluses: [BolusStored],
+        tempBasals: [TempBasalStored],
+        calendar: Calendar
+    ) -> [TDDStats] {
+        // Dictionary to store insulin amounts indexed by hour
+        var insulinByHour: [Date: Double] = [:]
+
+        // MARK: - Process Bolus Insulin
+
+        // Iterate through all bolus records and add their amounts to the appropriate hourly totals
+        for bolus in boluses {
+            guard let timestamp = bolus.pumpEvent?.timestamp,
+                  let amount = bolus.amount?.doubleValue
+            else {
+                continue // Skip entries with missing timestamp or amount
             }
 
-            let calendar = Calendar.current
+            // Create a date representing the hour of this bolus (truncating minutes/seconds)
+            let components = calendar.dateComponents([.year, .month, .day, .hour], from: timestamp)
+            guard let hourDate = calendar.date(from: components) else { continue }
 
-            // Calculate date range for hourly statistics (last 20 days)
-            let now = Date()
-            let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -20, to: now) ?? now
-
-            // Group entries by hour for hourly statistics, filtering for last 10 days only
-            let hourlyGrouped = Dictionary(grouping: fetchedResults.filter { entry in
-                guard let date = entry.pumpEvent?.timestamp else { return false }
-                return date >= twentyDaysAgo && date <= now
-            }) { entry in
-                // Create date components for hour-level grouping
-                let components = calendar.dateComponents(
-                    [.year, .month, .day, .hour],
-                    from: entry.pumpEvent?.timestamp ?? Date()
-                )
-                return calendar.date(from: components) ?? Date()
+            // Add this bolus amount to the running total for this hour
+            insulinByHour[hourDate, default: 0] += amount
+        }
+
+        // MARK: - Process Temporary Basal Insulin
+
+        // Sort temp basals chronologically for accurate duration calculation
+        let sortedTempBasals = tempBasals.sorted {
+            ($0.pumpEvent?.timestamp ?? Date.distantPast) < ($1.pumpEvent?.timestamp ?? Date.distantPast)
+        }
+
+        // Process each temporary basal event
+        for (index, tempBasal) in sortedTempBasals.enumerated() {
+            guard let timestamp = tempBasal.pumpEvent?.timestamp,
+                  let rate = tempBasal.rate?.doubleValue
+            else {
+                continue // Skip entries with missing timestamp or rate
             }
 
-            // Group entries by day for complete daily statistics
-            let dailyGrouped = Dictionary(grouping: fetchedResults) { entry in
-                calendar.startOfDay(for: entry.pumpEvent?.timestamp ?? Date())
+            // MARK: Calculate Actual Duration
+
+            // Determine the actual duration based on the time until the next temp basal event
+            var actualDurationInMinutes: Double
+
+            if index < sortedTempBasals.count - 1 {
+                // For all but the last event, calculate duration as time until next event
+                if let nextTimestamp = sortedTempBasals[index + 1].pumpEvent?.timestamp {
+                    // Calculate time difference in minutes between this event and the next
+                    actualDurationInMinutes = nextTimestamp.timeIntervalSince(timestamp) / 60.0
+                } else {
+                    // Fallback to planned duration if next timestamp is missing (unlikely)
+                    actualDurationInMinutes = Double(tempBasal.duration)
+                }
+            } else {
+                // For the last event, use the planned duration as there's no next event
+                actualDurationInMinutes = Double(tempBasal.duration)
             }
 
-            // Process hourly statistics
-            hourlyStats = hourlyGrouped.keys.sorted().map { timePoint in
-                let entries = hourlyGrouped[timePoint, default: []]
-                // Calculate total insulin for each hour
-                return TDDStats(
-                    date: timePoint,
-                    amount: entries.reduce(0.0) { sum, entry in
-                        sum + (entry.rate?.doubleValue ?? 0) * Double(entry.duration) / 60.0
-                    }
-                )
+            // Convert duration from minutes to hours for insulin calculation
+            let durationInHours = actualDurationInMinutes / 60.0
+
+            // MARK: Distribute Insulin Across Hours
+
+            // Handle temp basals that span multiple hours by distributing insulin appropriately
+            distributeInsulinAcrossHours(
+                startTime: timestamp,
+                durationInHours: durationInHours,
+                rate: rate,
+                insulinByHour: &insulinByHour,
+                calendar: calendar
+            )
+        }
+
+        // MARK: - Convert Results to TDDStats Array
+
+        // Transform the dictionary into a sorted array of TDDStats objects
+        return insulinByHour.keys.sorted().map { hourDate in
+            TDDStats(
+                date: hourDate,
+                amount: insulinByHour[hourDate, default: 0]
+            )
+        }
+    }
+
+    /// 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)
+    ///   - 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.
+    private func distributeInsulinAcrossHours(
+        startTime: Date,
+        durationInHours: Double,
+        rate: Double,
+        insulinByHour: inout [Date: Double],
+        calendar: Calendar
+    ) {
+        // Extract time components to calculate partial hours
+        let startComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: startTime)
+
+        // Create a date representing just the hour of the start time (truncating minutes/seconds)
+        guard let startHourDate = calendar
+            .date(from: Calendar.current.dateComponents([.year, .month, .day, .hour], from: startTime))
+        else {
+            return // Exit if we can't create a valid hour date
+        }
+
+        // MARK: - Handle First Hour (Partial)
+
+        // Calculate how many minutes remain in the first hour after the start time
+        let minutesInFirstHour = 60.0 - Double(startComponents.minute ?? 0) - (Double(startComponents.second ?? 0) / 60.0)
+
+        // 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
+        if hoursInFirstHour > 0 {
+            // Insulin = rate (U/h) * fraction of hour
+            insulinByHour[startHourDate, default: 0] += rate * hoursInFirstHour
+        }
+
+        // MARK: - Handle Subsequent Hours
+
+        // Calculate remaining duration after the first hour
+        var remainingDuration = durationInHours - hoursInFirstHour
+
+        // Start with the next hour
+        var currentHourDate = calendar.date(byAdding: .hour, value: 1, to: startHourDate) ?? startHourDate
+
+        // Distribute remaining insulin across subsequent hours
+        while remainingDuration > 0 {
+            // 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
+
+            // Reduce remaining duration and move to next hour
+            remainingDuration -= hoursToAdd
+            currentHourDate = calendar.date(byAdding: .hour, value: 1, to: currentHourDate) ?? currentHourDate
+        }
+    }
+
+    /// Processes TDDStored records to create daily Total Daily Dose statistics
+    /// - Parameters:
+    ///   - tdds: Array of TDDStored objects containing daily insulin data
+    ///   - calendar: Calendar instance used for date calculations and grouping
+    /// - Returns: Array of TDDStats objects representing daily insulin amounts
+    /// - Note: This method groups TDD records by day and uses only the last (most recent) entry
+    ///         for each day, as this represents the complete TDD value for that day. This approach
+    ///         is appropriate for week, month, and total views where we want the final daily totals.
+    private func processDailyTDDs(_ tdds: [TDDStored], calendar: Calendar) -> [TDDStats] {
+        // MARK: - Group TDDs by Calendar Day
+
+        // Create a dictionary where keys are start-of-day dates and values are arrays of TDD entries for that day
+        let dailyGrouped = Dictionary(grouping: tdds) { tdd in
+            guard let timestamp = tdd.date else { return Date() }
+            // Use start of day (midnight) as the key for grouping
+            return calendar.startOfDay(for: timestamp)
+        }
+
+        // MARK: - Process Each Day's Entries
+
+        // Create a TDDStats object for each day using the most recent TDD entry
+        return dailyGrouped.keys.sorted().map { dayDate in
+            // Get all TDD entries for this day
+            let entries = dailyGrouped[dayDate, default: []]
+
+            // MARK: - Sort and Select Most Recent Entry
+
+            // Sort entries chronologically to find the most recent one for the day
+            let sortedEntries = entries.sorted {
+                ($0.date ?? Date.distantPast) < ($1.date ?? Date.distantPast)
             }
 
-            // Process daily statistics
-            dailyStats = dailyGrouped.keys.sorted().map { timePoint in
-                let entries = dailyGrouped[timePoint, default: []]
-                // Calculate total insulin for each day
+            // MARK: - Create TDDStats from Most Recent Entry
+
+            // The last entry in the sorted array contains the complete TDD for the day
+            if let lastEntry = sortedEntries.last, let total = lastEntry.total?.doubleValue {
+                // Create TDDStats with the day's date and the total insulin amount
+                return TDDStats(
+                    date: dayDate,
+                    amount: total
+                )
+            } else {
+                // Fallback if no valid entry exists for this day
                 return TDDStats(
-                    date: timePoint,
-                    amount: entries.reduce(0.0) { sum, entry in
-                        sum + (entry.rate?.doubleValue ?? 0) * Double(entry.duration) / 60.0
-                    }
+                    date: dayDate,
+                    amount: 0.0
                 )
             }
         }
-
-        return (hourlyStats, dailyStats)
     }
 
     /// Calculates and caches the daily averages of Total Daily Dose (TDD) insulin values