BolusStatsSetup.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import CoreData
  2. import Foundation
  3. /// Represents statistical data about bolus insulin for a specific time period
  4. struct BolusStats: Identifiable {
  5. let id = UUID()
  6. /// The date representing this time period
  7. let date: Date
  8. /// Total manual bolus insulin in units
  9. let manualBolus: Double
  10. /// Total SMB insulin in units
  11. let smb: Double
  12. /// Total external bolus insulin in units
  13. let external: Double
  14. }
  15. extension Stat.StateModel {
  16. /// Sets up bolus statistics by fetching and processing bolus data
  17. ///
  18. /// This function:
  19. /// 1. Fetches hourly and daily bolus statistics asynchronously
  20. /// 2. Updates the state model with the fetched statistics on the main actor
  21. /// 3. Calculates and caches initial daily averages
  22. func setupBolusStats() {
  23. Task {
  24. do {
  25. let (hourly, daily) = try await fetchBolusStats()
  26. await MainActor.run {
  27. self.hourlyBolusStats = hourly
  28. self.dailyBolusStats = daily
  29. }
  30. // Initially calculate and cache daily averages
  31. await calculateAndCacheBolusAveragesAndTotals()
  32. } catch {
  33. debug(.default, "\(DebuggingIdentifiers.failed) failed to setup bolus stats: \(error.localizedDescription)")
  34. }
  35. }
  36. }
  37. /// Fetches and processes bolus statistics from Core Data
  38. /// - Returns: A tuple containing hourly and daily bolus statistics arrays
  39. ///
  40. /// This function:
  41. /// 1. Fetches bolus entries from Core Data
  42. /// 2. Groups entries by hour and day
  43. /// 3. Calculates total insulin for each time period
  44. /// 4. Returns the processed statistics as (hourly: [BolusStats], daily: [BolusStats])
  45. private func fetchBolusStats() async throws -> (hourly: [BolusStats], daily: [BolusStats]) {
  46. // Fetch PumpEventStored entries from Core Data
  47. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  48. ofType: BolusStored.self,
  49. onContext: bolusTaskContext,
  50. predicate: NSPredicate.pumpHistoryForStats,
  51. key: "pumpEvent.timestamp",
  52. ascending: true,
  53. batchSize: 100
  54. )
  55. // Variables to hold the results
  56. var hourlyStats: [BolusStats] = []
  57. var dailyStats: [BolusStats] = []
  58. // Process CoreData results within the context's thread
  59. await bolusTaskContext.perform {
  60. guard let fetchedResults = results as? [BolusStored] else {
  61. return
  62. }
  63. let calendar = Calendar.current
  64. // Group entries by hour for hourly statistics
  65. let now = Date()
  66. let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -20, to: now) ?? now
  67. let hourlyGrouped = Dictionary(grouping: fetchedResults.filter { entry in
  68. guard let date = entry.pumpEvent?.timestamp else { return false }
  69. return date >= twentyDaysAgo && date <= now
  70. }) { entry in
  71. let components = calendar.dateComponents(
  72. [.year, .month, .day, .hour],
  73. from: entry.pumpEvent?.timestamp ?? Date()
  74. )
  75. return calendar.date(from: components) ?? Date()
  76. }
  77. // Group entries by day for daily statistics
  78. let dailyGrouped = Dictionary(grouping: fetchedResults) { entry in
  79. calendar.startOfDay(for: entry.pumpEvent?.timestamp ?? Date())
  80. }
  81. // Process hourly stats
  82. hourlyStats = hourlyGrouped.keys.sorted().map { timePoint in
  83. let entries = hourlyGrouped[timePoint, default: []]
  84. return BolusStats(
  85. date: timePoint,
  86. manualBolus: entries.reduce(0.0) { sum, entry in
  87. if !entry.isSMB, !entry.isExternal {
  88. return sum + (entry.amount?.doubleValue ?? 0)
  89. }
  90. return sum
  91. },
  92. smb: entries.reduce(0.0) { sum, entry in
  93. if entry.isSMB {
  94. return sum + (entry.amount?.doubleValue ?? 0)
  95. }
  96. return sum
  97. },
  98. external: entries.reduce(0.0) { sum, entry in
  99. if entry.isExternal {
  100. return sum + (entry.amount?.doubleValue ?? 0)
  101. }
  102. return sum
  103. }
  104. )
  105. }
  106. // Process daily stats
  107. dailyStats = dailyGrouped.keys.sorted().map { timePoint in
  108. let entries = dailyGrouped[timePoint, default: []]
  109. return BolusStats(
  110. date: timePoint,
  111. manualBolus: entries.reduce(0.0) { sum, entry in
  112. if !entry.isSMB, !entry.isExternal {
  113. return sum + (entry.amount?.doubleValue ?? 0)
  114. }
  115. return sum
  116. },
  117. smb: entries.reduce(0.0) { sum, entry in
  118. if entry.isSMB {
  119. return sum + (entry.amount?.doubleValue ?? 0)
  120. }
  121. return sum
  122. },
  123. external: entries.reduce(0.0) { sum, entry in
  124. if entry.isExternal {
  125. return sum + (entry.amount?.doubleValue ?? 0)
  126. }
  127. return sum
  128. }
  129. )
  130. }
  131. }
  132. return (hourlyStats, dailyStats)
  133. }
  134. /// Calculates and caches the daily averages of bolus insulin
  135. ///
  136. /// This function:
  137. /// 1. Groups bolus statistics by day
  138. /// 2. Calculates average total, carb and correction bolus for each day
  139. /// 3. Caches the results for later use
  140. ///
  141. /// This only needs to be called once during subscribe.
  142. private func calculateAndCacheBolusAveragesAndTotals() async {
  143. let calendar = Calendar.current
  144. // Calculate averages in context
  145. let dailyAverages = await bolusTaskContext.perform { [dailyBolusStats] in
  146. // Group by days
  147. let groupedByDay = Dictionary(grouping: dailyBolusStats) { stat in
  148. calendar.startOfDay(for: stat.date)
  149. }
  150. // Calculate averages for each day
  151. var averages: [Date: (Double, Double, Double)] = [:]
  152. for (day, stats) in groupedByDay {
  153. let total = stats.reduce((0.0, 0.0, 0.0)) { acc, stat in
  154. (acc.0 + stat.manualBolus, acc.1 + stat.smb, acc.2 + stat.external)
  155. }
  156. let count = Double(stats.count)
  157. averages[day] = (total.0 / count, total.1 / count, total.2 / count)
  158. }
  159. return averages
  160. }
  161. // Calculate averages in context
  162. let dailyTotals = await bolusTaskContext.perform { [dailyBolusStats] in
  163. // Group by days
  164. let groupedByDay = Dictionary(grouping: dailyBolusStats) { stat in
  165. calendar.startOfDay(for: stat.date)
  166. }
  167. // Calculate totals for each day
  168. var totals: [(Date, Double)] = []
  169. for (day, stats) in groupedByDay {
  170. let total = stats.reduce(0.0) { _, stat in
  171. stat.manualBolus + stat.smb + stat.external
  172. }
  173. }
  174. return totals
  175. }
  176. // Update cache on main thread
  177. await MainActor.run {
  178. self.bolusAveragesCache = dailyAverages
  179. self.bolusTotalsCache = dailyTotals
  180. }
  181. }
  182. /// Returns the average bolus values for the given date range from the cache
  183. /// - Parameter range: A tuple containing the start and end dates to get averages for
  184. /// - Returns: A tuple containing the average total, carb and correction bolus values for the date range
  185. func getCachedBolusAverages(for range: (start: Date, end: Date)) -> (manual: Double, smb: Double, external: Double) {
  186. return calculateBolusAveragesForDateRange(from: range.start, to: range.end)
  187. }
  188. /// Returns the total bolus values for the given date range from the cache
  189. /// - Parameter range: A tuple containing the start and end dates to get averages for
  190. /// - Returns: Totals for bolus (sum of manual, smb and external) for the date range
  191. func getCachedBolusTotals(for range: (start: Date, end: Date)) -> Double {
  192. calculateBolusTotalsForDateRange(from: range.start, to: range.end)
  193. }
  194. /// Calculates the average bolus values for a given date range
  195. /// - Parameters:
  196. /// - startDate: The start date of the range to calculate averages for
  197. /// - endDate: The end date of the range to calculate averages for
  198. /// - Returns: A tuple containing the average total, carb and correction bolus values for the date range
  199. func calculateBolusAveragesForDateRange(
  200. from startDate: Date,
  201. to endDate: Date
  202. ) -> (manual: Double, smb: Double, external: Double) {
  203. // Filter cached values to only include those within the date range
  204. let relevantStats = bolusAveragesCache.filter { date, _ in
  205. date >= startDate && date <= endDate
  206. }
  207. // Return zeros if no data exists for the range
  208. guard !relevantStats.isEmpty else { return (0, 0, 0) }
  209. // Calculate total bolus across all days
  210. let total = relevantStats.values.reduce((0.0, 0.0, 0.0)) { acc, avg in
  211. (acc.0 + avg.0, acc.1 + avg.1, acc.2 + avg.2)
  212. }
  213. // Calculate averages by dividing totals by number of days
  214. let count = Double(relevantStats.count)
  215. return (total.0 / count, total.1 / count, total.2 / count)
  216. }
  217. /// Calculates the total bolus values for a given date range
  218. /// - Parameters:
  219. /// - startDate: The start date of the range to calculate averages for
  220. /// - endDate: The end date of the range to calculate averages for
  221. /// - Returns: A total bolus (sum of manual, smb and external) for the date range
  222. func calculateBolusTotalsForDateRange(
  223. from startDate: Date,
  224. to endDate: Date
  225. ) -> Double {
  226. // Filter cached values to only include those within the date range
  227. let relevantStats = bolusAveragesCache.filter { date, _ in
  228. date >= startDate && date <= endDate
  229. }
  230. // Return zeros if no data exists for the range
  231. guard !relevantStats.isEmpty else { return 0 }
  232. // Calculate total bolus across all days
  233. return relevantStats.values.reduce(0.0) { _, totalPerCategory in
  234. totalPerCategory.0 + totalPerCategory.1 + totalPerCategory.2
  235. }
  236. }
  237. }
  238. /// Extension to convert Decimal to Double
  239. private extension Decimal {
  240. var doubleValue: Double {
  241. NSDecimalNumber(decimal: self).doubleValue
  242. }
  243. }