Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
72 changes: 72 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions LoopFollow/Controllers/Nightscout/Treatments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,5 +191,9 @@ extension MainViewController {
}
}
processCage(entries: pumpSiteChange)

if Storage.shared.device.value == "Loop" {
updateLoopTDD()
}
}
}
4 changes: 4 additions & 0 deletions LoopFollow/Stats/AggregatedStatsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions LoopFollow/Stats/AggregatedStatsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class AggregatedStatsViewModel: ObservableObject {
var agpStats: AGPViewModel
var griStats: GRIViewModel
var tirStats: TIRViewModel
var basalVariabilityStats: BasalVariabilityViewModel

let dataService: StatsDataService

Expand All @@ -20,13 +21,15 @@ class AggregatedStatsViewModel: ObservableObject {
agpStats = AGPViewModel(dataService: dataService)
griStats = GRIViewModel(dataService: dataService)
tirStats = TIRViewModel(dataService: dataService)
basalVariabilityStats = BasalVariabilityViewModel(dataService: dataService)
}

func calculateStats() {
simpleStats.calculateStats()
agpStats.calculateAGP()
griStats.calculateGRI()
tirStats.calculateTIR()
basalVariabilityStats.calculate()
dataAvailability = dataService.getDataAvailability()
}

Expand All @@ -35,6 +38,7 @@ class AggregatedStatsViewModel: ObservableObject {
agpStats.clearStats()
griStats.clearStats()
tirStats.clearStats()
basalVariabilityStats.clearStats()
dataAvailability = nil
}

Expand Down
106 changes: 106 additions & 0 deletions LoopFollow/Stats/BasalVariability/BasalVariabilityCalculator.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
13 changes: 13 additions & 0 deletions LoopFollow/Stats/BasalVariability/BasalVariabilityDataPoint.swift
Original file line number Diff line number Diff line change
@@ -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
}
81 changes: 81 additions & 0 deletions LoopFollow/Stats/BasalVariability/BasalVariabilityGraphView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading