| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338 |
- // LoopFollow
- // SimpleStatsViewModel.swift
- import Combine
- import Foundation
- class SimpleStatsViewModel: ObservableObject {
- @Published var gmi: Double?
- @Published var avgGlucose: Double?
- @Published var stdDeviation: Double?
- @Published var coefficientOfVariation: Double?
- @Published var totalDailyDose: Double?
- @Published var programmedBasal: Double?
- @Published var actualBasal: Double?
- @Published var avgBolus: Double?
- @Published var avgCarbs: Double?
- @Published var totalNegativeBasal: Double?
- @Published var totalPositiveBasal: Double?
- private let dataService: StatsDataService
- init(dataService: StatsDataService) {
- self.dataService = dataService
- }
- func clearStats() {
- gmi = nil
- avgGlucose = nil
- stdDeviation = nil
- coefficientOfVariation = nil
- totalDailyDose = nil
- programmedBasal = nil
- actualBasal = nil
- avgBolus = nil
- avgCarbs = nil
- totalNegativeBasal = nil
- totalPositiveBasal = nil
- }
- func calculateStats() {
- let unitSettings = UnitSettingsStore.shared
- let bgData = dataService.getBGData()
- guard !bgData.isEmpty else { return }
- let totalGlucose = bgData.reduce(0) { $0 + $1.sgv }
- let avgBGmgdL = Double(totalGlucose) / Double(bgData.count)
- avgGlucose = unitSettings.convertMgdlToDisplay(avgBGmgdL)
- let variance = bgData.reduce(0.0) { sum, reading in
- let diff = Double(reading.sgv) - avgBGmgdL
- return sum + (diff * diff)
- }
- let stdDevMgdL = sqrt(variance / Double(bgData.count))
- stdDeviation = unitSettings.convertMgdlToDisplay(stdDevMgdL)
- gmi = GlycemicMetricCalculator.calculateGMI(avgGlucoseInDisplayUnits: avgGlucose)
- if avgBGmgdL > 0 {
- coefficientOfVariation = (stdDevMgdL / avgBGmgdL) * 100.0
- } else {
- coefficientOfVariation = nil
- }
- let bolusesInPeriod = dataService.getBolusData()
- let smbInPeriod = dataService.getSMBData()
- let bolusTotal = bolusesInPeriod.reduce(0.0) { $0 + $1.value }
- let smbTotal = smbInPeriod.reduce(0.0) { $0 + $1.value }
- let totalBolusInPeriod = bolusTotal + smbTotal
- let cutoffTime = Date().timeIntervalSince1970 - (Double(dataService.daysToAnalyze) * 24 * 60 * 60)
- let allBolusDates = (bolusesInPeriod + smbInPeriod).map { $0.date }.filter { $0 >= cutoffTime }
- let actualDays = calculateActualDaysCovered(dates: allBolusDates, requestedDays: dataService.daysToAnalyze)
- if actualDays > 0 {
- avgBolus = totalBolusInPeriod / Double(actualDays)
- } else {
- avgBolus = nil
- }
- let carbsInPeriod = dataService.getCarbData()
- let calendar = dateTimeUtils.displayCalendar()
- var dailyCarbs: [Date: Double] = [:]
- for carb in carbsInPeriod {
- let carbDate = Date(timeIntervalSince1970: carb.date)
- let dayStart = calendar.startOfDay(for: carbDate)
- if dailyCarbs[dayStart] == nil {
- dailyCarbs[dayStart] = 0.0
- }
- dailyCarbs[dayStart]? += carb.value
- }
- let totalCarbsInPeriod = dailyCarbs.values.reduce(0.0, +)
- let daysWithData = max(dailyCarbs.count, 1)
- if daysWithData > 0 {
- avgCarbs = totalCarbsInPeriod / Double(daysWithData)
- } else {
- avgCarbs = nil
- }
- let basalProfile = dataService.getBasalProfile()
- let dailyProgrammedBasal = calculateProgrammedBasalFromProfile(basalProfile: basalProfile)
- programmedBasal = dailyProgrammedBasal
- // Calculate actual basal using temp basal adjustments
- let tempBasalData = dataService.getTempBasalData()
- let (totalActualBasal, actualBasalDays, totalPositiveAdjustment, totalNegativeAdjustment) = calculateActualBasalFromTempBasals(
- tempBasals: tempBasalData,
- basalProfile: basalProfile,
- dailyProgrammedBasal: dailyProgrammedBasal
- )
- if actualBasalDays > 0 {
- totalPositiveBasal = totalPositiveAdjustment / Double(actualBasalDays)
- totalNegativeBasal = totalNegativeAdjustment / Double(actualBasalDays)
- } else {
- totalPositiveBasal = nil
- totalNegativeBasal = nil
- }
- var avgDailyBolus = 0.0
- var avgDailyBasal = 0.0
- if actualDays > 0 {
- avgDailyBolus = totalBolusInPeriod / Double(actualDays)
- }
- if actualBasalDays > 0 {
- avgDailyBasal = totalActualBasal / Double(actualBasalDays)
- actualBasal = avgDailyBasal
- } else {
- actualBasal = nil
- }
- if actualDays > 0 || actualBasalDays > 0 {
- totalDailyDose = avgDailyBolus + avgDailyBasal
- } else {
- totalDailyDose = nil
- }
- }
- /// Calculates actual basal delivered using:
- /// Actual = Programmed Basal + Sum of all temp basal adjustments
- /// Where adjustment = (temp_rate - scheduled_rate) * duration
- private func calculateActualBasalFromTempBasals(
- tempBasals: [StatsDataService.TempBasalEntry],
- basalProfile: [MainViewController.basalProfileStruct],
- dailyProgrammedBasal: Double
- ) -> (totalBasal: Double, daysWithData: Int, totalPositiveAdjustment: Double, totalNegativeAdjustment: Double) {
- let cutoffTime = dataService.startDate.timeIntervalSince1970
- let endTime = dataService.endDate.timeIntervalSince1970
- // Filter temp basals to the analysis period
- let relevantTempBasals = tempBasals.filter { tempBasal in
- let tempEnd = tempBasal.startTime + (tempBasal.durationMinutes * 60)
- return tempEnd > cutoffTime && tempBasal.startTime < endTime
- }
- guard !relevantTempBasals.isEmpty else {
- // No temp basals - return programmed basal for the period
- return (dailyProgrammedBasal, dataService.daysToAnalyze, 0.0, 0.0)
- }
- // Calculate total adjustment from all temp basals
- var totalAdjustment = 0.0
- var totalPositiveAdjustment = 0.0
- var totalNegativeAdjustment = 0.0
- for tempBasal in relevantTempBasals {
- // Clamp temp basal to analysis window
- let effectiveStart = max(tempBasal.startTime, cutoffTime)
- let effectiveEnd = min(tempBasal.startTime + (tempBasal.durationMinutes * 60), endTime)
- guard effectiveEnd > effectiveStart else { continue }
- // For each segment of this temp basal, calculate the adjustment
- // We need to handle cases where the temp basal spans multiple profile segments
- let adjustment = calculateAdjustmentForTempBasal(
- tempRate: tempBasal.rate,
- startTime: effectiveStart,
- endTime: effectiveEnd,
- profile: basalProfile
- )
- totalAdjustment += adjustment
- if adjustment > 0 {
- totalPositiveAdjustment += adjustment
- } else if adjustment < 0 {
- totalNegativeAdjustment += adjustment
- }
- }
- // Calculate days with data
- let calendar = dateTimeUtils.displayCalendar()
- var uniqueDays = Set<Date>()
- for tempBasal in relevantTempBasals {
- let dateObj = Date(timeIntervalSince1970: tempBasal.startTime)
- let dayStart = calendar.startOfDay(for: dateObj)
- uniqueDays.insert(dayStart)
- }
- let daysWithData = min(uniqueDays.count, dataService.daysToAnalyze)
- // Actual = Programmed + Adjustments
- let totalActualBasal = (dailyProgrammedBasal * Double(daysWithData)) + totalAdjustment
- return (totalActualBasal, daysWithData, totalPositiveAdjustment, totalNegativeAdjustment)
- }
- /// Calculates the adjustment (positive or negative) for a single temp basal
- /// Adjustment = (temp_rate - scheduled_rate) * duration_hours
- private func calculateAdjustmentForTempBasal(
- tempRate: Double,
- startTime: TimeInterval,
- endTime: TimeInterval,
- profile: [MainViewController.basalProfileStruct]
- ) -> Double {
- guard !profile.isEmpty, endTime > startTime else { return 0.0 }
- var totalAdjustment = 0.0
- let sortedProfile = profile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
- let calendar = dateTimeUtils.displayCalendar()
- var currentTime = startTime
- while currentTime < endTime {
- let currentDate = Date(timeIntervalSince1970: currentTime)
- let dayStart = calendar.startOfDay(for: currentDate).timeIntervalSince1970
- let nextDayStart = dayStart + 24 * 60 * 60
- // Process each profile segment for this day
- for i in 0 ..< sortedProfile.count {
- let scheduledRate = sortedProfile[i].value
- let segmentStartInDay = dayStart + sortedProfile[i].timeAsSeconds
- let segmentEndInDay: TimeInterval
- if i < sortedProfile.count - 1 {
- segmentEndInDay = dayStart + sortedProfile[i + 1].timeAsSeconds
- } else {
- segmentEndInDay = nextDayStart
- }
- // Calculate overlap between this profile segment and the temp basal
- let overlapStart = max(currentTime, segmentStartInDay)
- let overlapEnd = min(endTime, segmentEndInDay)
- if overlapEnd > overlapStart {
- let durationHours = (overlapEnd - overlapStart) / 3600.0
- // Adjustment = (temp_rate - scheduled_rate) * duration
- let adjustment = (tempRate - scheduledRate) * durationHours
- totalAdjustment += adjustment
- }
- }
- // Move to next day
- currentTime = nextDayStart
- }
- return totalAdjustment
- }
- private func getScheduledBasalRate(for time: TimeInterval, profile: [MainViewController.basalProfileStruct]) -> Double {
- guard !profile.isEmpty else { return 0.0 }
- let calendar = dateTimeUtils.displayCalendar()
- let date = Date(timeIntervalSince1970: time)
- let components = calendar.dateComponents([.hour, .minute, .second], from: date)
- let hours = components.hour ?? 0
- let minutes = components.minute ?? 0
- let seconds = components.second ?? 0
- let secondsSinceMidnight = Double(hours * 3600 + minutes * 60 + seconds)
- let sortedProfile = profile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
- for i in 0 ..< sortedProfile.count {
- let current = sortedProfile[i]
- let nextTime: Double
- if i < sortedProfile.count - 1 {
- nextTime = sortedProfile[i + 1].timeAsSeconds
- } else {
- nextTime = 24 * 60 * 60
- }
- if secondsSinceMidnight >= current.timeAsSeconds && secondsSinceMidnight < nextTime {
- return current.value
- }
- }
- return sortedProfile.first?.value ?? 0.0
- }
- private func calculateActualDaysCovered(dates: [TimeInterval], requestedDays: Int) -> Int {
- guard !dates.isEmpty else { return requestedDays }
- let calendar = dateTimeUtils.displayCalendar()
- let cutoffTime = Date().timeIntervalSince1970 - (Double(requestedDays) * 24 * 60 * 60)
- let filteredDates = dates.filter { $0 >= cutoffTime }
- var uniqueDays = Set<Date>()
- for date in filteredDates {
- let dateObj = Date(timeIntervalSince1970: date)
- let dayStart = calendar.startOfDay(for: dateObj)
- uniqueDays.insert(dayStart)
- }
- return min(uniqueDays.count, requestedDays)
- }
- private func calculateProgrammedBasalFromProfile(basalProfile: [MainViewController.basalProfileStruct]) -> Double {
- guard !basalProfile.isEmpty else { return 0.0 }
- let sortedProfile = basalProfile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
- var totalBasal = 0.0
- let secondsInDay = 24 * 60 * 60
- for i in 0 ..< sortedProfile.count {
- let current = sortedProfile[i]
- let currentTime = Double(current.timeAsSeconds)
- let nextTime: Double
- if i < sortedProfile.count - 1 {
- nextTime = Double(sortedProfile[i + 1].timeAsSeconds)
- } else {
- nextTime = Double(secondsInDay)
- }
- let durationHours = (nextTime - currentTime) / 3600.0
- totalBasal += current.value * durationHours
- }
- return totalBasal
- }
- }
|