From f941fe90190ea8bc561e2b2204fda609005f5e31 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 11:07:01 +0000 Subject: [PATCH] Add TDD calculation, Basal Variability chart, and fix stats date range - Add TDD (Total Daily Dose) calculation for Loop from device status and treatments, including basal gap fill, race condition handling, and crash fix on empty basalData - Add Basal Variability chart to Statistics view - Fix TDD double-count: compute only from treatments, not device status - Fix TDD blank on first load due to device status / treatments race condition https://claude.ai/code/session_01DGCES9o6CLpeAmfe7tMoR6 --- .../Controllers/Nightscout/DeviceStatus.swift | 2 +- .../Nightscout/DeviceStatusLoop.swift | 72 ++++++++++++ .../Controllers/Nightscout/Treatments.swift | 4 + LoopFollow/Stats/AggregatedStatsView.swift | 4 + .../Stats/AggregatedStatsViewModel.swift | 4 + .../BasalVariabilityCalculator.swift | 106 ++++++++++++++++++ .../BasalVariabilityDataPoint.swift | 13 +++ .../BasalVariabilityGraphView.swift | 81 +++++++++++++ .../BasalVariabilityView.swift | 93 +++++++++++++++ .../BasalVariabilityViewModel.swift | 35 ++++++ 10 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 LoopFollow/Stats/BasalVariability/BasalVariabilityCalculator.swift create mode 100644 LoopFollow/Stats/BasalVariability/BasalVariabilityDataPoint.swift create mode 100644 LoopFollow/Stats/BasalVariability/BasalVariabilityGraphView.swift create mode 100644 LoopFollow/Stats/BasalVariability/BasalVariabilityView.swift create mode 100644 LoopFollow/Stats/BasalVariability/BasalVariabilityViewModel.swift diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index ff2b13a78..0f6ad6b4a 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -86,7 +86,7 @@ extension MainViewController { // NS Device Status Response Processor func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) { let previousIOBText = Observable.shared.iobText.value - infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd]) + infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus]) // For Loop, clear the current override here - For Trio, it is handled using treatments if Storage.shared.device.value == "Loop" { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 56ebb6af0..43d12ab12 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -6,6 +6,78 @@ import Foundation import HealthKit import UIKit +extension MainViewController { + /// Calculates Loop TDD from treatment arrays and updates the info table. + /// Called both from DeviceStatusLoop and after treatments load, since device + /// status typically completes before treatments on first launch. + func updateLoopTDD() { + let now = dateTimeUtils.getNowTimeIntervalUTC() + let oneDayAgo = now - (24 * 60 * 60) + + let bolusIn24h = bolusData.filter { $0.date >= oneDayAgo } + let smbIn24h = smbData.filter { $0.date >= oneDayAgo } + let bolusUnits = bolusIn24h.reduce(0.0) { $0 + $1.value } + let smbUnits = smbIn24h.reduce(0.0) { $0 + $1.value } + let bolusTotal = bolusUnits + smbUnits + + var basalTotal = 0.0 + var scheduledPrefixTotal = 0.0 + + let basalWindowStart = basalData.first.map { max($0.date, oneDayAgo) } ?? oneDayAgo + if basalWindowStart > oneDayAgo { + scheduledPrefixTotal = scheduledBasalInWindow(from: oneDayAgo, to: basalWindowStart) + basalTotal += scheduledPrefixTotal + } + + var lastIntegratedEnd = basalWindowStart + for i in basalData.indices.dropLast() { + let segStart = max(basalData[i].date, oneDayAgo) + let segEnd = min(basalData[i + 1].date, now) + guard segEnd > segStart, segStart >= lastIntegratedEnd else { continue } + basalTotal += basalData[i].basalRate * (segEnd - segStart) / 3600.0 + lastIntegratedEnd = segEnd + } + + let tddValue = bolusTotal + basalTotal + LogManager.shared.log( + category: .deviceStatus, + message: String(format: + "TDD calc: bolus=%d×%.2fU smb=%d×%.2fU basalEntries=%d scheduledPrefix=%.2fU basal=%.2fU → TDD=%.2fU", + bolusIn24h.count, bolusUnits, + smbIn24h.count, smbUnits, + basalData.count, scheduledPrefixTotal, basalTotal, + tddValue), + isDebug: true + ) + + if tddValue > 0 { + infoManager.updateInfoData(type: .tdd, value: tddValue, maxFractionDigits: 2, minFractionDigits: 0) + } + } + + private func scheduledBasalInWindow(from startTime: TimeInterval, to endTime: TimeInterval) -> Double { + guard !basalProfile.isEmpty, endTime > startTime else { return 0.0 } + let sorted = basalProfile.sorted { $0.timeAsSeconds < $1.timeAsSeconds } + let calendar = dateTimeUtils.displayCalendar() + var total = 0.0 + var current = startTime + while current < endTime { + let dayStart = calendar.startOfDay(for: Date(timeIntervalSince1970: current)).timeIntervalSince1970 + for i in 0 ..< sorted.count { + let segStart = dayStart + sorted[i].timeAsSeconds + let segEnd = i < sorted.count - 1 ? dayStart + sorted[i + 1].timeAsSeconds : dayStart + 86400 + let clampedStart = max(current, segStart) + let clampedEnd = min(endTime, segEnd) + if clampedEnd > clampedStart { + total += sorted[i].value * (clampedEnd - clampedStart) / 3600.0 + } + } + current = dayStart + 86400 + } + return total + } +} + extension MainViewController { func DeviceStatusLoop(formatter: ISO8601DateFormatter, lastLoopRecord: [String: AnyObject]) { Storage.shared.device.value = "Loop" diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index b628737b4..f8b81feff 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -191,5 +191,9 @@ extension MainViewController { } } processCage(entries: pumpSiteChange) + + if Storage.shared.device.value == "Loop" { + updateLoopTDD() + } } } diff --git a/LoopFollow/Stats/AggregatedStatsView.swift b/LoopFollow/Stats/AggregatedStatsView.swift index 343831003..676c7bae5 100644 --- a/LoopFollow/Stats/AggregatedStatsView.swift +++ b/LoopFollow/Stats/AggregatedStatsView.swift @@ -96,6 +96,10 @@ struct AggregatedStatsView: View { GRIView(viewModel: viewModel.griStats) .padding(.horizontal) .opacity(isLoadingData ? 0.4 : 1.0) + + BasalVariabilityView(viewModel: viewModel.basalVariabilityStats) + .padding(.horizontal) + .opacity(isLoadingData ? 0.4 : 1.0) } .padding(.bottom) .frame(maxWidth: .infinity) diff --git a/LoopFollow/Stats/AggregatedStatsViewModel.swift b/LoopFollow/Stats/AggregatedStatsViewModel.swift index 253352e48..6770fbf73 100644 --- a/LoopFollow/Stats/AggregatedStatsViewModel.swift +++ b/LoopFollow/Stats/AggregatedStatsViewModel.swift @@ -9,6 +9,7 @@ class AggregatedStatsViewModel: ObservableObject { var agpStats: AGPViewModel var griStats: GRIViewModel var tirStats: TIRViewModel + var basalVariabilityStats: BasalVariabilityViewModel let dataService: StatsDataService @@ -20,6 +21,7 @@ class AggregatedStatsViewModel: ObservableObject { agpStats = AGPViewModel(dataService: dataService) griStats = GRIViewModel(dataService: dataService) tirStats = TIRViewModel(dataService: dataService) + basalVariabilityStats = BasalVariabilityViewModel(dataService: dataService) } func calculateStats() { @@ -27,6 +29,7 @@ class AggregatedStatsViewModel: ObservableObject { agpStats.calculateAGP() griStats.calculateGRI() tirStats.calculateTIR() + basalVariabilityStats.calculate() dataAvailability = dataService.getDataAvailability() } @@ -35,6 +38,7 @@ class AggregatedStatsViewModel: ObservableObject { agpStats.clearStats() griStats.clearStats() tirStats.clearStats() + basalVariabilityStats.clearStats() dataAvailability = nil } diff --git a/LoopFollow/Stats/BasalVariability/BasalVariabilityCalculator.swift b/LoopFollow/Stats/BasalVariability/BasalVariabilityCalculator.swift new file mode 100644 index 000000000..d67d75455 --- /dev/null +++ b/LoopFollow/Stats/BasalVariability/BasalVariabilityCalculator.swift @@ -0,0 +1,106 @@ +// LoopFollow +// BasalVariabilityCalculator.swift + +import Foundation + +class BasalVariabilityCalculator { + static func calculate( + basalData: [MainViewController.basalGraphStruct], + basalProfile: [MainViewController.basalProfileStruct], + startTime: TimeInterval, + endTime: TimeInterval + ) -> [BasalVariabilityDataPoint] { + guard !basalData.isEmpty, !basalProfile.isEmpty else { return [] } + + let sortedProfile = basalProfile.sorted { $0.timeAsSeconds < $1.timeAsSeconds } + let calendar = dateTimeUtils.displayCalendar() + let sampleInterval: TimeInterval = 5 * 60 + + var periodSamples: [TIRPeriod: [Double]] = [:] + + // Advance step-function pointer to the entry covering startTime + var basalIndex = 0 + while basalIndex < basalData.count - 1, basalData[basalIndex + 1].date <= startTime { + basalIndex += 1 + } + + var t = startTime + while t < endTime { + while basalIndex < basalData.count - 1, basalData[basalIndex + 1].date <= t { + basalIndex += 1 + } + + let actualRate = basalData[basalIndex].basalRate + + let date = Date(timeIntervalSince1970: t) + let dayStart = calendar.startOfDay(for: date).timeIntervalSince1970 + let secondsInDay = t - dayStart + + var scheduledRate = sortedProfile[0].value + for entry in sortedProfile { + if entry.timeAsSeconds <= secondsInDay { + scheduledRate = entry.value + } else { + break + } + } + + if scheduledRate > 0 { + let ratio = actualRate / scheduledRate + let hour = calendar.component(.hour, from: date) + + var period: TIRPeriod? + for p in [TIRPeriod.night, .morning, .day, .evening] { + if let range = p.hourRange, hour >= range.start, hour < range.end { + period = p + break + } + } + + if let period = period { + if periodSamples[period] == nil { periodSamples[period] = [] } + periodSamples[period]!.append(ratio) + } + } + + t += sampleInterval + } + + var result: [BasalVariabilityDataPoint] = [] + var allRatios: [Double] = [] + + for period in [TIRPeriod.night, .morning, .day, .evening] { + let ratios = periodSamples[period] ?? [] + allRatios.append(contentsOf: ratios) + result.append(dataPoint(period: period, ratios: ratios)) + } + result.append(dataPoint(period: .average, ratios: allRatios)) + + return result + } + + private static func dataPoint(period: TIRPeriod, ratios: [Double]) -> BasalVariabilityDataPoint { + let (vb, b, ap, a, va) = percentages(from: ratios) + return BasalVariabilityDataPoint(period: period, veryBelow: vb, below: b, atPlanned: ap, above: a, veryAbove: va) + } + + private static func percentages(from ratios: [Double]) -> (Double, Double, Double, Double, Double) { + guard !ratios.isEmpty else { return (0, 0, 0, 0, 0) } + let total = Double(ratios.count) + var vb = 0, b = 0, ap = 0, a = 0, va = 0 + for r in ratios { + if r < 0.5 { vb += 1 } + else if r < 0.75 { b += 1 } + else if r <= 1.25 { ap += 1 } + else if r <= 1.5 { a += 1 } + else { va += 1 } + } + return ( + Double(vb) / total * 100, + Double(b) / total * 100, + Double(ap) / total * 100, + Double(a) / total * 100, + Double(va) / total * 100 + ) + } +} diff --git a/LoopFollow/Stats/BasalVariability/BasalVariabilityDataPoint.swift b/LoopFollow/Stats/BasalVariability/BasalVariabilityDataPoint.swift new file mode 100644 index 000000000..97a2464ab --- /dev/null +++ b/LoopFollow/Stats/BasalVariability/BasalVariabilityDataPoint.swift @@ -0,0 +1,13 @@ +// LoopFollow +// BasalVariabilityDataPoint.swift + +import Foundation + +struct BasalVariabilityDataPoint { + let period: TIRPeriod + let veryBelow: Double // < 50% of planned + let below: Double // 50–75% of planned + let atPlanned: Double // 75–125% of planned (within ±25%) + let above: Double // 125–150% of planned + let veryAbove: Double // > 150% of planned +} diff --git a/LoopFollow/Stats/BasalVariability/BasalVariabilityGraphView.swift b/LoopFollow/Stats/BasalVariability/BasalVariabilityGraphView.swift new file mode 100644 index 000000000..7ef4d0a54 --- /dev/null +++ b/LoopFollow/Stats/BasalVariability/BasalVariabilityGraphView.swift @@ -0,0 +1,81 @@ +// LoopFollow +// BasalVariabilityGraphView.swift + +import Charts +import SwiftUI +import UIKit + +struct BasalVariabilityGraphView: UIViewRepresentable { + let data: [BasalVariabilityDataPoint] + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context _: Context) -> UIView { + let container = NonInteractiveContainerView() + container.backgroundColor = .systemBackground + + let chartView = BarChartView() + chartView.backgroundColor = .systemBackground + chartView.rightAxis.enabled = false + chartView.leftAxis.enabled = true + chartView.xAxis.labelPosition = .bottom + chartView.xAxis.granularity = 1.0 + chartView.leftAxis.axisMinimum = 0.0 + chartView.leftAxis.axisMaximum = 100.0 + chartView.leftAxis.valueFormatter = PercentageAxisValueFormatter() + chartView.leftAxis.labelCount = 5 + chartView.leftAxis.drawGridLinesEnabled = true + chartView.leftAxis.gridLineDashLengths = [5, 5] + chartView.rightAxis.drawGridLinesEnabled = false + chartView.xAxis.drawGridLinesEnabled = false + chartView.legend.enabled = false + chartView.chartDescription.enabled = false + chartView.isUserInteractionEnabled = false + + container.addSubview(chartView) + chartView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + chartView.topAnchor.constraint(equalTo: container.topAnchor), + chartView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + chartView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + chartView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + return container + } + + class Coordinator {} + + func updateUIView(_ containerView: UIView, context _: Context) { + guard let chartView = containerView.subviews.first as? BarChartView else { return } + guard !data.isEmpty else { return } + + var entries: [BarChartDataEntry] = [] + var labels: [String] = [] + + for (index, point) in data.enumerated() { + entries.append(BarChartDataEntry( + x: Double(index), + yValues: [point.veryBelow, point.below, point.atPlanned, point.above, point.veryAbove] + )) + labels.append(point.period.rawValue) + } + + let dataSet = BarChartDataSet(entries: entries, label: "Basal Variability") + dataSet.colors = [ + UIColor.systemBlue.withAlphaComponent(0.85), // veryBelow + UIColor.systemTeal.withAlphaComponent(0.65), // below + UIColor.systemGreen.withAlphaComponent(0.75), // atPlanned + UIColor.systemOrange.withAlphaComponent(0.65), // above + UIColor.systemRed.withAlphaComponent(0.75), // veryAbove + ] + dataSet.stackLabels = ["< 50%", "50–75%", "75–125%", "125–150%", "> 150%"] + dataSet.drawValuesEnabled = false + + let barData = BarChartData(dataSet: dataSet) + barData.barWidth = 0.6 + chartView.data = barData + chartView.xAxis.valueFormatter = IndexAxisValueFormatter(values: labels) + chartView.xAxis.labelCount = labels.count + chartView.notifyDataSetChanged() + } +} diff --git a/LoopFollow/Stats/BasalVariability/BasalVariabilityView.swift b/LoopFollow/Stats/BasalVariability/BasalVariabilityView.swift new file mode 100644 index 000000000..ffb2fce71 --- /dev/null +++ b/LoopFollow/Stats/BasalVariability/BasalVariabilityView.swift @@ -0,0 +1,93 @@ +// LoopFollow +// BasalVariabilityView.swift + +import SwiftUI + +struct BasalVariabilityView: View { + @ObservedObject var viewModel: BasalVariabilityViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Basal Variability") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + if let avg = viewModel.data.first(where: { $0.period == .average }) { + Text(String(format: "%.1f%% at plan", avg.atPlanned)) + .font(.caption) + .foregroundColor(.secondary) + } + } + + if !viewModel.data.isEmpty { + BasalVariabilityGraphView(data: viewModel.data) + .frame(height: 250) + .allowsHitTesting(false) + .clipped() + + VStack(alignment: .leading, spacing: 8) { + if let avg = viewModel.data.first(where: { $0.period == .average }) { + Text("Actual as % of planned basal") + .foregroundColor(.secondary) + + BasalVariabilityLegendItem( + color: .red.opacity(0.75), + label: "Very above (> 150%)", + percentage: avg.veryAbove + ) + BasalVariabilityLegendItem( + color: .orange.opacity(0.65), + label: "Above (125–150%)", + percentage: avg.above + ) + BasalVariabilityLegendItem( + color: .green.opacity(0.75), + label: "At planned (75–125%)", + percentage: avg.atPlanned + ) + BasalVariabilityLegendItem( + color: Color(uiColor: .systemTeal).opacity(0.65), + label: "Below (50–75%)", + percentage: avg.below + ) + BasalVariabilityLegendItem( + color: .blue.opacity(0.85), + label: "Very below (< 50%)", + percentage: avg.veryBelow + ) + } + } + .font(.caption2) + } else { + Text("No basal data available") + .font(.caption) + .foregroundColor(.secondary) + .frame(height: 250) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } +} + +struct BasalVariabilityLegendItem: View { + let color: Color + let label: String + let percentage: Double + + var body: some View { + HStack(spacing: 8) { + Rectangle() + .fill(color) + .frame(width: 16, height: 16) + Text(String(format: "%.1f%%", percentage)) + .foregroundColor(.primary) + Text(label) + .foregroundColor(.secondary) + Spacer() + } + } +} diff --git a/LoopFollow/Stats/BasalVariability/BasalVariabilityViewModel.swift b/LoopFollow/Stats/BasalVariability/BasalVariabilityViewModel.swift new file mode 100644 index 000000000..2263be40b --- /dev/null +++ b/LoopFollow/Stats/BasalVariability/BasalVariabilityViewModel.swift @@ -0,0 +1,35 @@ +// LoopFollow +// BasalVariabilityViewModel.swift + +import Combine +import Foundation + +class BasalVariabilityViewModel: ObservableObject { + @Published var data: [BasalVariabilityDataPoint] = [] + + private let dataService: StatsDataService + + init(dataService: StatsDataService) { + self.dataService = dataService + calculate() + } + + func calculate() { + let basalData = dataService.getBasalData() + let profile = dataService.getBasalProfile() + guard !basalData.isEmpty, !profile.isEmpty else { + data = [] + return + } + data = BasalVariabilityCalculator.calculate( + basalData: basalData, + basalProfile: profile, + startTime: dataService.startDate.timeIntervalSince1970, + endTime: dataService.endDate.timeIntervalSince1970 + ) + } + + func clearStats() { + data = [] + } +}