SimpleStatsViewModel.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. // LoopFollow
  2. // SimpleStatsViewModel.swift
  3. import Combine
  4. import Foundation
  5. class SimpleStatsViewModel: ObservableObject {
  6. @Published var gmi: Double?
  7. @Published var avgGlucose: Double?
  8. @Published var stdDeviation: Double?
  9. @Published var coefficientOfVariation: Double?
  10. @Published var totalDailyDose: Double?
  11. @Published var programmedBasal: Double?
  12. @Published var actualBasal: Double?
  13. @Published var avgBolus: Double?
  14. @Published var avgCarbs: Double?
  15. @Published var totalNegativeBasal: Double?
  16. @Published var totalPositiveBasal: Double?
  17. private let dataService: StatsDataService
  18. init(dataService: StatsDataService) {
  19. self.dataService = dataService
  20. }
  21. func clearStats() {
  22. gmi = nil
  23. avgGlucose = nil
  24. stdDeviation = nil
  25. coefficientOfVariation = nil
  26. totalDailyDose = nil
  27. programmedBasal = nil
  28. actualBasal = nil
  29. avgBolus = nil
  30. avgCarbs = nil
  31. totalNegativeBasal = nil
  32. totalPositiveBasal = nil
  33. }
  34. func calculateStats() {
  35. let unitSettings = UnitSettingsStore.shared
  36. let bgData = dataService.getBGData()
  37. guard !bgData.isEmpty else { return }
  38. let totalGlucose = bgData.reduce(0) { $0 + $1.sgv }
  39. let avgBGmgdL = Double(totalGlucose) / Double(bgData.count)
  40. avgGlucose = unitSettings.convertMgdlToDisplay(avgBGmgdL)
  41. let variance = bgData.reduce(0.0) { sum, reading in
  42. let diff = Double(reading.sgv) - avgBGmgdL
  43. return sum + (diff * diff)
  44. }
  45. let stdDevMgdL = sqrt(variance / Double(bgData.count))
  46. stdDeviation = unitSettings.convertMgdlToDisplay(stdDevMgdL)
  47. gmi = GlycemicMetricCalculator.calculateGMI(avgGlucoseInDisplayUnits: avgGlucose)
  48. if avgBGmgdL > 0 {
  49. coefficientOfVariation = (stdDevMgdL / avgBGmgdL) * 100.0
  50. } else {
  51. coefficientOfVariation = nil
  52. }
  53. let bolusesInPeriod = dataService.getBolusData()
  54. let smbInPeriod = dataService.getSMBData()
  55. let bolusTotal = bolusesInPeriod.reduce(0.0) { $0 + $1.value }
  56. let smbTotal = smbInPeriod.reduce(0.0) { $0 + $1.value }
  57. let totalBolusInPeriod = bolusTotal + smbTotal
  58. let cutoffTime = Date().timeIntervalSince1970 - (Double(dataService.daysToAnalyze) * 24 * 60 * 60)
  59. let allBolusDates = (bolusesInPeriod + smbInPeriod).map { $0.date }.filter { $0 >= cutoffTime }
  60. let actualDays = calculateActualDaysCovered(dates: allBolusDates, requestedDays: dataService.daysToAnalyze)
  61. if actualDays > 0 {
  62. avgBolus = totalBolusInPeriod / Double(actualDays)
  63. } else {
  64. avgBolus = nil
  65. }
  66. let carbsInPeriod = dataService.getCarbData()
  67. let calendar = dateTimeUtils.displayCalendar()
  68. var dailyCarbs: [Date: Double] = [:]
  69. for carb in carbsInPeriod {
  70. let carbDate = Date(timeIntervalSince1970: carb.date)
  71. let dayStart = calendar.startOfDay(for: carbDate)
  72. if dailyCarbs[dayStart] == nil {
  73. dailyCarbs[dayStart] = 0.0
  74. }
  75. dailyCarbs[dayStart]? += carb.value
  76. }
  77. let totalCarbsInPeriod = dailyCarbs.values.reduce(0.0, +)
  78. let daysWithData = max(dailyCarbs.count, 1)
  79. if daysWithData > 0 {
  80. avgCarbs = totalCarbsInPeriod / Double(daysWithData)
  81. } else {
  82. avgCarbs = nil
  83. }
  84. let basalProfile = dataService.getBasalProfile()
  85. let dailyProgrammedBasal = calculateProgrammedBasalFromProfile(basalProfile: basalProfile)
  86. programmedBasal = dailyProgrammedBasal
  87. // Calculate actual basal using temp basal adjustments
  88. let tempBasalData = dataService.getTempBasalData()
  89. let (totalActualBasal, actualBasalDays, totalPositiveAdjustment, totalNegativeAdjustment) = calculateActualBasalFromTempBasals(
  90. tempBasals: tempBasalData,
  91. basalProfile: basalProfile,
  92. dailyProgrammedBasal: dailyProgrammedBasal
  93. )
  94. if actualBasalDays > 0 {
  95. totalPositiveBasal = totalPositiveAdjustment / Double(actualBasalDays)
  96. totalNegativeBasal = totalNegativeAdjustment / Double(actualBasalDays)
  97. } else {
  98. totalPositiveBasal = nil
  99. totalNegativeBasal = nil
  100. }
  101. var avgDailyBolus = 0.0
  102. var avgDailyBasal = 0.0
  103. if actualDays > 0 {
  104. avgDailyBolus = totalBolusInPeriod / Double(actualDays)
  105. }
  106. if actualBasalDays > 0 {
  107. avgDailyBasal = totalActualBasal / Double(actualBasalDays)
  108. actualBasal = avgDailyBasal
  109. } else {
  110. actualBasal = nil
  111. }
  112. if actualDays > 0 || actualBasalDays > 0 {
  113. totalDailyDose = avgDailyBolus + avgDailyBasal
  114. } else {
  115. totalDailyDose = nil
  116. }
  117. }
  118. /// Calculates actual basal delivered using:
  119. /// Actual = Programmed Basal + Sum of all temp basal adjustments
  120. /// Where adjustment = (temp_rate - scheduled_rate) * duration
  121. private func calculateActualBasalFromTempBasals(
  122. tempBasals: [StatsDataService.TempBasalEntry],
  123. basalProfile: [MainViewController.basalProfileStruct],
  124. dailyProgrammedBasal: Double
  125. ) -> (totalBasal: Double, daysWithData: Int, totalPositiveAdjustment: Double, totalNegativeAdjustment: Double) {
  126. let cutoffTime = dataService.startDate.timeIntervalSince1970
  127. let endTime = dataService.endDate.timeIntervalSince1970
  128. // Filter temp basals to the analysis period
  129. let relevantTempBasals = tempBasals.filter { tempBasal in
  130. let tempEnd = tempBasal.startTime + (tempBasal.durationMinutes * 60)
  131. return tempEnd > cutoffTime && tempBasal.startTime < endTime
  132. }
  133. guard !relevantTempBasals.isEmpty else {
  134. // No temp basals - return programmed basal for the period
  135. return (dailyProgrammedBasal, dataService.daysToAnalyze, 0.0, 0.0)
  136. }
  137. // Calculate total adjustment from all temp basals
  138. var totalAdjustment = 0.0
  139. var totalPositiveAdjustment = 0.0
  140. var totalNegativeAdjustment = 0.0
  141. for tempBasal in relevantTempBasals {
  142. // Clamp temp basal to analysis window
  143. let effectiveStart = max(tempBasal.startTime, cutoffTime)
  144. let effectiveEnd = min(tempBasal.startTime + (tempBasal.durationMinutes * 60), endTime)
  145. guard effectiveEnd > effectiveStart else { continue }
  146. // For each segment of this temp basal, calculate the adjustment
  147. // We need to handle cases where the temp basal spans multiple profile segments
  148. let adjustment = calculateAdjustmentForTempBasal(
  149. tempRate: tempBasal.rate,
  150. startTime: effectiveStart,
  151. endTime: effectiveEnd,
  152. profile: basalProfile
  153. )
  154. totalAdjustment += adjustment
  155. if adjustment > 0 {
  156. totalPositiveAdjustment += adjustment
  157. } else if adjustment < 0 {
  158. totalNegativeAdjustment += adjustment
  159. }
  160. }
  161. // Calculate days with data
  162. let calendar = dateTimeUtils.displayCalendar()
  163. var uniqueDays = Set<Date>()
  164. for tempBasal in relevantTempBasals {
  165. let dateObj = Date(timeIntervalSince1970: tempBasal.startTime)
  166. let dayStart = calendar.startOfDay(for: dateObj)
  167. uniqueDays.insert(dayStart)
  168. }
  169. let daysWithData = min(uniqueDays.count, dataService.daysToAnalyze)
  170. // Actual = Programmed + Adjustments
  171. let totalActualBasal = (dailyProgrammedBasal * Double(daysWithData)) + totalAdjustment
  172. return (totalActualBasal, daysWithData, totalPositiveAdjustment, totalNegativeAdjustment)
  173. }
  174. /// Calculates the adjustment (positive or negative) for a single temp basal
  175. /// Adjustment = (temp_rate - scheduled_rate) * duration_hours
  176. private func calculateAdjustmentForTempBasal(
  177. tempRate: Double,
  178. startTime: TimeInterval,
  179. endTime: TimeInterval,
  180. profile: [MainViewController.basalProfileStruct]
  181. ) -> Double {
  182. guard !profile.isEmpty, endTime > startTime else { return 0.0 }
  183. var totalAdjustment = 0.0
  184. let sortedProfile = profile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
  185. let calendar = dateTimeUtils.displayCalendar()
  186. var currentTime = startTime
  187. while currentTime < endTime {
  188. let currentDate = Date(timeIntervalSince1970: currentTime)
  189. let dayStart = calendar.startOfDay(for: currentDate).timeIntervalSince1970
  190. let nextDayStart = dayStart + 24 * 60 * 60
  191. // Process each profile segment for this day
  192. for i in 0 ..< sortedProfile.count {
  193. let scheduledRate = sortedProfile[i].value
  194. let segmentStartInDay = dayStart + sortedProfile[i].timeAsSeconds
  195. let segmentEndInDay: TimeInterval
  196. if i < sortedProfile.count - 1 {
  197. segmentEndInDay = dayStart + sortedProfile[i + 1].timeAsSeconds
  198. } else {
  199. segmentEndInDay = nextDayStart
  200. }
  201. // Calculate overlap between this profile segment and the temp basal
  202. let overlapStart = max(currentTime, segmentStartInDay)
  203. let overlapEnd = min(endTime, segmentEndInDay)
  204. if overlapEnd > overlapStart {
  205. let durationHours = (overlapEnd - overlapStart) / 3600.0
  206. // Adjustment = (temp_rate - scheduled_rate) * duration
  207. let adjustment = (tempRate - scheduledRate) * durationHours
  208. totalAdjustment += adjustment
  209. }
  210. }
  211. // Move to next day
  212. currentTime = nextDayStart
  213. }
  214. return totalAdjustment
  215. }
  216. private func getScheduledBasalRate(for time: TimeInterval, profile: [MainViewController.basalProfileStruct]) -> Double {
  217. guard !profile.isEmpty else { return 0.0 }
  218. let calendar = dateTimeUtils.displayCalendar()
  219. let date = Date(timeIntervalSince1970: time)
  220. let components = calendar.dateComponents([.hour, .minute, .second], from: date)
  221. let hours = components.hour ?? 0
  222. let minutes = components.minute ?? 0
  223. let seconds = components.second ?? 0
  224. let secondsSinceMidnight = Double(hours * 3600 + minutes * 60 + seconds)
  225. let sortedProfile = profile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
  226. for i in 0 ..< sortedProfile.count {
  227. let current = sortedProfile[i]
  228. let nextTime: Double
  229. if i < sortedProfile.count - 1 {
  230. nextTime = sortedProfile[i + 1].timeAsSeconds
  231. } else {
  232. nextTime = 24 * 60 * 60
  233. }
  234. if secondsSinceMidnight >= current.timeAsSeconds && secondsSinceMidnight < nextTime {
  235. return current.value
  236. }
  237. }
  238. return sortedProfile.first?.value ?? 0.0
  239. }
  240. private func calculateActualDaysCovered(dates: [TimeInterval], requestedDays: Int) -> Int {
  241. guard !dates.isEmpty else { return requestedDays }
  242. let calendar = dateTimeUtils.displayCalendar()
  243. let cutoffTime = Date().timeIntervalSince1970 - (Double(requestedDays) * 24 * 60 * 60)
  244. let filteredDates = dates.filter { $0 >= cutoffTime }
  245. var uniqueDays = Set<Date>()
  246. for date in filteredDates {
  247. let dateObj = Date(timeIntervalSince1970: date)
  248. let dayStart = calendar.startOfDay(for: dateObj)
  249. uniqueDays.insert(dayStart)
  250. }
  251. return min(uniqueDays.count, requestedDays)
  252. }
  253. private func calculateProgrammedBasalFromProfile(basalProfile: [MainViewController.basalProfileStruct]) -> Double {
  254. guard !basalProfile.isEmpty else { return 0.0 }
  255. let sortedProfile = basalProfile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
  256. var totalBasal = 0.0
  257. let secondsInDay = 24 * 60 * 60
  258. for i in 0 ..< sortedProfile.count {
  259. let current = sortedProfile[i]
  260. let currentTime = Double(current.timeAsSeconds)
  261. let nextTime: Double
  262. if i < sortedProfile.count - 1 {
  263. nextTime = Double(sortedProfile[i + 1].timeAsSeconds)
  264. } else {
  265. nextTime = Double(secondsInDay)
  266. }
  267. let durationHours = (nextTime - currentTime) / 3600.0
  268. totalBasal += current.value * durationHours
  269. }
  270. return totalBasal
  271. }
  272. }