LoopChartSetup.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import CoreData
  2. import Foundation
  3. /// Represents statistical data about loop execution success/failure for a specific time period
  4. struct LoopStatsByPeriod: Identifiable {
  5. /// The date representing this time period
  6. let period: Date
  7. /// Number of successful loop executions in this period
  8. let successful: Int
  9. /// Number of failed loop executions in this period
  10. let failed: Int
  11. /// Median duration of loop executions in this period
  12. let medianDuration: Double
  13. /// Number of glucose measurements in this period
  14. let glucoseCount: Int
  15. /// Total number of loop executions in this period
  16. var total: Int { successful + failed }
  17. /// Percentage of successful loops (0-100)
  18. var successPercentage: Double { total > 0 ? Double(successful) / Double(total) * 100 : 0 }
  19. /// Percentage of failed loops (0-100)
  20. var failurePercentage: Double { total > 0 ? Double(failed) / Double(total) * 100 : 0 }
  21. /// Unique identifier for this period, using the period date
  22. var id: Date { period }
  23. }
  24. struct LoopStatsProcessedData: Identifiable {
  25. var id = UUID()
  26. let category: LoopStatsDataType
  27. let count: Int
  28. let percentage: Double
  29. let medianDuration: Double
  30. let medianInterval: Double
  31. let totalDays: Int
  32. }
  33. enum LoopStatsDataType: String {
  34. case successfulLoop
  35. case glucoseCount
  36. var displayName: String {
  37. switch self {
  38. case .successfulLoop: return String(localized: "Successful Loops")
  39. case .glucoseCount: return String(localized: "Glucose Count")
  40. }
  41. }
  42. }
  43. extension Stat.StateModel {
  44. /// Initiates the process of fetching and processing loop statistics
  45. /// This function coordinates three main tasks:
  46. /// 1. Fetching loop stat record IDs for the selected duration
  47. /// 2. Calculating grouped statistics for the Loop stats chart
  48. /// 3. Updating loop stat records on the main thread (!) for the Loop duration chart
  49. func setupLoopStatRecords() {
  50. Task {
  51. do {
  52. let (recordIDs, failedRecordIDs) = try await self.fetchLoopStatRecords(for: selectedIntervalForLoopStats)
  53. // Update loop records for duration chart
  54. await self.updateLoopStatRecords(allLoopIds: recordIDs)
  55. // Calculate statistics and update on main thread
  56. let stats = try await self.getLoopStats(
  57. allLoopIds: recordIDs,
  58. failedLoopIds: failedRecordIDs,
  59. interval: selectedIntervalForLoopStats
  60. )
  61. await MainActor.run {
  62. self.loopStats = stats
  63. }
  64. } catch {
  65. debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch loop stats: \(error)")
  66. }
  67. }
  68. }
  69. /// Fetches loop statistics records for the specified duration
  70. /// - Parameter interval: The time period to fetch records for
  71. /// - Returns: A tuple containing arrays of NSManagedObjectIDs for (all loops, failed loops)
  72. func fetchLoopStatRecords(for interval: StatsTimeIntervalWithToday) async throws
  73. -> ([NSManagedObjectID], [NSManagedObjectID])
  74. {
  75. // Calculate the date range based on selected duration
  76. let now = Date()
  77. let startDate: Date
  78. switch interval {
  79. case .day:
  80. startDate = now.addingTimeInterval(-24.hours.timeInterval)
  81. case .today:
  82. startDate = Calendar.current.startOfDay(for: now)
  83. case .week:
  84. startDate = now.addingTimeInterval(-7.days.timeInterval)
  85. case .month:
  86. startDate = now.addingTimeInterval(-30.days.timeInterval)
  87. case .total:
  88. startDate = now.addingTimeInterval(-90.days.timeInterval)
  89. }
  90. // Perform both fetches asynchronously
  91. async let allLoopsResult = CoreDataStack.shared.fetchEntitiesAsync(
  92. ofType: LoopStatRecord.self,
  93. onContext: loopTaskContext,
  94. predicate: NSPredicate(format: "start > %@", startDate as NSDate),
  95. key: "start",
  96. ascending: false
  97. )
  98. async let failedLoopsResult = CoreDataStack.shared.fetchEntitiesAsync(
  99. ofType: LoopStatRecord.self,
  100. onContext: loopTaskContext,
  101. predicate: NSPredicate(
  102. format: "start > %@ AND loopStatus != %@",
  103. startDate as NSDate,
  104. "Success"
  105. ),
  106. key: "start",
  107. ascending: false
  108. )
  109. // Wait for both results and convert to object IDs
  110. let (allLoops, failedLoops) = try await (allLoopsResult, failedLoopsResult)
  111. return (
  112. (allLoops as? [LoopStatRecord] ?? []).map(\.objectID),
  113. (failedLoops as? [LoopStatRecord] ?? []).map(\.objectID)
  114. )
  115. }
  116. /// Updates the loopStatRecords array on the main thread with records from the provided IDs
  117. /// - Parameters:
  118. /// - allLoopIds: Array of NSManagedObjectIDs for all loop records
  119. @MainActor func updateLoopStatRecords(allLoopIds: [NSManagedObjectID]) {
  120. loopStatRecords = allLoopIds.compactMap { id -> LoopStatRecord? in
  121. do {
  122. return try viewContext.existingObject(with: id) as? LoopStatRecord
  123. } catch {
  124. debugPrint("\(DebuggingIdentifiers.failed) Error fetching loop stat: \(error)")
  125. return nil
  126. }
  127. }
  128. }
  129. /// Calculates loop and glucose statistics based on the provided record IDs
  130. /// - Parameters:
  131. /// - allLoopIds: Array of NSManagedObjectIDs for all loop records
  132. /// - failedLoopIds: Array of NSManagedObjectIDs for failed loop records
  133. /// - interval: The time period for statistics calculation
  134. /// - Returns: Array of tuples containing category, count and percentage for each statistic
  135. func getLoopStats(
  136. allLoopIds: [NSManagedObjectID],
  137. failedLoopIds: [NSManagedObjectID],
  138. interval: StatsTimeIntervalWithToday
  139. ) async throws
  140. -> [LoopStatsProcessedData]
  141. {
  142. // Calculate the date range for glucose readings
  143. let now = Date()
  144. let startDate: Date
  145. switch interval {
  146. case .day:
  147. startDate = now.addingTimeInterval(-24.hours.timeInterval)
  148. case .today:
  149. startDate = Calendar.current.startOfDay(for: now)
  150. case .week:
  151. startDate = now.addingTimeInterval(-7.days.timeInterval)
  152. case .month:
  153. startDate = now.addingTimeInterval(-30.days.timeInterval)
  154. case .total:
  155. startDate = now.addingTimeInterval(-90.days.timeInterval)
  156. }
  157. // Get glucose statistics
  158. let totalGlucose = try await calculateGlucoseStats(from: startDate, to: now)
  159. // Get NSManagedObject
  160. let allLoops = try await CoreDataStack.shared
  161. .getNSManagedObject(with: allLoopIds, context: loopTaskContext) as? [LoopStatRecord] ?? []
  162. let failedLoops = try await CoreDataStack.shared
  163. .getNSManagedObject(with: failedLoopIds, context: loopTaskContext) as? [LoopStatRecord] ?? []
  164. return await loopTaskContext.perform {
  165. let totalLoopsCount = allLoops.count
  166. let failedLoopsCount = failedLoops.count
  167. let successfulLoops = totalLoopsCount - failedLoopsCount
  168. let maxLoopsPerDay = 288.0 // Maximum possible loops per day (every 5 minutes)
  169. let numberOfDays = max(1, Calendar.current.dateComponents([.day], from: startDate, to: now).day ?? 1)
  170. let averageLoopsPerDay = Double(successfulLoops) / Double(numberOfDays)
  171. let averageGlucosePerDay = Double(totalGlucose) / Double(numberOfDays)
  172. // Calculate median duration (time from start to end of each loop)
  173. let sortedDurations: [TimeInterval] = allLoops.compactMap { loop in
  174. guard let start = loop.start, let end = loop.end else { return nil }
  175. return end.timeIntervalSince(start)
  176. }.sorted()
  177. let medianDuration = sortedDurations.isEmpty ? 0.0 : sortedDurations[sortedDurations.count / 2]
  178. // Calculate median interval (time between end of n-th loop and start of n+1th loop)
  179. let sortedIntervals: [TimeInterval] = zip(allLoops.dropLast(), allLoops.dropFirst()).compactMap { previous, next in
  180. guard let previousEnd = previous.end, let nextStart = next.start else { return nil }
  181. return previousEnd.timeIntervalSince(nextStart)
  182. }.sorted()
  183. let medianInterval = sortedIntervals.isEmpty ? 0.0 : sortedIntervals[sortedIntervals.count / 2]
  184. let loopPercentage = (averageLoopsPerDay / maxLoopsPerDay) * 100
  185. let glucosePercentage = (averageGlucosePerDay / maxLoopsPerDay) * 100
  186. return [
  187. LoopStatsProcessedData(
  188. category: LoopStatsDataType.successfulLoop,
  189. count: Int(round(averageLoopsPerDay)),
  190. percentage: loopPercentage,
  191. medianDuration: medianDuration,
  192. medianInterval: medianInterval,
  193. totalDays: numberOfDays
  194. ),
  195. LoopStatsProcessedData(
  196. category: LoopStatsDataType.glucoseCount,
  197. count: Int(round(averageGlucosePerDay)),
  198. percentage: glucosePercentage,
  199. medianDuration: medianDuration,
  200. medianInterval: medianInterval,
  201. totalDays: numberOfDays
  202. )
  203. ]
  204. }
  205. }
  206. /// Fetches and calculates glucose statistics for the given time period
  207. /// - Parameters:
  208. /// - startDate: The start date of the period to analyze
  209. /// - now: The current date (end of period)
  210. /// - Returns: Number of glucose readings in the period
  211. private func calculateGlucoseStats(
  212. from startDate: Date,
  213. to _: Date
  214. ) async throws -> Int {
  215. // Create predicate for glucose readings
  216. let glucosePredicate = NSPredicate(format: "date >= %@", startDate as NSDate)
  217. // Fetch glucose readings asynchronously
  218. let glucoseResult = try await CoreDataStack.shared.fetchEntitiesAsync(
  219. ofType: GlucoseStored.self,
  220. onContext: loopTaskContext,
  221. predicate: glucosePredicate,
  222. key: "date",
  223. ascending: false
  224. )
  225. return await loopTaskContext.perform {
  226. guard let readings = glucoseResult as? [GlucoseStored] else {
  227. return 0
  228. }
  229. return readings.count
  230. }
  231. }
  232. }