Przeglądaj źródła

Performance optimizations

polscm32 aka Marvout 1 rok temu
rodzic
commit
9edc422102
1 zmienionych plików z 161 dodań i 153 usunięć
  1. 161 153
      FreeAPS/Sources/APS/Storage/TDDStorage.swift

+ 161 - 153
FreeAPS/Sources/APS/Storage/TDDStorage.swift

@@ -41,57 +41,57 @@ final class BaseTDDStorage: TDDStorage, Injectable {
     ) async -> TDDResult {
         debug(.apsManager, "Starting TDD calculation with \(pumpHistory.count) pump events")
 
-        var bolusInsulin: Decimal = 0
-        var tempBasalInsulin: Decimal = 0
-        var scheduledBasalInsulin: Decimal = 0
+        // Group events by type once to avoid multiple filters
+        let groupedEvents = Dictionary(grouping: pumpHistory, by: { $0.type })
+        let bolusEvents = groupedEvents[.bolus] ?? []
+        let tempBasalEvents = groupedEvents[.tempBasal] ?? []
 
-        let pumpData = calculatePumpDataHours(pumpHistory)
-        debug(.apsManager, "Hours of pump data available: \(pumpData)")
-
-        let bolusEvents = pumpHistory.filter({ $0.type == .bolus })
-        let tempBasalEvents = pumpHistory.filter({ $0.type == .tempBasal })
-
-        debug(.apsManager, "Temp basal events: \(tempBasalEvents.description)")
+        // Calculate all components concurrently
+        async let pumpDataHours = calculatePumpDataHours(pumpHistory)
+        async let bolusInsulin = calculateBolusInsulin(bolusEvents)
 
         let gaps = findBasalGaps(in: tempBasalEvents)
-        if !gaps.isEmpty {
-            scheduledBasalInsulin = calculateScheduledBasalInsulin(
-                gaps: gaps,
-                profile: basalProfile,
-                roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
-            )
-            debug(.apsManager, "Total scheduled basal insulin: \(scheduledBasalInsulin)U")
-        }
-
-        bolusInsulin = calculateBolusInsulin(bolusEvents)
-        debug(.apsManager, "Total bolus insulin: \(bolusInsulin)U")
+        async let scheduledBasalInsulin = !gaps.isEmpty ? calculateScheduledBasalInsulin(
+            gaps: gaps,
+            profile: basalProfile,
+            roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
+        ) : 0
 
-        tempBasalInsulin = calculateTempBasalInsulin(
+        async let tempBasalInsulin = calculateTempBasalInsulin(
             tempBasalEvents,
             roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
         )
-        debug(.apsManager, "Total temp basal insulin: \(tempBasalInsulin)U")
 
-        let total = bolusInsulin + tempBasalInsulin + scheduledBasalInsulin
-        let weightedAverage = await calculateWeightedAverage()
+        async let weightedAverage = calculateWeightedAverage()
+
+        // Await all concurrent calculations
+        let (hours, bolus, scheduled, temp, weighted) = await (
+            pumpDataHours,
+            bolusInsulin,
+            scheduledBasalInsulin,
+            tempBasalInsulin,
+            weightedAverage
+        )
+
+        let total = bolus + temp + scheduled
 
         debug(.apsManager, """
         TDD Summary:
         - Total: \(total) U
-        - Bolus: \(bolusInsulin) U (\((bolusInsulin / total * 100).rounded(toPlaces: 1)) %)
-        - Temp Basal: \(tempBasalInsulin) U (\((tempBasalInsulin / total * 100).rounded(toPlaces: 1)) %)
-        - Scheduled Basal: \(scheduledBasalInsulin) U (\((scheduledBasalInsulin / total * 100).rounded(toPlaces: 1)) %)
-        - WeightedAverage: \(weightedAverage ?? 0) U
-        - Hours of Data: \(pumpData)
+        - Bolus: \(bolus) U (\((bolus / total * 100).rounded(toPlaces: 1)) %)
+        - Temp Basal: \(temp) U (\((temp / total * 100).rounded(toPlaces: 1)) %)
+        - Scheduled Basal: \(scheduled) U (\((scheduled / total * 100).rounded(toPlaces: 1)) %)
+        - WeightedAverage: \(weighted ?? 0) U
+        - Hours of Data: \(hours)
         """)
 
         return TDDResult(
             total: total,
-            bolus: bolusInsulin,
-            tempBasal: tempBasalInsulin,
-            scheduledBasal: scheduledBasalInsulin,
-            weightedAverage: weightedAverage,
-            hoursOfData: pumpData
+            bolus: bolus,
+            tempBasal: temp,
+            scheduledBasal: scheduled,
+            weightedAverage: weighted,
+            hoursOfData: hours
         )
     }
 
@@ -100,50 +100,48 @@ final class BaseTDDStorage: TDDStorage, Injectable {
     /// - 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 {
-            // No events = full day gap
             let startOfDay = Calendar.current.startOfDay(for: Date())
-            let endOfDay = startOfDay.addingTimeInterval(24 * 60 * 60 - 1)
-            return [(start: startOfDay, end: endOfDay)]
+            return [(start: startOfDay, end: startOfDay.addingTimeInterval(24 * 60 * 60 - 1))]
         }
 
-        // Sort events by timestamp
+        // 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)
 
-        var gaps: [(start: Date, end: Date)] = []
+        // 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)
 
-        // Track the end time of the last temp basal event
-        var lastEndTime: Date?
+        // Process events in a single pass
+        var lastEndTime = sortedEvents.first!.timestamp
 
-        for (index, event) in sortedEvents.enumerated() {
-            // Calculate the actual end time for the current event
+        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 a cancellation
-            if index < sortedEvents.count - 1 {
-                let nextEvent = sortedEvents[index + 1]
+            // Check for cancellation by next event
+            if i < sortedEvents.count - 1 {
+                let nextEvent = sortedEvents[i + 1]
                 if nextEvent.timestamp < currentEndTime {
-                    // The next event cancels this one, adjust the end time
                     currentEndTime = nextEvent.timestamp
                 }
             }
 
-            // If there’s a gap between the last event's end time and the current event's start time, record it
-            if let lastEnd = lastEndTime, event.timestamp > lastEnd {
-                gaps.append((start: lastEnd, end: event.timestamp))
+            // Record gap if exists
+            if event.timestamp > lastEndTime {
+                gaps.append((start: lastEndTime, end: event.timestamp))
             }
 
-            // Update the last end time to the current event's (possibly adjusted) end time
             lastEndTime = currentEndTime
         }
 
-        // Handle gap at the end of the dataset (if needed)
-        if let lastEnd = lastEndTime {
-            let endOfDay = Calendar.current.startOfDay(for: sortedEvents.first!.timestamp)
-                .addingTimeInterval(24 * 60 * 60 - 1)
-            if lastEnd < endOfDay {
-                gaps.append((start: lastEnd, end: endOfDay))
-            }
+        // Add final gap if needed
+        if lastEndTime < endOfDay {
+            gaps.append((start: lastEndTime, end: endOfDay))
         }
 
         return gaps
@@ -203,161 +201,171 @@ final class BaseTDDStorage: TDDStorage, Injectable {
             }
     }
 
-    /// Calculates insulin delivered via temporary basal rates, accounting for interruptions
+    /// Calculates temporary basal insulin delivery for a given time period
     /// - Parameters:
-    ///   - tempBasalEvents: Array of pump history events of type tempBasal
-    /// - Returns: Total temporary basal insulin
+    ///   - tempBasalEvents: Array of temporary basal events sorted by timestamp
+    ///   - roundToSupportedBasalRate: Closure to round rates to pump-supported values
+    /// - Returns: Total insulin delivered via temporary basal rates in units
     private func calculateTempBasalInsulin(
         _ tempBasalEvents: [PumpHistoryEvent],
         roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
     ) -> Decimal {
-        guard !tempBasalEvents.isEmpty else { return Decimal(0) }
+        guard !tempBasalEvents.isEmpty else { return 0 }
 
         let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
+        let currentDate = Date()
 
-        return sortedEvents.enumerated().reduce(Decimal(0)) { totalInsulin, currentEvent in
+        return sortedEvents.enumerated().reduce(into: Decimal(0)) { totalInsulin, currentEvent in
             let (index, event) = currentEvent
 
-            // Ensure the event has valid data
-            guard let rate = event.amount, // Rate in U/hr
-                  let durationMinutes = event.duration, // Duration in minutes
-                  rate > 0, durationMinutes > 0 else { return totalInsulin }
+            // Validate required event data (rate and duration)
+            guard let rate = event.amount,
+                  let durationMinutes = event.duration,
+                  rate > 0, durationMinutes > 0
+            else { return }
 
-            // Calculate the actual duration in minutes the temp basal ran
             let actualDurationMinutes: Int
+
             if index < sortedEvents.count - 1 {
-                // Next event exists; calculate if it interrupts the current event
+                // Handle interruption by subsequent temp basal
                 let nextEvent = sortedEvents[index + 1]
                 let currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(durationMinutes * 60))
 
-                // Include a small buffer for timestamp comparison
-                if nextEvent.timestamp.addingTimeInterval(-1) < currentEndTime {
-                    // Interrupted; calculate the actual duration
-                    let interruptedDuration = nextEvent.timestamp.timeIntervalSince(event.timestamp) / 60
-                    actualDurationMinutes = max(0, Int(interruptedDuration)) // Ensure non-negative duration
-                } else {
-                    // Not interrupted; use full duration
-                    actualDurationMinutes = durationMinutes
-                }
+                actualDurationMinutes = nextEvent.timestamp.addingTimeInterval(-1) < currentEndTime
+                    ? max(0, Int(nextEvent.timestamp.timeIntervalSince(event.timestamp) / 60))
+                    : durationMinutes
             } else {
-                // Last event in the list; calculate if it is running longer than current time
+                // Handle currently running temp basal
                 let currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(durationMinutes * 60))
-                if currentEndTime > Date().addingTimeInterval(-1) {
-                    let interruptedDuration = Date().timeIntervalSince(event.timestamp) / 60
-                    actualDurationMinutes = max(0, Int(interruptedDuration)) // Ensure non-negative duration
-                } else {
-                    actualDurationMinutes = durationMinutes
-                }
+                actualDurationMinutes = currentEndTime > currentDate.addingTimeInterval(-1)
+                    ? max(0, Int(currentDate.timeIntervalSince(event.timestamp) / 60))
+                    : durationMinutes
             }
 
-            // Convert the duration to hours and calculate insulin
+            // Calculate and accumulate insulin delivery
             let durationHours = Decimal(actualDurationMinutes) / 60
             let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
 
-            debug(
-                .apsManager,
-                "Temp basal: \(rate) U/hr for \(Decimal(actualDurationMinutes) / 60) hr = \(insulin) U"
-            )
-
-            return totalInsulin + insulin
+            totalInsulin += insulin
         }
     }
 
-    /// Calculates total scheduled basal insulin within gaps
+    /// Calculates scheduled basal insulin delivery during gaps between temporary basals
     /// - Parameters:
-    ///   - tempBasalEvents: Array of pump history events of type tempBasal
-    ///   - profile: Array of basal profile entries
-    /// - Returns: Total scheduled basal insulin
+    ///   - 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 {
-        var totalInsulin: Decimal = 0
+        // Initialize cached formatter for time string conversion
+        let timeFormatter: DateFormatter = {
+            let formatter = DateFormatter()
+            formatter.dateFormat = "HH:mm:ss"
+            return formatter
+        }()
 
-        for gap in gaps {
+        // 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 {
-                guard let rate = findBasalRate(for: getTimeString(from: currentTime), in: profile) else {
-                    debug(.apsManager, "No basal rate found for time \(currentTime)")
-                    break
-                }
+                // Find applicable basal rate for current time
+                guard let rate = findBasalRate(
+                    for: timeFormatter.string(from: currentTime),
+                    in: profile
+                ) else { break }
+
+                // Determine when rate changes (either profile switch or gap end)
+                let nextSwitchTime = getNextBasalRateSwitch(
+                    after: currentTime,
+                    switches: profileSwitches,
+                    calendar: Calendar.current
+                ) ?? gap.end
 
-                // Determine the next switch time in the basal profile or the end of the gap
-                let nextSwitchTime = getNextBasalRateSwitch(after: currentTime, in: profile) ?? gap.end
                 let endTime = min(nextSwitchTime, gap.end)
-
-                // Calculate duration in hours and insulin delivered
                 let duration = Decimal(endTime.timeIntervalSince(currentTime)) / 3600
-                let insulin = Decimal(roundToSupportedBasalRate(Double(rate * duration)))
-                totalInsulin += insulin
-
-                debug(.apsManager, "Scheduled basal: \(rate) U/hr from \(currentTime) to \(endTime) = \(insulin) U")
 
-                // Move to the next time block
+                totalInsulin += Decimal(roundToSupportedBasalRate(Double(rate * duration)))
                 currentTime = endTime
             }
         }
-
-        return totalInsulin
     }
 
-    /// Finds the next basal profile switch after a given time
+    /// Finds the next basal rate switch time after a given time
     /// - Parameters:
-    ///   - time: Current time
-    ///   - profile: Array of basal profile entries
-    /// - Returns: The time of the next switch, if any
-    private func getNextBasalRateSwitch(after time: Date, in profile: [BasalProfileEntry]) -> Date? {
-        let calendar = Calendar.current
+    ///   - time: Reference time to find next switch after
+    ///   - switches: Pre-calculated array of minutes when profile rates change
+    ///   - calendar: Calendar instance for date calculations
+    /// - Returns: Date of next basal rate switch, or nil if none found
+    private func getNextBasalRateSwitch(
+        after time: Date,
+        switches: [Int],
+        calendar: Calendar
+    ) -> Date? {
         let timeMinutes = calendar.component(.hour, from: time) * 60 + calendar.component(.minute, from: time)
 
-        // Find the next entry in the profile after the current time
-        for entry in profile {
-            if entry.minutes > timeMinutes {
-                let nextSwitchTime = calendar.startOfDay(for: time).addingTimeInterval(TimeInterval(entry.minutes * 60))
-                return nextSwitchTime
-            }
+        // Find first switch time after current time
+        guard let nextSwitch = switches.first(where: { $0 > timeMinutes }) else {
+            return nil
         }
 
-        return nil // No further switches; end of day
-    }
-
-    /// Converts a Date to a time string in "HH:mm:ss" format
-    private func getTimeString(from date: Date) -> String {
-        let formatter = DateFormatter()
-        formatter.dateFormat = "HH:mm:ss"
-        return formatter.string(from: date)
+        // Convert switch time to absolute date
+        return calendar.startOfDay(for: time).addingTimeInterval(TimeInterval(nextSwitch * 60))
     }
 
-    /// Finds the basal rate for a specific time in the profile, considering closest increments or wide coverage.
+    /// Finds the basal rate for a specific time using binary search
     /// - Parameters:
-    ///   - timeString: Time string in "HH:mm:ss" format
-    ///   - profile: Array of basal profile entries
-    /// - Returns: Basal rate if found
+    ///   - timeString: Time in format "HH:mm:ss"
+    ///   - profile: Array of basal profile entries sorted by time
+    /// - Returns: Basal rate in units per hour, or nil if not found
     private func findBasalRate(for timeString: String, in profile: [BasalProfileEntry]) -> Decimal? {
-        // Convert the timeString to minutes since midnight
-        let timeComponents = timeString.split(separator: ":").compactMap { Int($0) }
-        guard timeComponents.count == 3 else { return nil }
-        let totalMinutes = timeComponents[0] * 60 + timeComponents[1]
-
-        // If only one entry exists, return its rate (covers full 24 hours)
-        guard profile.count > 1 else {
-            return profile.first?.rate
+        // Parse time string in "HH:mm:ss" format into hours and minutes components
+        let timeComponents = timeString.split(separator: ":")
+        guard timeComponents.count == 3,
+              let hours = Int(timeComponents[0]),
+              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
         }
 
-        // Find the closest matching basal entry
-        for (index, entry) in profile.enumerated() {
-            // Check if the time falls within the range of the current entry
-            let startMinutes = entry.minutes
-            let endMinutes = (index + 1 < profile.count) ? profile[index + 1].minutes : 1440 // End of the day
+        // 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
 
-            if totalMinutes >= startMinutes, totalMinutes < endMinutes {
+            // 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
+            }
         }
 
-        // Default to nil if no match found
+        // No applicable rate found for the given time
         return nil
     }