SimpleStatsViewModel.swift 13 KB

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