AreaChartSetup.swift 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import CoreData
  2. import Foundation
  3. /// Represents statistical values for glucose readings grouped by hour of the day.
  4. ///
  5. /// This struct contains various percentile calculations that help visualize
  6. /// glucose distribution patterns throughout the day:
  7. ///
  8. /// - The median (50th percentile) shows the central tendency
  9. /// - The 25th and 75th percentiles form the interquartile range (IQR)
  10. /// - The 10th and 90th percentiles show the wider range of values
  11. ///
  12. /// Example usage in visualization:
  13. /// ```
  14. /// let stats = HourlyStats(
  15. /// hour: 14, // 2 PM
  16. /// median: 120, // Center line
  17. /// percentile25: 100, // Lower bound of dark band
  18. /// percentile75: 140, // Upper bound of dark band
  19. /// percentile10: 80, // Lower bound of light band
  20. /// percentile90: 160 // Upper bound of light band
  21. /// )
  22. /// ```
  23. ///
  24. /// This data structure is used to create area charts showing glucose
  25. /// variability patterns across different times of day.
  26. public struct HourlyStats: Equatable {
  27. /// The hour of day (0-23) these statistics represent
  28. let hour: Int
  29. /// The median (50th percentile) glucose value for this hour
  30. let median: Double
  31. /// The 25th percentile glucose value (lower quartile)
  32. let percentile25: Double
  33. /// The 75th percentile glucose value (upper quartile)
  34. let percentile75: Double
  35. /// The 10th percentile glucose value (lower whisker)
  36. let percentile10: Double
  37. /// The 90th percentile glucose value (upper whisker)
  38. let percentile90: Double
  39. }
  40. extension Double {
  41. var isEven: Bool {
  42. truncatingRemainder(dividingBy: 2) == 0
  43. }
  44. }
  45. extension Stat.StateModel {
  46. /// Calculates hourly statistical values (median, percentiles) from glucose readings.
  47. /// The calculation runs asynchronously using the CoreData context.
  48. ///
  49. /// The calculation works as follows:
  50. /// 1. Group readings by hour of day (0-23)
  51. /// 2. For each hour:
  52. /// - Sort glucose values
  53. /// - Calculate median (50th percentile)
  54. /// - Calculate 10th, 25th, 75th, and 90th percentiles
  55. ///
  56. /// Example:
  57. /// For readings at 6:00 AM across multiple days:
  58. /// ```
  59. /// Readings: [80, 100, 120, 140, 160, 180, 200]
  60. /// Results:
  61. /// - 10th percentile: 84 (lower whisker)
  62. /// - 25th percentile: 110 (lower band)
  63. /// - median: 140 (center line)
  64. /// - 75th percentile: 170 (upper band)
  65. /// - 90th percentile: 196 (upper whisker)
  66. /// ```
  67. ///
  68. /// The resulting statistics are used to show:
  69. /// - A dark blue area for the interquartile range (25th-75th percentile)
  70. /// - A light blue area for the wider range (10th-90th percentile)
  71. /// - A solid blue line for the median
  72. func calculateHourlyStatsForGlucoseAreaChart(from ids: [NSManagedObjectID]) async {
  73. let taskContext = CoreDataStack.shared.newTaskContext()
  74. let calendar = Calendar.current
  75. let stats = await taskContext.perform {
  76. // Convert IDs to GlucoseStored objects using the context
  77. let readings = ids.compactMap { id -> GlucoseStored? in
  78. do {
  79. return try taskContext.existingObject(with: id) as? GlucoseStored
  80. } catch {
  81. debugPrint("\(DebuggingIdentifiers.failed) Error fetching glucose: \(error)")
  82. return nil
  83. }
  84. }
  85. // Group readings by hour of day (0-23)
  86. // Example: [8: [reading1, reading2], 9: [reading3, reading4, reading5], ...]
  87. let groupedByHour = Dictionary(grouping: readings) { reading in
  88. calendar.component(.hour, from: reading.date ?? Date())
  89. }
  90. // Process each hour of the day (0-23)
  91. return (0 ... 23).map { hour in
  92. // Get all readings for this hour (or empty if none)
  93. let readings = groupedByHour[hour] ?? []
  94. // Extract and sort glucose values for percentile calculations
  95. // Example: [100, 120, 130, 140, 150, 160, 180]
  96. let values = readings.map { Double($0.glucose) }.sorted()
  97. let count = Double(values.count)
  98. // Handle hours with no readings
  99. guard !values.isEmpty else {
  100. return HourlyStats(
  101. hour: hour,
  102. median: 0,
  103. percentile25: 0,
  104. percentile75: 0,
  105. percentile10: 0,
  106. percentile90: 0
  107. )
  108. }
  109. // Calculate median
  110. // For even count: average of two middle values
  111. // For odd count: middle value
  112. let median = count.isEven ?
  113. (values[Int(count / 2) - 1] + values[Int(count / 2)]) / 2 :
  114. values[Int(count / 2)]
  115. // Create statistics object with all percentiles
  116. // Index calculation: multiply count by desired percentile (0.25 for 25th)
  117. return HourlyStats(
  118. hour: hour,
  119. median: median,
  120. percentile25: values[Int(count * 0.25)], // Lower quartile
  121. percentile75: values[Int(count * 0.75)], // Upper quartile
  122. percentile10: values[Int(count * 0.10)], // Lower whisker
  123. percentile90: values[Int(count * 0.90)] // Upper whisker
  124. )
  125. }
  126. }
  127. // Update stats on main thread
  128. await MainActor.run {
  129. self.hourlyStats = stats
  130. }
  131. }
  132. }