Browse Source

Loop chart rework

polscm32 aka Marvout 1 year ago
parent
commit
77b1327169

+ 152 - 146
FreeAPS/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift

@@ -31,180 +31,186 @@ extension Stat.StateModel {
     /// 3. Updating loop stat records on the main thread (!) for the Loop duration chart
     func setupLoopStatRecords() {
         Task {
-            let recordIDs = await self.fetchLoopStatRecords(for: selectedDurationForLoopStats)
+            let (recordIDs, failedRecordIDs) = await self.fetchLoopStatRecords(for: selectedDurationForLoopStats)
 
-            // Used for the Loop stats chart (success/failure percentages)
-            let stats = await calculateLoopStats(from: recordIDs)
+            // Update loop records for duration chart
+            await self.updateLoopStatRecords(allLoopIds: recordIDs)
+
+            // Calculate statistics and update on main thread
+            let stats = await self.getLoopStats(
+                allLoopIds: recordIDs,
+                failedLoopIds: failedRecordIDs,
+                duration: selectedDurationForLoopStats
+            )
 
-            // Update property on main thread to avoid data races
             await MainActor.run {
-                self.groupedLoopStats = stats
+                self.loopStats = stats
             }
-
-            // Used for the Loop duration chart (execution times)
-            await self.updateLoopStatRecords(from: recordIDs)
         }
     }
 
-    /// Fetches loop stat record IDs from Core Data based on the selected time duration
-    /// - Parameter duration: The time period to fetch records for (Today, Day, Week, Month, or Total)
-    /// - Returns: Array of NSManagedObjectIDs for the matching loop stat records
-    func fetchLoopStatRecords(for duration: Duration) async -> [NSManagedObjectID] {
-        // Create compound predicate combining duration and non-nil constraints
-        let predicate: NSCompoundPredicate
-        let durationPredicate: NSPredicate
-        let nonNilDurationPredicate = NSPredicate(format: "duration != nil AND duration != 0")
-
-        // Set up date-based predicate based on selected duration
+    /// Fetches loop statistics records for the specified duration
+    /// - Parameter duration: The time period to fetch records for
+    /// - Returns: A tuple containing arrays of NSManagedObjectIDs for (all loops, failed loops)
+    func fetchLoopStatRecords(for duration: Duration) async -> ([NSManagedObjectID], [NSManagedObjectID]) {
+        // Calculate the date range based on selected duration
+        let now = Date()
+        let startDate: Date
         switch duration {
-        case .Day,
-             .Today:
-            durationPredicate = NSPredicate(
-                format: "end >= %@",
-                Calendar.current.date(
-                    byAdding: .day,
-                    value: -2,
-                    to: Calendar.current.startOfDay(for: Date())
-                )! as CVarArg
-            )
+        case .Day:
+            startDate = Calendar.current.startOfDay(for: now)
+        case .Today:
+            startDate = now.addingTimeInterval(-24.hours.timeInterval)
         case .Week:
-            durationPredicate = NSPredicate(
-                format: "end >= %@",
-                Calendar.current.date(
-                    byAdding: .day,
-                    value: -7,
-                    to: Calendar.current.startOfDay(for: Date())
-                )! as CVarArg
-            )
+            startDate = now.addingTimeInterval(-7.days.timeInterval)
         case .Month:
-            durationPredicate = NSPredicate(
-                format: "end >= %@",
-                Calendar.current.date(
-                    byAdding: .month,
-                    value: -1,
-                    to: Calendar.current.startOfDay(for: Date())
-                )! as CVarArg
-            )
+            startDate = now.addingTimeInterval(-30.days.timeInterval)
         case .Total:
-            durationPredicate = NSPredicate(
-                format: "end >= %@",
-                Calendar.current.date(
-                    byAdding: .month,
-                    value: -3,
-                    to: Calendar.current.startOfDay(for: Date())
-                )! as CVarArg
-            )
+            startDate = now.addingTimeInterval(-90.days.timeInterval)
         }
-        predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [durationPredicate, nonNilDurationPredicate])
 
-        // Fetch records using the constructed predicate
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+        // Perform both fetches asynchronously
+        async let allLoopsResult = CoreDataStack.shared.fetchEntitiesAsync(
             ofType: LoopStatRecord.self,
             onContext: loopTaskContext,
-            predicate: predicate,
-            key: "end",
-            ascending: false,
-            batchSize: 100
+            predicate: NSPredicate(format: "start > %@", startDate as NSDate),
+            key: "start",
+            ascending: false
         )
 
-        return await loopTaskContext.perform {
-            guard let fetchedResults = results as? [LoopStatRecord] else { return [] }
-            return fetchedResults.map(\.objectID)
-        }
+        async let failedLoopsResult = CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: LoopStatRecord.self,
+            onContext: loopTaskContext,
+            predicate: NSPredicate(
+                format: "start > %@ AND loopStatus != %@",
+                startDate as NSDate,
+                "Success"
+            ),
+            key: "start",
+            ascending: false
+        )
+
+        // Wait for both results and convert to object IDs
+        let (allLoops, failedLoops) = await (allLoopsResult, failedLoopsResult)
+
+        return (
+            (allLoops as? [LoopStatRecord] ?? []).map(\.objectID),
+            (failedLoops as? [LoopStatRecord] ?? []).map(\.objectID)
+        )
     }
 
-    /// Calculates statistics for loop executions grouped by time periods
-    /// - Parameter ids: Array of NSManagedObjectIDs for loop stat records
-    /// - Returns: Array of LoopStatsByPeriod containing success/failure statistics
-    func calculateLoopStats(from ids: [NSManagedObjectID]) async -> [LoopStatsByPeriod] {
-        await loopTaskContext.perform {
-            let calendar = Calendar.current
-            let now = Date()
-
-            // Convert IDs to LoopStatRecord objects
-            let records = ids.compactMap { id -> LoopStatRecord? in
-                do {
-                    return try self.loopTaskContext.existingObject(with: id) as? LoopStatRecord
-                } catch {
-                    debugPrint("\(DebuggingIdentifiers.failed) Error fetching loop stat: \(error)")
-                    return nil
-                }
+    /// Updates the loopStatRecords array on the main thread with records from the provided IDs
+    /// - Parameters:
+    ///   - allLoopIds: Array of NSManagedObjectIDs for all loop records
+    @MainActor func updateLoopStatRecords(allLoopIds: [NSManagedObjectID]) {
+        loopStatRecords = allLoopIds.compactMap { id -> LoopStatRecord? in
+            do {
+                return try viewContext.existingObject(with: id) as? LoopStatRecord
+            } catch {
+                debugPrint("\(DebuggingIdentifiers.failed) Error fetching loop stat: \(error)")
+                return nil
             }
+        }
+    }
 
-            // Determine start date based on selected duration
-            let startDate: Date
-            switch self.selectedDurationForLoopStats {
-            case .Day,
-                 .Today:
-                startDate = calendar.date(byAdding: .day, value: -2, to: calendar.startOfDay(for: now))!
-            case .Week:
-                startDate = calendar.date(byAdding: .day, value: -7, to: calendar.startOfDay(for: now))!
-            case .Month:
-                startDate = calendar.date(byAdding: .month, value: -1, to: calendar.startOfDay(for: now))!
-            case .Total:
-                startDate = calendar.date(byAdding: .month, value: -3, to: calendar.startOfDay(for: now))!
-            }
+    /// Calculates loop and glucose statistics based on the provided record IDs
+    /// - Parameters:
+    ///   - allLoopIds: Array of NSManagedObjectIDs for all loop records
+    ///   - failedLoopIds: Array of NSManagedObjectIDs for failed loop records
+    ///   - duration: The time period for statistics calculation
+    /// - Returns: Array of tuples containing category, count and percentage for each statistic
+    func getLoopStats(
+        allLoopIds: [NSManagedObjectID],
+        failedLoopIds: [NSManagedObjectID],
+        duration: Duration
+    ) async -> [(category: String, count: Int, percentage: Double)] {
+        // Calculate the date range for glucose readings
+        let now = Date()
+        let startDate: Date
+        switch duration {
+        case .Day:
+            startDate = Calendar.current.startOfDay(for: now)
+        case .Today:
+            startDate = now.addingTimeInterval(-24.hours.timeInterval)
+        case .Week:
+            startDate = now.addingTimeInterval(-7.days.timeInterval)
+        case .Month:
+            startDate = now.addingTimeInterval(-30.days.timeInterval)
+        case .Total:
+            startDate = now.addingTimeInterval(-90.days.timeInterval)
+        }
 
-            // Create array of all dates in the range
-            var dates: [Date] = []
-            var currentDate = startDate
-            while currentDate <= now {
-                dates.append(calendar.startOfDay(for: currentDate))
-                currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
-            }
+        // Get glucose statistics
+        let totalGlucose = await calculateGlucoseStats(from: startDate, to: now)
 
-            // Group records by day
-            let recordsByDay = Dictionary(grouping: records) { record in
-                guard let date = record.start else { return Date() }
-                return calendar.startOfDay(for: date)
-            }
+        // Get NSManagedObject
+        let allLoops = await CoreDataStack.shared.getNSManagedObject(with: allLoopIds, context: loopTaskContext)
+        let failedLoops = await CoreDataStack.shared.getNSManagedObject(with: failedLoopIds, context: loopTaskContext)
 
-            // Create stats for each day, including days with no data
-            return dates.map { date in
-                let dayRecords = recordsByDay[date, default: []]
-                let successful = dayRecords.filter { $0.loopStatus?.contains("Success") ?? false }.count
-                let failed = dayRecords.count - successful
-
-                // Calculate glucose count for the period
-                let glucoseFetchRequest = GlucoseStored.fetchRequest()
-                let periodStart = date
-                let periodEnd = calendar.date(byAdding: .day, value: 1, to: date)!
-
-                glucoseFetchRequest.predicate = NSPredicate(
-                    format: "date >= %@ AND date < %@",
-                    periodStart as NSDate,
-                    periodEnd as NSDate
-                )
-
-                var glucoseCount = 0
-                do {
-                    glucoseCount = try self.loopTaskContext.count(for: glucoseFetchRequest)
-                } catch {
-                    debugPrint("\(DebuggingIdentifiers.failed) Error counting glucose readings: \(error)")
-                }
-
-                return LoopStatsByPeriod(
-                    period: date,
-                    successful: successful,
-                    failed: failed,
-                    medianDuration: BareStatisticsView
-                        .medianCalculationDouble(array: dayRecords.compactMap { $0.duration as Double? }),
-                    glucoseCount: glucoseCount
-                )
-            }.sorted { $0.period < $1.period }
+        return await loopTaskContext.perform {
+            let totalLoopsCount = allLoops.count
+            let failedLoopsCount = failedLoops.count
+            let successfulLoops = totalLoopsCount - failedLoopsCount
+            let maxLoopsPerDay = 288.0 // Maximum possible loops per day (every 5 minutes)
+
+            switch duration {
+            case .Day:
+                // For Day view: Calculate percentage based on maximum possible loops per day
+                let loopPercentage = (Double(successfulLoops) / maxLoopsPerDay) * 100
+                let glucosePercentage = (Double(totalGlucose) / maxLoopsPerDay) * 100
+
+                return [
+                    ("Loop Success Rate", successfulLoops, loopPercentage),
+                    ("Glucose Count", totalGlucose, glucosePercentage)
+                ]
+
+            case .Month,
+                 .Today,
+                 .Total,
+                 .Week:
+                // For other views: Calculate average per day
+                let numberOfDays = max(1, Calendar.current.dateComponents([.day], from: startDate, to: now).day ?? 1)
+
+                let averageLoopsPerDay = Double(successfulLoops) / Double(numberOfDays)
+                let averageGlucosePerDay = Double(totalGlucose) / Double(numberOfDays)
+
+                let loopPercentage = (averageLoopsPerDay / maxLoopsPerDay) * 100
+                let glucosePercentage = (averageGlucosePerDay / maxLoopsPerDay) * 100
+
+                return [
+                    ("Successful Loops", Int(round(averageLoopsPerDay)), loopPercentage),
+                    ("Glucose Count", Int(round(averageGlucosePerDay)), glucosePercentage)
+                ]
+            }
         }
     }
 
-    /// Updates the loopStatRecords array on the main thread with records from the provided IDs
-    /// - Parameter ids: Array of NSManagedObjectIDs for loop stat records
-    @MainActor func updateLoopStatRecords(from ids: [NSManagedObjectID]) {
-        loopStatRecords = ids.compactMap { id -> LoopStatRecord? in
-            do {
-                return try viewContext.existingObject(with: id) as? LoopStatRecord
-            } catch {
-                debugPrint("Error fetching loop stat: \(error)")
-                return nil
+    /// Fetches and calculates glucose statistics for the given time period
+    /// - Parameters:
+    ///   - startDate: The start date of the period to analyze
+    ///   - now: The current date (end of period)
+    /// - Returns: Number of glucose readings in the period
+    private func calculateGlucoseStats(
+        from startDate: Date,
+        to _: Date
+    ) async -> Int {
+        // Create predicate for glucose readings
+        let glucosePredicate = NSPredicate(format: "date >= %@", startDate as NSDate)
+
+        // Fetch glucose readings asynchronously
+        let glucoseResult = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: loopTaskContext,
+            predicate: glucosePredicate,
+            key: "date",
+            ascending: false
+        )
+
+        return await loopTaskContext.perform {
+            guard let readings = glucoseResult as? [GlucoseStored] else {
+                return 0
             }
+            return readings.count
         }
     }
 }

+ 1 - 0
FreeAPS/Sources/Modules/Stat/StatStateModel.swift

@@ -48,6 +48,7 @@ extension Stat {
         var units: GlucoseUnits = .mgdL
         var glucoseFromPersistence: [GlucoseStored] = []
         var loopStatRecords: [LoopStatRecord] = []
+        var loopStats: [(category: String, count: Int, percentage: Double)] = []
         var groupedLoopStats: [LoopStatsByPeriod] = []
         var tddStats: [TDD] = []
         var bolusStats: [BolusStats] = []

+ 1 - 1
FreeAPS/Sources/Modules/Stat/View/StatRootView.swift

@@ -263,7 +263,7 @@ extension Stat {
                     LoopStatsView(
                         loopStatRecords: state.loopStatRecords,
                         selectedDuration: state.selectedDurationForLoopStats,
-                        groupedStats: state.groupedLoopStats
+                        statsData: state.loopStats
                     )
                 }
             }

+ 54 - 167
FreeAPS/Sources/Modules/Stat/View/ViewElements/LoopStatsView.swift

@@ -4,188 +4,75 @@ import SwiftUI
 struct LoopStatsView: View {
     let loopStatRecords: [LoopStatRecord]
     let selectedDuration: Stat.StateModel.Duration
-    let groupedStats: [LoopStatsByPeriod]
-    private let calendar = Calendar.current
-
-    private var medianLoopDuration: Double {
-        groupedStats.first?.medianDuration ?? 0
-    }
+    let statsData: [(category: String, count: Int, percentage: Double)]
 
     var body: some View {
         VStack(spacing: 20) {
-            loopDurationChart
-            Divider()
-            loopStatsChart
-        }
-    }
-
-    private var loopDurationChart: some View {
-        Chart {
-            ForEach(loopStatRecords, id: \.id) { record in
-                LineMark(
-                    x: .value("Time", record.start ?? Date(), unit: .hour),
-                    y: .value("Duration", record.duration / 1000)
+            Chart(statsData, id: \.category) { data in
+                BarMark(
+                    x: .value("Percentage", data.percentage),
+                    y: .value("Category", data.category)
                 )
-                .interpolationMethod(.catmullRom)
-                .foregroundStyle(.blue.opacity(0.6))
-            }
-
-            RuleMark(
-                y: .value("Median", medianLoopDuration / 1000)
-            )
-            .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
-            .foregroundStyle(.orange)
-            .annotation(position: .top, alignment: .trailing) {
-                Text("\((medianLoopDuration / 1000).formatted(.number.precision(.fractionLength(1)))) s")
-                    .font(.caption)
-                    .foregroundStyle(.orange)
-            }
-        }
-        .chartYAxis {
-            loopDurationChartYAxisMarks
-        }
-        .chartYAxisLabel(alignment: .leading) {
-            Text("Loop duration")
-                .foregroundStyle(.primary)
-                .font(.caption)
-                .padding(.vertical, 3)
-        }
-        .chartXAxis {
-            loopDurationAxisMarks
-        }
-        .frame(height: 200)
-        .padding(.horizontal)
-    }
-
-    private var loopDurationAxisMarks: some AxisContent {
-        AxisMarks { value in
-            if let date = value.as(Date.self) {
-                AxisValueLabel {
-                    switch selectedDuration {
-                    case .Day,
-                         .Today:
-                        Text(date, format: .dateTime.hour(.defaultDigits(amPM: .abbreviated)))
-                    case .Week:
-                        Text(date, format: .dateTime.weekday(.abbreviated))
-                    case .Month,
-                         .Total:
-                        Text(date, format: .dateTime.day().month(.defaultDigits))
+                .cornerRadius(5)
+                .foregroundStyle(data.category == "Successful Loops" ? Color.blue.gradient : Color.green.gradient)
+                .annotation(position: .overlay) {
+                    HStack {
+                        Text(annotationText(for: data))
+                            .font(.callout)
+                            .foregroundStyle(.white)
                     }
                 }
-                AxisGridLine()
             }
-        }
-    }
-
-    private var loopDurationChartYAxisMarks: some AxisContent {
-        AxisMarks(position: .leading) { value in
-            if let duration = value.as(Double.self) {
-                AxisValueLabel {
-                    Text("\(duration.formatted(.number.precision(.fractionLength(1)))) s")
-                        .font(.caption)
+            .chartYAxis {
+                AxisMarks { value in
+                    if let category = value.as(String.self) {
+                        AxisValueLabel {
+                            Text(category)
+                                .font(.footnote)
+                        }
+                    }
                 }
-                AxisGridLine()
             }
-        }
-    }
-
-    private var loopStatsChart: some View {
-        Chart {
-            ForEach(groupedStats) { stat in
-                // Stacked Bar Chart first (will be in background)
-                // Succeeded Loops
-                BarMark(
-                    x: .value("Time", stat.period, unit: .day),
-                    y: .value("Successful", stat.successPercentage)
-                )
-                .foregroundStyle(Color.green.opacity(0.9))
-                .foregroundStyle(by: .value("Type", "Success"))
-//                .zIndex(1)
-
-                // Failed Loops
-                BarMark(
-                    x: .value("Time", stat.period, unit: .day),
-                    y: .value("Failed", stat.failurePercentage)
-                )
-                .foregroundStyle(Color.red.opacity(0.9))
-                .foregroundStyle(by: .value("Type", "Failed"))
-//                .zIndex(1)
-
-                // Dotted Line Mark showing the daily Glucose counts (will overlay the bars)
-                LineMark(
-                    x: .value("Time", stat.period, unit: .day),
-                    y: .value("Glucose Count", Double(stat.glucoseCount) / 288.0 * 100)
-                )
-                .foregroundStyle(Color.blue)
-                .lineStyle(StrokeStyle(lineWidth: 2))
-                .foregroundStyle(by: .value("Type", "Glucose Count"))
-//                .zIndex(2)
-
-                PointMark(
-                    x: .value("Time", stat.period, unit: .day),
-                    y: .value("Glucose Count", Double(stat.glucoseCount) / 288.0 * 100)
-                )
-                .foregroundStyle(Color.blue)
-                .symbolSize(50)
-                .foregroundStyle(by: .value("Type", "Glucose Count"))
-//                .zIndex(3)
-            }
-        }
-        .chartForegroundStyleScale([
-            "Success": Color.green,
-            "Failed": Color.red,
-            "Glucose Count": Color.blue
-        ])
-        .chartYAxis {
-            AxisMarks(position: .leading) { value in
-                if let percent = value.as(Double.self) {
-                    AxisValueLabel {
-                        Text("\(percent.formatted(.number.precision(.fractionLength(0))))%")
-                            .font(.caption)
+            .chartXAxis {
+                AxisMarks(position: .bottom) { value in
+                    if let percentage = value.as(Double.self) {
+                        AxisValueLabel {
+                            Text("\(Int(percentage))%")
+                                .font(.footnote)
+                        }
+                        AxisGridLine()
                     }
-                    AxisGridLine()
                 }
             }
-
-            let maxPossibleReadings = 288.0
-            let strideBy = 4.0
-            let defaultStride = Array(stride(from: 0, to: 100, by: 100 / strideBy))
-            let glucoseStride = Array(stride(from: 0, through: maxPossibleReadings, by: maxPossibleReadings / strideBy))
-
-            AxisMarks(position: .trailing, values: defaultStride) { axis in
-                let value = glucoseStride[axis.index]
-                AxisValueLabel("\(Int(value))", centered: true)
-                    .font(.caption)
-            }
+            .chartXScale(domain: 0 ... 100)
+            .frame(height: 200)
+            .padding()
         }
-        .chartYAxisLabel(alignment: .leading) {
-            Text("Loop Success Rate")
-                .foregroundStyle(.primary)
-                .font(.caption)
-                .padding(.vertical, 3)
-        }
-        .chartXAxis {
-            statsAxisMarks
-        }
-        .frame(height: 200)
-        .padding(.horizontal)
     }
 
-    private var statsAxisMarks: some AxisContent {
-        AxisMarks { value in
-            if let date = value.as(Date.self) {
-                AxisValueLabel {
-                    switch selectedDuration {
-                    case .Day,
-                         .Today,
-                         .Week:
-                        Text(date, format: .dateTime.weekday(.abbreviated))
-                    case .Month,
-                         .Total:
-                        Text(date, format: .dateTime.day().month(.defaultDigits))
-                    }
-                }
-                AxisGridLine()
+    private func annotationText(for data: (category: String, count: Int, percentage: Double)) -> String {
+        if data.category == "Loop Success Rate" {
+            switch selectedDuration {
+            case .Day,
+                 .Today:
+                return "\(data.count) Loops"
+            case .Month,
+                 .Total,
+                 .Week:
+                let maxLoopsPerDay = 288.0
+                let averageLoopsPerDay = Double(data.count) / maxLoopsPerDay * 100
+                return "\(Int(round(averageLoopsPerDay))) Loops per day"
+            }
+        } else {
+            // For Glucose Count, show different text based on duration
+            switch selectedDuration {
+            case .Day,
+                 .Today:
+                return "\(data.count) Readings"
+            case .Month,
+                 .Total,
+                 .Week:
+                return "\(data.count) Readings per day"
             }
         }
     }

+ 4 - 4
FreeAPS/Sources/Modules/Stat/View/ViewElements/SectorChart.swift

@@ -32,7 +32,7 @@ struct SectorChart: View {
                         outerRadius: selectedRange == data.range ? 100 : 80,
                         angularInset: 1.5
                     )
-                    .cornerRadius(8)
+                    .cornerRadius(3)
                     .foregroundStyle(data.color.gradient)
                     .annotation(position: .overlay, alignment: .center, spacing: 0) {
                         if data.percentage > 0 {
@@ -47,9 +47,9 @@ struct SectorChart: View {
             .chartLegend(position: .bottom, spacing: 20)
             .chartAngleSelection(value: $selectedCount)
             .chartForegroundStyleScale([
-                "High": Color.orange.gradient,
-                "In Range": Color.green.gradient,
-                "Low": Color.red.gradient
+                "High": Color.orange,
+                "In Range": Color.green,
+                "Low": Color.red
             ])
             .padding(.vertical)
             .frame(height: 250)