GlucoseStatsSetup.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import CoreData
  2. import Foundation
  3. /// A thread-safe value type to hold glucose data without Core Data dependencies
  4. struct GlucoseReading {
  5. let value: Int
  6. let date: Date
  7. }
  8. /// Represents statistical data for daily glucose metrics by distribution ranges
  9. struct GlucoseDailyDistributionStats: Identifiable {
  10. let id = UUID()
  11. /// The date this data represents
  12. let date: Date
  13. /// The time-in-range type used for calculations
  14. let timeInRangeType: TimeInRangeType
  15. /// The original glucose readings
  16. let readings: [GlucoseStored]
  17. /// Percentage of glucose readings below 54 mg/dL
  18. let veryLowPct: Double
  19. /// Percentage of glucose readings in the [54 – lowLimit] mg/dL range
  20. let lowPct: Double
  21. /// Percentage of glucose readings within the tighter control range of [bottomThreshold – topThreshold] mg/dL
  22. let inSmallRangePct: Double
  23. /// Percentage of glucose readings within the target range of [bottomThreshold – highLimit] mg/dL
  24. let inRangePct: Double
  25. /// Percentage of glucose readings in the (highLimit – 250] mg/dL range
  26. let highPct: Double
  27. /// Percentage of glucose readings above 250 mg/dL
  28. let veryHighPct: Double
  29. init(
  30. date: Date,
  31. timeInRangeType: TimeInRangeType,
  32. readings: [GlucoseStored] = [GlucoseStored](),
  33. veryLowPct: Double = 0,
  34. lowPct: Double = 0,
  35. inSmallRangePct: Double = 0,
  36. inRangePct: Double = 0,
  37. highPct: Double = 0,
  38. veryHighPct: Double = 0
  39. ) {
  40. self.date = date
  41. self.timeInRangeType = timeInRangeType
  42. self.readings = readings
  43. self.veryLowPct = veryLowPct
  44. self.lowPct = lowPct
  45. self.inSmallRangePct = inSmallRangePct
  46. self.inRangePct = inRangePct
  47. self.highPct = highPct
  48. self.veryHighPct = veryHighPct
  49. }
  50. }
  51. /// Represents percentile-based statistical data for daily glucose metrics
  52. struct GlucoseDailyPercentileStats: Identifiable {
  53. let id = UUID()
  54. /// The date this data represents
  55. let date: Date
  56. /// The original glucose readings
  57. let readings: [GlucoseStored]
  58. /// Minimum glucose value
  59. let minimum: Double
  60. /// 10th percentile glucose value
  61. let percentile10: Double
  62. /// 25th percentile glucose value (lower quartile)
  63. let percentile25: Double
  64. /// Median (50th percentile) glucose value
  65. let median: Double
  66. /// 75th percentile glucose value (upper quartile)
  67. let percentile75: Double
  68. /// 90th percentile glucose value
  69. let percentile90: Double
  70. /// Maximum glucose value
  71. let maximum: Double
  72. init(
  73. date: Date,
  74. readings: [GlucoseStored] = [GlucoseStored](),
  75. minimum: Double = 0,
  76. percentile10: Double = 0,
  77. percentile25: Double = 0,
  78. median: Double = 0,
  79. percentile75: Double = 0,
  80. percentile90: Double = 0,
  81. maximum: Double = 0
  82. ) {
  83. self.date = date
  84. self.readings = readings
  85. self.minimum = minimum
  86. self.percentile10 = percentile10
  87. self.percentile25 = percentile25
  88. self.median = median
  89. self.percentile75 = percentile75
  90. self.percentile90 = percentile90
  91. self.maximum = maximum
  92. }
  93. }
  94. extension Stat.StateModel {
  95. /// Performs setup for both percentile and distribution glucose statistics from provided IDs
  96. ///
  97. /// This method optimizes performance by:
  98. /// 1. Computing both percentile and distribution statistics concurrently
  99. /// 2. Creating lookup caches for both stat types simultaneously
  100. ///
  101. /// - Parameter ids: Array of NSManagedObjectIDs for glucose readings
  102. @MainActor func setupGlucoseStats(with ids: [NSManagedObjectID]) async {
  103. // Get dates for the past 90 days
  104. let dates = getDates()
  105. // Calculate both types of statistics concurrently
  106. async let percentileStats = calculateDailyPercentileStats(
  107. for: dates,
  108. glucoseIDs: ids
  109. )
  110. async let distributionStats = calculateDailyDistributionStats(
  111. for: dates,
  112. glucoseIDs: ids,
  113. highLimit: highLimit,
  114. timeInRangeType: timeInRangeType
  115. )
  116. let (pStats, dStats) = await (percentileStats, distributionStats)
  117. dailyGlucosePercentileStats = pStats
  118. glucosePercentileCache = Dictionary(
  119. uniqueKeysWithValues: pStats.map {
  120. (Calendar.current.startOfDay(for: $0.date), $0)
  121. }
  122. )
  123. dailyGlucoseDistributionStats = dStats
  124. glucoseDistributionCache = Dictionary(
  125. uniqueKeysWithValues: dStats.map {
  126. (Calendar.current.startOfDay(for: $0.date), $0)
  127. }
  128. )
  129. }
  130. /// Generates an array of dates for the specified number of days
  131. /// - Parameter daysCount: Number of days to generate
  132. /// - Returns: Array of dates starting from (today - daysCount) to today
  133. func getDates() -> [Date] {
  134. let calendar = Calendar.current
  135. let today = calendar.startOfDay(for: Date())
  136. return (0 ..< 90).map { dayOffset -> Date in
  137. calendar.startOfDay(for: calendar.date(byAdding: .day, value: -(89 - dayOffset), to: today)!)
  138. }
  139. }
  140. /// Processes glucose readings for a set of dates in a thread-safe manner
  141. /// - Parameters:
  142. /// - dates: Array of dates to process data for
  143. /// - glucoseIDs: Array of NSManagedObjectIDs for glucose readings
  144. /// - Returns: Array of (date, readings) tuples containing filtered readings for each date
  145. private func processGlucoseReadingsForDates(
  146. _ dates: [Date],
  147. glucoseIDs: [NSManagedObjectID]
  148. ) async -> [(date: Date, readings: [GlucoseReading])] {
  149. let calendar = Calendar.current
  150. // Handle cancellation early
  151. if Task.isCancelled {
  152. return []
  153. }
  154. // Extract the thread-safe glucose readings
  155. let privateContext = CoreDataStack.shared.newTaskContext()
  156. var glucoseReadings: [GlucoseReading] = []
  157. await privateContext.perform {
  158. let readings = glucoseIDs.compactMap { privateContext.object(with: $0) as? GlucoseStored }
  159. glucoseReadings = readings.compactMap { reading in
  160. guard let date = reading.date else { return nil }
  161. return GlucoseReading(value: Int(reading.glucose), date: date)
  162. }
  163. }
  164. return await withTaskGroup(of: (date: Date, readings: [GlucoseReading]).self) { group in
  165. for date in dates {
  166. group.addTask {
  167. let dayStart = calendar.startOfDay(for: date)
  168. let dayEnd = calendar.isDateInToday(date) ?
  169. Date.now :
  170. calendar.date(byAdding: .day, value: 1, to: dayStart)!
  171. let filteredReadings = glucoseReadings.filter {
  172. $0.date >= dayStart && $0.date < dayEnd
  173. }
  174. return (date: date, readings: filteredReadings)
  175. }
  176. }
  177. // Collect results
  178. var results: [(date: Date, readings: [GlucoseReading])] = []
  179. for await result in group {
  180. results.append(result)
  181. }
  182. return results.sorted { $0.date < $1.date }
  183. }
  184. }
  185. /// Creates a GlucoseDailyDistributionStats object from thread-safe reading values
  186. /// - Parameters:
  187. /// - date: Date for the day
  188. /// - readings: Array of thread-safe glucose readings
  189. /// - highLimit: Upper limit for target glucose range
  190. /// - timeInRangeType: The time-in-range type to use for calculations
  191. /// - Returns: GlucoseDailyDistributionStats object with calculated statistics
  192. private func createGlucoseDailyDistributionStatsFromReadings(
  193. date: Date,
  194. readings: [GlucoseReading],
  195. highLimit: Decimal,
  196. timeInRangeType: TimeInRangeType
  197. ) -> GlucoseDailyDistributionStats {
  198. let totalReadings = Double(readings.count)
  199. // Count readings in each range
  200. let veryHighReadings = readings.filter { $0.value > 250 }.count
  201. let highReadings = readings.filter { $0.value > Int(highLimit) && $0.value <= 250 }.count
  202. let inRangeReadings = readings.filter { $0.value >= timeInRangeType.bottomThreshold && $0.value <= Int(highLimit) }
  203. .count
  204. let inSmallRangeReadings = readings
  205. .filter { $0.value >= timeInRangeType.bottomThreshold && $0.value <= timeInRangeType.topThreshold }.count
  206. let lowReadings = readings.filter { $0.value < timeInRangeType.bottomThreshold && $0.value >= 54 }.count
  207. let veryLowReadings = readings.filter { $0.value < 54 }.count
  208. // Calculate percentages
  209. let veryLowPct = totalReadings > 0 ? Double(veryLowReadings) / totalReadings * 100 : 0
  210. let lowPct = totalReadings > 0 ? Double(lowReadings) / totalReadings * 100 : 0
  211. let inSmallRangePct = totalReadings > 0 ? Double(inSmallRangeReadings) / totalReadings * 100 : 0
  212. let inRangePct = totalReadings > 0 ? Double(inRangeReadings) / totalReadings * 100 : 0
  213. let highPct = totalReadings > 0 ? Double(highReadings) / totalReadings * 100 : 0
  214. let veryHighPct = totalReadings > 0 ? Double(veryHighReadings) / totalReadings * 100 : 0
  215. // Create empty managed object array since we don't need the actual Core Data objects
  216. let emptyStoredArray: [GlucoseStored] = []
  217. return GlucoseDailyDistributionStats(
  218. date: date,
  219. timeInRangeType: timeInRangeType,
  220. readings: emptyStoredArray,
  221. veryLowPct: veryLowPct,
  222. lowPct: lowPct,
  223. inSmallRangePct: inSmallRangePct,
  224. inRangePct: inRangePct,
  225. highPct: highPct,
  226. veryHighPct: veryHighPct
  227. )
  228. }
  229. /// Creates a GlucoseDailyPercentileStats object from thread-safe reading values
  230. /// - Parameters:
  231. /// - date: Date for the day
  232. /// - readings: Array of thread-safe glucose readings
  233. /// - Returns: GlucoseDailyPercentileStats object with calculated statistics
  234. private func createGlucoseDailyPercentileStatsFromReadings(
  235. date: Date,
  236. readings: [GlucoseReading]
  237. ) -> GlucoseDailyPercentileStats {
  238. let glucoseValues = readings.map { Double($0.value) }.sorted()
  239. // If no data, return empty data
  240. guard !glucoseValues.isEmpty else {
  241. return GlucoseDailyPercentileStats(date: date)
  242. }
  243. let count = glucoseValues.count
  244. let calculatePercentile = { (p: Double) -> Double in
  245. let position = Double(count - 1) * p
  246. let lower = Int(floor(position))
  247. let upper = Int(ceil(position))
  248. if lower == upper {
  249. return glucoseValues[lower]
  250. }
  251. let weight = position - Double(lower)
  252. return glucoseValues[lower] * (1 - weight) + glucoseValues[upper] * weight
  253. }
  254. // Calculate all percentiles concurrently
  255. return GlucoseDailyPercentileStats(
  256. date: date,
  257. readings: [],
  258. minimum: glucoseValues.first ?? 0,
  259. percentile10: calculatePercentile(0.10),
  260. percentile25: calculatePercentile(0.25),
  261. median: calculatePercentile(0.5),
  262. percentile75: calculatePercentile(0.75),
  263. percentile90: calculatePercentile(0.90),
  264. maximum: glucoseValues.last ?? 0
  265. )
  266. }
  267. func calculateDailyDistributionStats(
  268. for dates: [Date],
  269. glucoseIDs: [NSManagedObjectID],
  270. highLimit: Decimal,
  271. timeInRangeType: TimeInRangeType
  272. ) async -> [GlucoseDailyDistributionStats] {
  273. // Process readings for each date
  274. let processedData = await processGlucoseReadingsForDates(
  275. dates,
  276. glucoseIDs: glucoseIDs
  277. )
  278. // Transform into distribution stats
  279. return processedData.map { date, readings in
  280. if readings.isEmpty {
  281. return GlucoseDailyDistributionStats(date: date, timeInRangeType: timeInRangeType)
  282. } else {
  283. return createGlucoseDailyDistributionStatsFromReadings(
  284. date: date,
  285. readings: readings,
  286. highLimit: highLimit,
  287. timeInRangeType: timeInRangeType
  288. )
  289. }
  290. }
  291. }
  292. func calculateDailyPercentileStats(
  293. for dates: [Date],
  294. glucoseIDs: [NSManagedObjectID]
  295. ) async -> [GlucoseDailyPercentileStats] {
  296. // Process readings for each date
  297. let processedData = await processGlucoseReadingsForDates(
  298. dates,
  299. glucoseIDs: glucoseIDs
  300. )
  301. // Transform into percentile stats
  302. return processedData.map { date, readings in
  303. if readings.isEmpty {
  304. return GlucoseDailyPercentileStats(date: date)
  305. } else {
  306. return createGlucoseDailyPercentileStatsFromReadings(
  307. date: date,
  308. readings: readings
  309. )
  310. }
  311. }
  312. }
  313. }