From f2bef2074906d41d5f38bdab7e0e1ba41a68ef68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 28 Oct 2026 14:16:54 +0100 Subject: [PATCH 1/5] Add insulin activity history graph for OpenAPS/Trio Graphs the activity value OpenAPS and Trio upload with IOB in devicestatus, in U/h, as a history pane next to IOB. The value is net of the scheduled basal, like IOB. --- LoopFollow/Charts/BGChartHistory.swift | 19 +++++++++++++++---- .../Nightscout/DeviceStatusHistory.swift | 7 ++++--- .../DeviceStatusHistorySample.swift | 12 ++++++++---- LoopFollow/Settings/GraphSettingsView.swift | 6 +++++- LoopFollow/Storage/Storage.swift | 1 + Tests/DeviceStatusHistoryTests.swift | 9 ++++++--- 6 files changed, 39 insertions(+), 15 deletions(-) diff --git a/LoopFollow/Charts/BGChartHistory.swift b/LoopFollow/Charts/BGChartHistory.swift index 8d625d948..ace4f2b27 100644 --- a/LoopFollow/Charts/BGChartHistory.swift +++ b/LoopFollow/Charts/BGChartHistory.swift @@ -20,11 +20,12 @@ enum BGChartHistory { } enum PaneKind: Int, CaseIterable { - case iob, cob, sensitivityRatio + case iob, insulinActivity, cob, sensitivityRatio var title: String { switch self { case .iob: return "IOB" + case .insulinActivity: return "Activity" case .cob: return "COB" case .sensitivityRatio: return "Sens. Ratio" } @@ -33,18 +34,20 @@ enum BGChartHistory { var color: Color { switch self { case .iob: return Color("Insulin") + case .insulinActivity: return Color(.systemTeal) case .cob: return Color(.systemOrange) case .sensitivityRatio: return Color(.systemIndigo) } } var fillsArea: Bool { - self == .iob || self == .cob + self == .iob || self == .insulinActivity || self == .cob } var isEnabled: Bool { switch self { case .iob: return Storage.shared.showIOBGraph.value + case .insulinActivity: return Storage.shared.showInsulinActivityGraph.value && Storage.shared.device.value != "Loop" case .cob: return Storage.shared.showCOBGraph.value case .sensitivityRatio: return Storage.shared.showSensitivityRatioGraph.value && Storage.shared.device.value != "Loop" } @@ -53,6 +56,7 @@ enum BGChartHistory { func value(of sample: DeviceStatusHistorySample) -> Double? { switch self { case .iob: return sample.iob + case .insulinActivity: return sample.insulinActivity case .cob: return sample.cob case .sensitivityRatio: return sample.sensitivityRatio } @@ -62,6 +66,8 @@ enum BGChartHistory { switch self { case .iob: return Localizer.formatToLocalizedString(value, maxFractionDigits: 2) + "U" + case .insulinActivity: + return Localizer.formatToLocalizedString(value, maxFractionDigits: 2) + "U/h" case .cob: return Localizer.formatToLocalizedString(value, maxFractionDigits: 0) + "g" case .sensitivityRatio: @@ -73,6 +79,8 @@ enum BGChartHistory { switch self { case .iob: return Localizer.formatToLocalizedString(value, maxFractionDigits: 1) + "U" + case .insulinActivity: + return Localizer.formatToLocalizedString(value, maxFractionDigits: 1) + "U/h" default: return format(value) } @@ -130,6 +138,9 @@ enum BGChartHistory { let lower: Double let upper: Double switch kind { + case .insulinActivity: + lower = min(0, minValue) + upper = max(1, maxValue.rounded(.up)) case .cob: lower = 0 upper = max(10, (maxValue / 10).rounded(.up) * 10) @@ -233,7 +244,7 @@ enum BGChartHistory { let upper: Double var ticks: [Double] switch kind { - case .iob: + case .iob, .insulinActivity: lower = min(0, minValue) upper = max(1, maxValue.rounded(.up)) ticks = [0, upper] @@ -254,7 +265,7 @@ enum BGChartHistory { points: points, yDomain: (lower - pad) ... (upper + pad), ticks: ticks, - baseline: kind == .iob || kind == .cob ? 0 : lower - pad + baseline: kind.fillsArea ? 0 : lower - pad ) } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift index c2e252300..37c60d8b1 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift @@ -5,8 +5,8 @@ import Combine import Foundation /// History of the values the looping app reports in Nightscout devicestatus -/// records (IOB, COB, smoothed BG, sensitivity ratio, forecasts and the info -/// table values), shared by the history graphs and the scrubbed-time displays. +/// records (IOB, insulin activity, COB, smoothed BG, sensitivity ratio, +/// forecasts and the info table values), shared by the history graphs and the scrubbed-time displays. /// /// Devicestatus records never change once written, so the extracted fields are /// cached on disk and only missing spans are fetched: the Show Days Back window @@ -28,7 +28,7 @@ final class DeviceStatusHistory { } private struct CacheFile: Codable { - static let currentVersion = 2 + static let currentVersion = 3 let version: Int let url: String let state: DeviceStatusHistoryState @@ -82,6 +82,7 @@ final class DeviceStatusHistory { Publishers.MergeMany( s.showIOBGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), s.showCOBGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.showInsulinActivityGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), s.showSensitivityRatioGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), s.showSmoothedBG.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), s.historyCurvePlacement.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift index bcc233fba..ea4919d72 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift @@ -10,6 +10,9 @@ struct DeviceStatusHistorySample: Codable, Equatable { /// Time of the loop cycle that produced the record. let date: TimeInterval var iob: Double? + /// OpenAPS/Trio `iob.activity` in U/h: the insulin acting at that moment, + /// net of the scheduled basal. + var insulinActivity: Double? var cob: Double? /// Glucose the OpenAPS/Trio algorithm ran on (`suggested.bg`); smoothed in Trio. var smoothedBG: Double? @@ -89,7 +92,8 @@ enum DeviceStatusHistoryParser { guard let date = determinationDate ?? createdAt else { return nil } var sample = DeviceStatusHistorySample(date: date.timeIntervalSince1970) - sample.iob = openAPSIOB(openAPS["iob"]) + sample.iob = openAPSIOB(openAPS["iob"], key: "iob") + sample.insulinActivity = openAPSIOB(openAPS["iob"], key: "activity").map { $0 * 60 } sample.cob = number(determination["COB"]) ?? reasonValue("COB", in: determination["reason"] as? String) sample.smoothedBG = number(determination["bg"]).flatMap { $0 > 0 ? $0 : nil } sample.isf = number(determination["ISF"]).flatMap { $0 > 0 ? $0 : nil } @@ -124,9 +128,9 @@ enum DeviceStatusHistoryParser { /// `openaps.iob` is a dictionary on Trio/AAPS and an array of projections on /// oref0 rigs, where the first element is the current value. - private static func openAPSIOB(_ value: Any?) -> Double? { - if let dict = value as? [String: Any] { return number(dict["iob"]) } - if let array = value as? [[String: Any]] { return array.first.flatMap { number($0["iob"]) } } + private static func openAPSIOB(_ value: Any?, key: String) -> Double? { + if let dict = value as? [String: Any] { return number(dict[key]) } + if let array = value as? [[String: Any]] { return array.first.flatMap { number($0[key]) } } return nil } diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 493142ca9..70ed89c57 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -18,6 +18,7 @@ struct GraphSettingsView: View { @ObservedObject private var showIOBGraph = Storage.shared.showIOBGraph @ObservedObject private var showCOBGraph = Storage.shared.showCOBGraph @ObservedObject private var showSensitivityRatioGraph = Storage.shared.showSensitivityRatioGraph + @ObservedObject private var showInsulinActivityGraph = Storage.shared.showInsulinActivityGraph @ObservedObject private var showSmoothedBG = Storage.shared.showSmoothedBG @ObservedObject private var device = Storage.shared.device @ObservedObject private var historyCurvePlacement = Storage.shared.historyCurvePlacement @@ -83,8 +84,11 @@ struct GraphSettingsView: View { if device.value != "Loop" { Toggle("Show Smoothed BG", isOn: $showSmoothedBG.value) Toggle("Show Sensitivity Ratio Graph", isOn: $showSensitivityRatioGraph.value) + Toggle("Show Insulin Activity Graph", isOn: $showInsulinActivityGraph.value) } - if showIOBGraph.value || showCOBGraph.value || showSensitivityRatioGraph.value { + if showIOBGraph.value || showCOBGraph.value || showSensitivityRatioGraph.value + || showInsulinActivityGraph.value + { Picker("Curve Placement", selection: $historyCurvePlacement.value) { ForEach(HistoryCurvePlacement.allCases, id: \.self) { placement in Text(placement.displayName).tag(placement) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 5417df992..f0c8f3158 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -148,6 +148,7 @@ class Storage { var showIOBGraph = StorageValue(key: "showIOBGraph", defaultValue: false) var showCOBGraph = StorageValue(key: "showCOBGraph", defaultValue: false) var showSensitivityRatioGraph = StorageValue(key: "showSensitivityRatioGraph", defaultValue: false) + var showInsulinActivityGraph = StorageValue(key: "showInsulinActivityGraph", defaultValue: false) var showSmoothedBG = StorageValue(key: "showSmoothedBG", defaultValue: false) var historyCurvePlacement = StorageValue(key: "historyCurvePlacement", defaultValue: .separate) diff --git a/Tests/DeviceStatusHistoryTests.swift b/Tests/DeviceStatusHistoryTests.swift index b2b9f87f2..5f601427a 100644 --- a/Tests/DeviceStatusHistoryTests.swift +++ b/Tests/DeviceStatusHistoryTests.swift @@ -24,18 +24,19 @@ struct DeviceStatusHistoryParserTests { #expect(sample.date == DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:05Z")!.timeIntervalSince1970) #expect(createdAt == DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:30.000Z")) #expect(sample.iob == 1.25) + #expect(sample.insulinActivity == nil) #expect(sample.cob == 20) #expect(sample.smoothedBG == nil) #expect(sample.forecast?.curves["main"] == [110, 113, 115]) #expect(sample.forecast?.start == DeviceStatusHistoryParser.parseDate("2026-09-27T09:59:00Z")!.timeIntervalSince1970) } - @Test("Trio record yields smoothed BG, ISF, ratio and the four forecast curves") + @Test("Trio record yields insulin activity, smoothed BG, ISF, ratio and the forecast curves") func trioRecord() throws { let record = json(""" {"created_at": "2026-09-27T10:00:30.000Z", "device": "Trio", "pump": {"reservoir": 120.5, "battery": {"percent": 80}}, "uploader": {"battery": 55}, - "openaps": {"iob": {"iob": 2.5, "time": "2026-09-27T10:00:00Z"}, + "openaps": {"iob": {"iob": 2.5, "activity": 0.0425, "time": "2026-09-27T10:00:00Z"}, "suggested": {"deliverAt": "2026-09-27T10:00:10.123Z", "bg": 142, "COB": 12.5, "ISF": 45.2, "sensitivityRatio": 1.15, "eventualBG": 130, "current_target": 5.5, "CR": 9, "TDD": 42.1, @@ -44,6 +45,7 @@ struct DeviceStatusHistoryParserTests { let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) #expect(sample.date == DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:10.123Z")!.timeIntervalSince1970) #expect(sample.iob == 2.5) + #expect(abs((sample.insulinActivity ?? 0) - 2.55) < 1e-9) #expect(sample.cob == 12.5) #expect(sample.smoothedBG == 142) #expect(sample.isf == 45.2) @@ -75,7 +77,7 @@ struct DeviceStatusHistoryParserTests { func openAPSFallbacks() throws { let record = json(""" {"created_at": "2026-09-27T10:00:30Z", - "openaps": {"iob": [{"iob": 0.8}, {"iob": 0.6}], + "openaps": {"iob": [{"iob": 0.8, "activity": -0.01}, {"iob": 0.6, "activity": 0.02}], "enacted": {"timestamp": "2026-09-27T10:00:00Z", "ISF": 0, "reason": "maxCOB: 120, COB: 7.5, Dev: 3, ISF: 54"}}} """) @@ -83,6 +85,7 @@ struct DeviceStatusHistoryParserTests { #expect(sample.isf == nil) #expect(sample.cob == 7.5) #expect(sample.iob == 0.8) + #expect(abs((sample.insulinActivity ?? 0) + 0.6) < 1e-9) } @Test("Out-of-range forecast values are clamped") From 2e8a0c103c23e0186a4a38df535012ffa2c992a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 28 Oct 2026 14:43:29 +0100 Subject: [PATCH 2/5] Add insulin activity info row and compute insulin activity for Loop Trio's activity value is shown as an info row, also at a scrubbed time. Loop does not upload insulin activity, so LoopFollow computes it from the boluses and temp basals in Nightscout, net of the scheduled basal scaled by active overrides, with the insulin model selected in the graph settings. --- LoopFollow/Charts/BGChartHistory.swift | 2 +- LoopFollow/Charts/BGChartModel.swift | 11 +- .../Nightscout/DeviceStatusOpenAPS.swift | 5 + .../Nightscout/LoopInsulinActivity.swift | 128 ++++++++++++++++++ .../Controllers/Nightscout/Profile.swift | 1 + .../Controllers/Nightscout/Treatments.swift | 1 + .../Helpers/HistoryCurvePlacement.swift | 2 +- LoopFollow/Helpers/LoopInsulinModel.swift | 75 ++++++++++ LoopFollow/InfoTable/InfoType.swift | 3 +- LoopFollow/InfoTable/RetroSnapshot.swift | 5 + LoopFollow/Settings/GraphSettingsView.swift | 15 +- LoopFollow/Storage/Storage.swift | 1 + .../ViewControllers/MainViewController.swift | 10 ++ Tests/LoopInsulinActivityTests.swift | 95 +++++++++++++ 14 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift create mode 100644 LoopFollow/Helpers/LoopInsulinModel.swift create mode 100644 Tests/LoopInsulinActivityTests.swift diff --git a/LoopFollow/Charts/BGChartHistory.swift b/LoopFollow/Charts/BGChartHistory.swift index ace4f2b27..7fa171eb6 100644 --- a/LoopFollow/Charts/BGChartHistory.swift +++ b/LoopFollow/Charts/BGChartHistory.swift @@ -47,7 +47,7 @@ enum BGChartHistory { var isEnabled: Bool { switch self { case .iob: return Storage.shared.showIOBGraph.value - case .insulinActivity: return Storage.shared.showInsulinActivityGraph.value && Storage.shared.device.value != "Loop" + case .insulinActivity: return Storage.shared.showInsulinActivityGraph.value case .cob: return Storage.shared.showCOBGraph.value case .sensitivityRatio: return Storage.shared.showSensitivityRatioGraph.value && Storage.shared.device.value != "Loop" } diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 525fcfcb2..5cfbd1de3 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -662,9 +662,18 @@ final class BGChartModel: ObservableObject { let enabledKinds = BGChartHistory.PaneKind.allCases.filter(\.isEnabled) let showSmoothed = Storage.shared.showSmoothedBG.value && Storage.shared.device.value != "Loop" let startTime = start.timeIntervalSince1970 - let samples = enabledKinds.isEmpty && !showSmoothed + var samples = enabledKinds.isEmpty && !showSmoothed ? [] : DeviceStatusHistory.shared.state.samples.filter { $0.date >= startTime } + // Loop's activity is computed from treatments, not read from devicestatus. + if enabledKinds.contains(.insulinActivity), let vc = MainViewController.shared, !vc.loopInsulinActivity.isEmpty { + samples += vc.loopInsulinActivity.filter { $0.date >= startTime }.map { + var sample = DeviceStatusHistorySample(date: $0.date) + sample.insulinActivity = $0.value + return sample + } + samples.sort { $0.date < $1.date } + } if Storage.shared.historyCurvePlacement.value == .mainGraph { historyPanes = [] diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index e9cb08ba5..5008b14b2 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -77,6 +77,11 @@ extension MainViewController { latestIOB = iobMetric Observable.shared.iobText.value = iobMetric.formattedValue() } + if let activity = (lastLoopRecord["iob"] as? [String: AnyObject])?["activity"] as? Double { + infoManager.updateInfoData(type: .insulinActivity, value: BGChartHistory.PaneKind.insulinActivity.format(activity * 60)) + } else { + infoManager.clearInfoData(type: .insulinActivity) + } // COB if let cobMetric = CarbMetric(from: enactedOrSuggested, key: "COB") { diff --git a/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift b/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift new file mode 100644 index 000000000..dc03c27b2 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift @@ -0,0 +1,128 @@ +// LoopFollow +// LoopInsulinActivity.swift + +import Foundation + +/// Insulin activity for Loop, which uploads IOB but not activity. Computed from +/// the boluses and temp basals in Nightscout with the selected insulin model, +/// the way Loop computes IOB: temp basals count net of the scheduled basal, +/// scaled by an active override's insulin needs, and are split into +/// five-minute doses. +enum LoopInsulinActivity { + struct Dose: Equatable { + let date: TimeInterval + let units: Double + } + + struct Override: Equatable { + let start: TimeInterval + let end: TimeInterval + let insulinNeedsScaleFactor: Double + } + + struct Point: Equatable { + let date: TimeInterval + /// U/h. + let value: Double + } + + static let step: TimeInterval = 5 * 60 + + /// Net basal doses from the delivered rates (`basalData`) and the scheduled + /// rates (`basalScheduleData`). Both are rate steps in chronological order: + /// each dot's rate holds until the next dot. + static func netBasalDoses( + delivered: [MainViewController.basalGraphStruct], + scheduled: [MainViewController.basalGraphStruct], + overrides: [Override] = [], + until end: TimeInterval + ) -> [Dose] { + guard !scheduled.isEmpty else { return [] } + var doses: [Dose] = [] + var scheduleIndex = 0 + + func scheduledRate(at time: TimeInterval) -> Double { + while scheduleIndex + 1 < scheduled.count, scheduled[scheduleIndex + 1].date <= time { + scheduleIndex += 1 + } + let factor = overrides.last { $0.start <= time && time < $0.end }?.insulinNeedsScaleFactor ?? 1 + return scheduled[scheduleIndex].basalRate * factor + } + + for i in delivered.indices { + let start = delivered[i].date + let stop = min(i + 1 < delivered.count ? delivered[i + 1].date : end, end) + guard stop > start else { continue } + var time = start + while time < stop { + let next = min(time + step, stop) + let units = (delivered[i].basalRate - scheduledRate(at: time)) * (next - time) / 3600 + if units != 0 { + doses.append(Dose(date: time, units: units)) + } + time = next + } + } + return doses + } + + /// Activity at each of `times` (ascending), from `doses` (any order). + static func activity(at times: [TimeInterval], doses: [Dose], model: LoopInsulinModel) -> [Point] { + let sorted = doses.sorted { $0.date < $1.date } + var first = 0 + return times.map { time in + while first < sorted.count, sorted[first].date <= time - model.effectDuration { + first += 1 + } + var value = 0.0 + var index = first + while index < sorted.count, sorted[index].date <= time { + value += sorted[index].units * model.activity(at: time - sorted[index].date) + index += 1 + } + return Point(date: time, value: value) + } + } + + /// Five-minute steps from `start` to `end`, ending exactly at `end`. + static func times(from start: TimeInterval, to end: TimeInterval) -> [TimeInterval] { + guard end >= start else { return [] } + var times = Array(stride(from: (start / step).rounded(.up) * step, to: end, by: step)) + times.append(end) + return times + } +} + +extension MainViewController { + /// Recomputes Loop's insulin activity after the treatments, the basal + /// schedule or the insulin model changed. + func updateLoopInsulinActivity() { + guard Storage.shared.device.value == "Loop" else { + if !loopInsulinActivity.isEmpty { + loopInsulinActivity = [] + chartModel.rebuild() + } + return + } + + let model = Storage.shared.loopInsulinModel.value + let now = dateTimeUtils.getNowTimeIntervalUTC() + // Treatments are fetched from this time on; earlier doses are unknown, + // so values start once those would have stopped acting. + let fetchStart = now - TimeInterval(Storage.shared.downloadDays.value) * 24 * 3600 + let boluses = (bolusData + smbData).map { LoopInsulinActivity.Dose(date: $0.date, units: $0.value) } + let overrides = overrideGraphData.map { + LoopInsulinActivity.Override(start: $0.date, end: $0.endDate, insulinNeedsScaleFactor: $0.insulNeedsScaleFactor) + } + let basal = LoopInsulinActivity.netBasalDoses(delivered: basalData, scheduled: basalScheduleData, overrides: overrides, until: now) + let times = LoopInsulinActivity.times(from: fetchStart + model.effectDuration, to: now) + loopInsulinActivity = LoopInsulinActivity.activity(at: times, doses: boluses + basal, model: model) + + if let latest = loopInsulinActivity.last { + infoManager.updateInfoData(type: .insulinActivity, value: BGChartHistory.PaneKind.insulinActivity.format(latest.value)) + } else { + infoManager.clearInfoData(type: .insulinActivity) + } + chartModel.rebuild() + } +} diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index d88c453fa..b537a8e1f 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -126,5 +126,6 @@ extension MainViewController { if Storage.shared.graphBasal.value { updateBasalScheduledGraph() } + updateLoopInsulinActivity() } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 430751bc4..a03ac9b10 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -219,5 +219,6 @@ extension MainViewController { } } processCage(entries: pumpSiteChange) + updateLoopInsulinActivity() } } diff --git a/LoopFollow/Helpers/HistoryCurvePlacement.swift b/LoopFollow/Helpers/HistoryCurvePlacement.swift index d5dc6919f..6fff9cb2a 100644 --- a/LoopFollow/Helpers/HistoryCurvePlacement.swift +++ b/LoopFollow/Helpers/HistoryCurvePlacement.swift @@ -1,7 +1,7 @@ // LoopFollow // HistoryCurvePlacement.swift -/// Where the device status history curves (IOB, COB, sensitivity ratio) are drawn. +/// Where the history curves (IOB, insulin activity, COB, sensitivity ratio) are drawn. enum HistoryCurvePlacement: String, CaseIterable, Codable { /// Each curve in its own graph under the main graph, with its own axis. case separate diff --git a/LoopFollow/Helpers/LoopInsulinModel.swift b/LoopFollow/Helpers/LoopInsulinModel.swift new file mode 100644 index 000000000..8065c4974 --- /dev/null +++ b/LoopFollow/Helpers/LoopInsulinModel.swift @@ -0,0 +1,75 @@ +// LoopFollow +// LoopInsulinModel.swift + +import Foundation + +/// The exponential insulin curves Loop offers, with LoopKit's +/// `ExponentialInsulinModelPreset` durations, peaks and delay. +enum LoopInsulinModel: String, CaseIterable, Codable { + case rapidActingAdult + case rapidActingChild + case fiasp + case lyumjev + case afrezza + + var displayName: String { + switch self { + case .rapidActingAdult: return "Rapid-Acting – Adults" + case .rapidActingChild: return "Rapid-Acting – Children" + case .fiasp: return "Fiasp" + case .lyumjev: return "Lyumjev" + case .afrezza: return "Afrezza" + } + } + + /// Duration of the curve, excluding the delay. + var actionDuration: TimeInterval { + switch self { + case .afrezza: return 300 * 60 + default: return 360 * 60 + } + } + + /// Time of peak activity, counted from the end of the delay. + var peakActivity: TimeInterval { + switch self { + case .rapidActingAdult: return 75 * 60 + case .rapidActingChild: return 65 * 60 + case .fiasp, .lyumjev: return 55 * 60 + case .afrezza: return 29 * 60 + } + } + + var delay: TimeInterval { 10 * 60 } + + /// Time from delivery until a dose has no effect left. + var effectDuration: TimeInterval { actionDuration + delay } + + private var terms: (tau: Double, a: Double, s: Double) { + let td = actionDuration + let tp = peakActivity + let tau = tp * (1 - tp / td) / (1 - 2 * tp / td) + let a = 2 * tau / td + let s = 1 / (1 - a + (1 + a) * exp(-td / tau)) + return (tau, a, s) + } + + /// Fraction of a dose still to act, `time` after delivery (insulin on board). + func percentEffectRemaining(at time: TimeInterval) -> Double { + let t = time - delay + if t <= 0 { return 1 } + if t >= actionDuration { return 0 } + let (tau, a, s) = terms + let td = actionDuration + return 1 - s * (1 - a) * ((t * t / (tau * td * (1 - a)) - t / tau - 1) * exp(-t / tau) + 1) + } + + /// Fraction of a dose acting per hour, `time` after delivery: the rate at + /// which `percentEffectRemaining` falls. + func activity(at time: TimeInterval) -> Double { + let t = time - delay + guard t > 0, t < actionDuration else { return 0 } + let (tau, _, s) = terms + return s / (tau * tau) * t * (1 - t / actionDuration) * exp(-t / tau) * 3600 + } +} diff --git a/LoopFollow/InfoTable/InfoType.swift b/LoopFollow/InfoTable/InfoType.swift index ca127c027..ff93eeece 100644 --- a/LoopFollow/InfoTable/InfoType.swift +++ b/LoopFollow/InfoTable/InfoType.swift @@ -4,7 +4,7 @@ import Foundation enum InfoType: Int, CaseIterable, Codable { - case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize, smoothedBG + case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize, smoothedBG, insulinActivity var name: String { switch self { @@ -30,6 +30,7 @@ enum InfoType: Int, CaseIterable, Codable { case .iage: return "IAGE" case .dbSize: return "DB Size" case .smoothedBG: return "Smoothed BG" + case .insulinActivity: return "Insulin Activity" } } diff --git a/LoopFollow/InfoTable/RetroSnapshot.swift b/LoopFollow/InfoTable/RetroSnapshot.swift index d7a0a7cff..adc2dfb33 100644 --- a/LoopFollow/InfoTable/RetroSnapshot.swift +++ b/LoopFollow/InfoTable/RetroSnapshot.swift @@ -162,6 +162,7 @@ extension MainViewController { set(.pump, sample.reservoir.map { String(format: "%.0f", $0) + "U" }, sample.reservoir) set(.autosens, sample.sensitivityRatio.map { String(format: "%.0f", $0 * 100) + "%" }) set(.smoothedBG, sample.smoothedBG.map { Localizer.formatQuantity($0) }) + set(.insulinActivity, sample.insulinActivity.map { BGChartHistory.PaneKind.insulinActivity.format($0) }) set(.updated, Localizer.formatTimestampToLocalString(sample.date)) set(.minMax, retroMinMax(sample.forecast)) if let override = sample.loopOverride { @@ -170,6 +171,10 @@ extension MainViewController { } } + if isLoop, let point = loopInsulinActivity.last(where: { abs($0.date - time) <= LoopInsulinActivity.step / 2 }) { + set(.insulinActivity, BGChartHistory.PaneKind.insulinActivity.format(point.value)) + } + // Profile-based rows, from the loaded profile's schedule at that time // of day, with the loop's own value where the record carries one. if let profileISF = profileManager.isf(at: date) { diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 70ed89c57..2058d75d2 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -19,6 +19,7 @@ struct GraphSettingsView: View { @ObservedObject private var showCOBGraph = Storage.shared.showCOBGraph @ObservedObject private var showSensitivityRatioGraph = Storage.shared.showSensitivityRatioGraph @ObservedObject private var showInsulinActivityGraph = Storage.shared.showInsulinActivityGraph + @ObservedObject private var loopInsulinModel = Storage.shared.loopInsulinModel @ObservedObject private var showSmoothedBG = Storage.shared.showSmoothedBG @ObservedObject private var device = Storage.shared.device @ObservedObject private var historyCurvePlacement = Storage.shared.historyCurvePlacement @@ -81,10 +82,16 @@ struct GraphSettingsView: View { Section { Toggle("Show IOB Graph", isOn: $showIOBGraph.value) Toggle("Show COB Graph", isOn: $showCOBGraph.value) - if device.value != "Loop" { + Toggle("Show Insulin Activity Graph", isOn: $showInsulinActivityGraph.value) + if device.value == "Loop" { + Picker("Insulin Model", selection: $loopInsulinModel.value) { + ForEach(LoopInsulinModel.allCases, id: \.self) { model in + Text(model.displayName).tag(model) + } + } + } else { Toggle("Show Smoothed BG", isOn: $showSmoothedBG.value) Toggle("Show Sensitivity Ratio Graph", isOn: $showSensitivityRatioGraph.value) - Toggle("Show Insulin Activity Graph", isOn: $showInsulinActivityGraph.value) } if showIOBGraph.value || showCOBGraph.value || showSensitivityRatioGraph.value || showInsulinActivityGraph.value @@ -97,6 +104,10 @@ struct GraphSettingsView: View { } } header: { Text("History Graphs") + } footer: { + if device.value == "Loop" { + Text("Loop does not upload insulin activity. LoopFollow computes it from the boluses and temp basals in Nightscout with this insulin model, so choose the model selected in Loop.") + } } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index f0c8f3158..d7a65de33 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -149,6 +149,7 @@ class Storage { var showCOBGraph = StorageValue(key: "showCOBGraph", defaultValue: false) var showSensitivityRatioGraph = StorageValue(key: "showSensitivityRatioGraph", defaultValue: false) var showInsulinActivityGraph = StorageValue(key: "showInsulinActivityGraph", defaultValue: false) + var loopInsulinModel = StorageValue(key: "loopInsulinModel", defaultValue: .rapidActingAdult) var showSmoothedBG = StorageValue(key: "showSmoothedBG", defaultValue: false) var historyCurvePlacement = StorageValue(key: "historyCurvePlacement", defaultValue: .separate) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index b8989f28f..25cb2e986 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -76,6 +76,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var basalScheduleData: [basalGraphStruct] = [] var bolusData: [bolusGraphStruct] = [] var smbData: [bolusGraphStruct] = [] + /// Loop only; see `updateLoopInsulinActivity()`. + var loopInsulinActivity: [LoopInsulinActivity.Point] = [] var carbData: [carbGraphStruct] = [] // Stats-specific data storage (can hold up to 30 days) @@ -243,6 +245,14 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { } DeviceStatusHistory.shared.start() + Publishers.MergeMany( + Storage.shared.loopInsulinModel.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + Storage.shared.device.$value.removeDuplicates().dropFirst().map { _ in () }.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.updateLoopInsulinActivity() } + .store(in: &cancellables) + scheduleAllTasks() setupNightscoutSocket() diff --git a/Tests/LoopInsulinActivityTests.swift b/Tests/LoopInsulinActivityTests.swift new file mode 100644 index 000000000..2570eff29 --- /dev/null +++ b/Tests/LoopInsulinActivityTests.swift @@ -0,0 +1,95 @@ +// LoopFollow +// LoopInsulinActivityTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct LoopInsulinActivityTests { + private typealias Basal = MainViewController.basalGraphStruct + + @Test("Activity is the rate at which insulin on board falls", arguments: LoopInsulinModel.allCases) + func activityMatchesIOB(model: LoopInsulinModel) { + let dt: TimeInterval = 10 + var acted = 0.0 + var time: TimeInterval = 0 + while time < model.effectDuration + 600 { + acted += model.activity(at: time + dt / 2) * dt / 3600 + time += dt + #expect(abs(acted - (1 - model.percentEffectRemaining(at: time))) < 1e-4) + } + #expect(abs(acted - 1) < 1e-4) + } + + @Test("Activity starts after the delay and peaks at the model's peak") + func activityShape() { + let model = LoopInsulinModel.rapidActingAdult + #expect(model.activity(at: 0) == 0) + #expect(model.activity(at: model.delay) == 0) + let peak = model.delay + model.peakActivity + #expect(model.activity(at: peak) > model.activity(at: peak - 60)) + #expect(model.activity(at: peak) > model.activity(at: peak + 60)) + #expect(model.activity(at: model.effectDuration) == 0) + } + + @Test("Temp basals count net of the scheduled rate, in five-minute doses") + func netBasal() { + // Scheduled 1 U/h, a 3 U/h temp basal for 10 minutes, then a 0 U/h + // temp basal for 7 minutes. + let scheduled = [Basal(basalRate: 1, date: 0), Basal(basalRate: 1, date: 7200)] + let delivered = [ + Basal(basalRate: 3, date: 600), Basal(basalRate: 3, date: 1200), + Basal(basalRate: 0, date: 1200), Basal(basalRate: 0, date: 1620), + Basal(basalRate: 1, date: 1620), + ] + let doses = LoopInsulinActivity.netBasalDoses(delivered: delivered, scheduled: scheduled, until: 3600) + let expected: [LoopInsulinActivity.Dose] = [ + .init(date: 600, units: 2.0 / 12), + .init(date: 900, units: 2.0 / 12), + .init(date: 1200, units: -1.0 / 12), + .init(date: 1500, units: -1.0 * 120 / 3600), + ] + #expect(doses.count == expected.count) + for (dose, want) in zip(doses, expected) { + #expect(dose.date == want.date) + #expect(abs(dose.units - want.units) < 1e-9) + } + } + + @Test("A scheduled rate change applies from its time on") + func scheduleChange() { + let scheduled = [Basal(basalRate: 1, date: 0), Basal(basalRate: 1, date: 900), Basal(basalRate: 2, date: 900), Basal(basalRate: 2, date: 7200)] + let delivered = [Basal(basalRate: 2, date: 600), Basal(basalRate: 2, date: 1200)] + let doses = LoopInsulinActivity.netBasalDoses(delivered: delivered, scheduled: scheduled, until: 3600) + #expect(doses == [.init(date: 600, units: 1.0 / 12)]) + } + + @Test("An override's insulin needs scale the scheduled rate") + func overrideScale() { + let scheduled = [Basal(basalRate: 1, date: 0), Basal(basalRate: 1, date: 7200)] + let delivered = [Basal(basalRate: 1, date: 0), Basal(basalRate: 1, date: 1200)] + let overrides = [LoopInsulinActivity.Override(start: 600, end: 900, insulinNeedsScaleFactor: 1.5)] + let doses = LoopInsulinActivity.netBasalDoses(delivered: delivered, scheduled: scheduled, overrides: overrides, until: 3600) + #expect(doses.count == 1) + #expect(doses.first?.date == 600) + #expect(abs((doses.first?.units ?? 0) + 0.5 / 12) < 1e-9) + } + + @Test("Activity sums the doses acting at each time") + func activitySum() { + let model = LoopInsulinModel.fiasp + let doses: [LoopInsulinActivity.Dose] = [.init(date: 0, units: 2), .init(date: 1800, units: -0.5), .init(date: 99999, units: 5)] + let times: [TimeInterval] = [0, 3600, model.effectDuration + 1800] + let points = LoopInsulinActivity.activity(at: times, doses: doses, model: model) + #expect(points.map(\.date) == times) + #expect(points[0].value == 0) + #expect(abs(points[1].value - (2 * model.activity(at: 3600) - 0.5 * model.activity(at: 1800))) < 1e-12) + #expect(abs(points[2].value - (-0.5 * model.activity(at: model.effectDuration))) < 1e-12) + } + + @Test("Times step every five minutes and end at the end time") + func timeGrid() { + #expect(LoopInsulinActivity.times(from: 100, to: 700) == [300, 600, 700]) + #expect(LoopInsulinActivity.times(from: 700, to: 100).isEmpty) + } +} From 52e65e0c8bcb4d61d89665cf12cb8504447be2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 28 Oct 2026 14:55:26 +0100 Subject: [PATCH 3/5] Color the insulin activity info row and shorten its name The row takes threshold coloring like IOB, carries its value to the watch's info page with the other rows, and shows one decimal under the name Ins. Activity so it fits on one line. --- .../Controllers/Nightscout/DeviceStatusOpenAPS.swift | 2 +- .../Controllers/Nightscout/LoopInsulinActivity.swift | 2 +- LoopFollow/InfoTable/InfoManager.swift | 10 ++++++++++ LoopFollow/InfoTable/InfoType.swift | 4 +++- LoopFollow/InfoTable/RetroSnapshot.swift | 4 ++-- Tests/LoopInsulinActivityTests.swift | 7 +++++++ 6 files changed, 24 insertions(+), 5 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 5008b14b2..38e364505 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -78,7 +78,7 @@ extension MainViewController { Observable.shared.iobText.value = iobMetric.formattedValue() } if let activity = (lastLoopRecord["iob"] as? [String: AnyObject])?["activity"] as? Double { - infoManager.updateInfoData(type: .insulinActivity, value: BGChartHistory.PaneKind.insulinActivity.format(activity * 60)) + infoManager.updateInsulinActivity(activity * 60) } else { infoManager.clearInfoData(type: .insulinActivity) } diff --git a/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift b/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift index dc03c27b2..ece920d27 100644 --- a/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift +++ b/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift @@ -119,7 +119,7 @@ extension MainViewController { loopInsulinActivity = LoopInsulinActivity.activity(at: times, doses: boluses + basal, model: model) if let latest = loopInsulinActivity.last { - infoManager.updateInfoData(type: .insulinActivity, value: BGChartHistory.PaneKind.insulinActivity.format(latest.value)) + infoManager.updateInsulinActivity(latest.value) } else { infoManager.clearInfoData(type: .insulinActivity) } diff --git a/LoopFollow/InfoTable/InfoManager.swift b/LoopFollow/InfoTable/InfoManager.swift index 3fd32e0eb..5c053703a 100644 --- a/LoopFollow/InfoTable/InfoManager.swift +++ b/LoopFollow/InfoTable/InfoManager.swift @@ -53,6 +53,16 @@ class InfoManager: ObservableObject { updateInfoData(type: type, value: formattedValue, numericValue: value.value) } + /// Insulin activity in U/h. + func updateInsulinActivity(_ value: Double) { + updateInfoData(type: .insulinActivity, value: Self.insulinActivityText(value), numericValue: value) + } + + static func insulinActivityText(_ value: Double) -> String { + let rounded = (value * 10).rounded() / 10 + return Localizer.formatToLocalizedString(rounded == 0 ? 0 : rounded, maxFractionDigits: 1, minFractionDigits: 0) + "U/h" + } + func clearInfoData(type: InfoType) { tableData[type.rawValue].value = "" tableData[type.rawValue].numericValue = nil diff --git a/LoopFollow/InfoTable/InfoType.swift b/LoopFollow/InfoTable/InfoType.swift index ff93eeece..11a6f3ba5 100644 --- a/LoopFollow/InfoTable/InfoType.swift +++ b/LoopFollow/InfoTable/InfoType.swift @@ -30,7 +30,7 @@ enum InfoType: Int, CaseIterable, Codable { case .iage: return "IAGE" case .dbSize: return "DB Size" case .smoothedBG: return "Smoothed BG" - case .insulinActivity: return "Insulin Activity" + case .insulinActivity: return "Ins. Activity" } } @@ -62,6 +62,8 @@ enum InfoType: Int, CaseIterable, Codable { return InfoColorConfig(direction: .below, unit: "%", range: 0 ... 100, step: 5, defaultWarning: 30, defaultUrgent: 15) case .pump: return InfoColorConfig(direction: .below, unit: "U", range: 0 ... 50, step: 1, defaultWarning: 20, defaultUrgent: 10) + case .insulinActivity: + return InfoColorConfig(direction: .above, unit: "U/h", range: 0 ... 10, step: 0.1, defaultWarning: 2, defaultUrgent: 4) case .tdd: return InfoColorConfig(direction: .above, unit: "U", range: 0 ... 200, step: 1, defaultWarning: 60, defaultUrgent: 80) case .recBolus: diff --git a/LoopFollow/InfoTable/RetroSnapshot.swift b/LoopFollow/InfoTable/RetroSnapshot.swift index adc2dfb33..c08045a7a 100644 --- a/LoopFollow/InfoTable/RetroSnapshot.swift +++ b/LoopFollow/InfoTable/RetroSnapshot.swift @@ -162,7 +162,7 @@ extension MainViewController { set(.pump, sample.reservoir.map { String(format: "%.0f", $0) + "U" }, sample.reservoir) set(.autosens, sample.sensitivityRatio.map { String(format: "%.0f", $0 * 100) + "%" }) set(.smoothedBG, sample.smoothedBG.map { Localizer.formatQuantity($0) }) - set(.insulinActivity, sample.insulinActivity.map { BGChartHistory.PaneKind.insulinActivity.format($0) }) + set(.insulinActivity, sample.insulinActivity.map { InfoManager.insulinActivityText($0) }, sample.insulinActivity) set(.updated, Localizer.formatTimestampToLocalString(sample.date)) set(.minMax, retroMinMax(sample.forecast)) if let override = sample.loopOverride { @@ -172,7 +172,7 @@ extension MainViewController { } if isLoop, let point = loopInsulinActivity.last(where: { abs($0.date - time) <= LoopInsulinActivity.step / 2 }) { - set(.insulinActivity, BGChartHistory.PaneKind.insulinActivity.format(point.value)) + set(.insulinActivity, InfoManager.insulinActivityText(point.value), point.value) } // Profile-based rows, from the loaded profile's schedule at that time diff --git a/Tests/LoopInsulinActivityTests.swift b/Tests/LoopInsulinActivityTests.swift index 2570eff29..ec511c955 100644 --- a/Tests/LoopInsulinActivityTests.swift +++ b/Tests/LoopInsulinActivityTests.swift @@ -87,6 +87,13 @@ struct LoopInsulinActivityTests { #expect(abs(points[2].value - (-0.5 * model.activity(at: model.effectDuration))) < 1e-12) } + @Test("The info row rounds to one decimal and never shows minus zero") + func infoText() { + #expect(InfoManager.insulinActivityText(-0.04) == InfoManager.insulinActivityText(0)) + #expect(InfoManager.insulinActivityText(1.37) == InfoManager.insulinActivityText(1.4)) + #expect(InfoManager.insulinActivityText(0).hasSuffix("U/h")) + } + @Test("Times step every five minutes and end at the end time") func timeGrid() { #expect(LoopInsulinActivity.times(from: 100, to: 700) == [300, 600, 700]) From cad559cf54c068933d6e13574b5b6f43b42a08ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 28 Oct 2026 15:13:22 +0100 Subject: [PATCH 4/5] Draw the IOB and insulin activity forecast The IOB and insulin activity graphs continue dashed into the future for the insulin already delivered and the running temp basal until it ends, up to the Hours of Prediction. OpenAPS/Trio provide it as openaps.iobForecast; for Loop it is computed with the selected insulin model. --- LoopFollow/Charts/BGChartHistory.swift | 63 ++++++++++++++----- LoopFollow/Charts/BGChartHistoryPanes.swift | 10 +++ LoopFollow/Charts/BGChartModel.swift | 8 ++- LoopFollow/Charts/BGChartView.swift | 2 +- .../Nightscout/DeviceStatusOpenAPS.swift | 1 + .../Nightscout/InsulinForecast.swift | 47 ++++++++++++++ .../Nightscout/LoopInsulinActivity.swift | 40 +++++++++--- .../ViewControllers/MainViewController.swift | 1 + Tests/LoopInsulinActivityTests.swift | 37 +++++++++++ 9 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 LoopFollow/Controllers/Nightscout/InsulinForecast.swift diff --git a/LoopFollow/Charts/BGChartHistory.swift b/LoopFollow/Charts/BGChartHistory.swift index 7fa171eb6..accc35e66 100644 --- a/LoopFollow/Charts/BGChartHistory.swift +++ b/LoopFollow/Charts/BGChartHistory.swift @@ -62,6 +62,15 @@ enum BGChartHistory { } } + /// The value this kind takes from an insulin forecast, for the kinds it covers. + func forecastValue(of point: InsulinForecast.Point) -> Double? { + switch self { + case .iob: return point.iob + case .insulinActivity: return point.activity + case .cob, .sensitivityRatio: return nil + } + } + func format(_ value: Double) -> String { switch self { case .iob: @@ -95,6 +104,8 @@ enum BGChartHistory { let ticks: [Double] /// Bottom of the area fill. let baseline: Double + /// Insulin forecast ahead of the history, drawn dashed. + var forecast: [Point] = [] var id: Int { kind.rawValue } } @@ -106,7 +117,9 @@ enum BGChartHistory { /// Negative IOB, drawn mirrored above the baseline. let isNegative: Bool let points: [Point] - var id: String { "\(kind.rawValue)-\(isNegative)" } + /// Insulin forecast, drawn dashed. + var isForecast = false + var id: String { "\(kind.rawValue)-\(isNegative)-\(isForecast)" } var color: Color { isNegative ? BGChartHistory.negativeIOBColor : kind.color } } @@ -115,22 +128,31 @@ enum BGChartHistory { static let negativeIOBColor = Color(.systemPink) - static func overlay(kinds: [PaneKind], samples: [DeviceStatusHistorySample], bandTop: Double) -> Overlay? { + static func overlay( + kinds: [PaneKind], + samples: [DeviceStatusHistorySample], + forecast: InsulinForecast? = nil, + bandTop: Double + ) -> Overlay? { var series: [Overlay.Series] = [] for kind in kinds { let points = points(from: samples, value: kind.value(of:)) - guard let minValue = points.map(\.value).min(), - let maxValue = points.map(\.value).max() - else { continue } + let forecastPoints = forecastPoints(kind: kind, forecast: forecast) + let values = (points + forecastPoints).map(\.value) + guard let minValue = values.min(), let maxValue = values.max() else { continue } if kind == .iob { // Magnitude scale: negative IOB is mirrored upward in its own color. let magnitude = max(abs(minValue), maxValue, 1).rounded(.up) - let (positive, negative) = splitAtZero(points) - series.append(.init(kind: kind, isNegative: false, points: positive.map { scaled($0, abs($0.value) / magnitude * bandTop) })) - if !negative.isEmpty { - series.append(.init(kind: kind, isNegative: true, points: negative.map { scaled($0, abs($0.value) / magnitude * bandTop) })) + for (curve, isForecast) in [(points, false), (forecastPoints, true)] where !curve.isEmpty { + let (positive, negative) = splitAtZero(curve) + if !positive.isEmpty { + series.append(.init(kind: kind, isNegative: false, points: positive.map { scaled($0, abs($0.value) / magnitude * bandTop) }, isForecast: isForecast)) + } + if !negative.isEmpty { + series.append(.init(kind: kind, isNegative: true, points: negative.map { scaled($0, abs($0.value) / magnitude * bandTop) }, isForecast: isForecast)) + } } continue } @@ -152,7 +174,9 @@ enum BGChartHistory { continue } let span = max(upper - lower, .ulpOfOne) - series.append(.init(kind: kind, isNegative: false, points: points.map { scaled($0, ($0.value - lower) / span * bandTop) })) + for (curve, isForecast) in [(points, false), (forecastPoints, true)] where !curve.isEmpty { + series.append(.init(kind: kind, isNegative: false, points: curve.map { scaled($0, ($0.value - lower) / span * bandTop) }, isForecast: isForecast)) + } } guard !series.isEmpty else { return nil } @@ -234,11 +258,19 @@ enum BGChartHistory { return points } - static func pane(kind: PaneKind, samples: [DeviceStatusHistorySample]) -> Pane? { + /// The forecast's values for `kind` as one unbroken curve. + static func forecastPoints(kind: PaneKind, forecast: InsulinForecast?) -> [Point] { + guard let forecast else { return [] } + return forecast.points.compactMap { point in + kind.forecastValue(of: point).map { Point(date: Date(timeIntervalSince1970: point.date), value: $0, segment: 0) } + } + } + + static func pane(kind: PaneKind, samples: [DeviceStatusHistorySample], forecast: InsulinForecast? = nil) -> Pane? { let points = points(from: samples, value: kind.value(of:)) - guard let minValue = points.map(\.value).min(), - let maxValue = points.map(\.value).max() - else { return nil } + let forecastPoints = forecastPoints(kind: kind, forecast: forecast) + let values = (points + forecastPoints).map(\.value) + guard let minValue = values.min(), let maxValue = values.max() else { return nil } let lower: Double let upper: Double @@ -265,7 +297,8 @@ enum BGChartHistory { points: points, yDomain: (lower - pad) ... (upper + pad), ticks: ticks, - baseline: kind.fillsArea ? 0 : lower - pad + baseline: kind.fillsArea ? 0 : lower - pad, + forecast: forecastPoints ) } diff --git a/LoopFollow/Charts/BGChartHistoryPanes.swift b/LoopFollow/Charts/BGChartHistoryPanes.swift index dfd45f37f..9d48117c2 100644 --- a/LoopFollow/Charts/BGChartHistoryPanes.swift +++ b/LoopFollow/Charts/BGChartHistoryPanes.swift @@ -62,6 +62,16 @@ struct BGChartHistoryPaneCanvas: View, Equatable { .lineStyle(StrokeStyle(lineWidth: 1.5)) .interpolationMethod(.linear) } + ForEach(chartWindowedLine(pane.forecast, windowStart: windowStart, windowEnd: windowEnd) { $0.date }) { pt in + LineMark( + x: .value("time", pt.date), + y: .value(pane.kind.title, pt.value), + series: .value("series", "forecast") + ) + .foregroundStyle(color) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3])) + .interpolationMethod(.linear) + } } .chartXScale(domain: windowStart ... windowEnd) .chartYScale(domain: pane.yDomain) diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 5cfbd1de3..91f38efc0 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -675,12 +675,16 @@ final class BGChartModel: ObservableObject { samples.sort { $0.date < $1.date } } + // Same reach as the BG forecast lines. + let forecastEnd = now.timeIntervalSince1970 + Storage.shared.predictionToLoad.value * 3600 + let forecast = MainViewController.shared?.insulinForecast?.clipped(to: forecastEnd) + if Storage.shared.historyCurvePlacement.value == .mainGraph { historyPanes = [] // The band stays below the low line so it does not cover in-range readings. - historyOverlay = BGChartHistory.overlay(kinds: enabledKinds, samples: samples, bandTop: min(maxBG * 0.22, lowLine * 0.9)) + historyOverlay = BGChartHistory.overlay(kinds: enabledKinds, samples: samples, forecast: forecast, bandTop: min(maxBG * 0.22, lowLine * 0.9)) } else { - historyPanes = enabledKinds.compactMap { BGChartHistory.pane(kind: $0, samples: samples) } + historyPanes = enabledKinds.compactMap { BGChartHistory.pane(kind: $0, samples: samples, forecast: forecast) } historyOverlay = nil } smoothedBG = showSmoothed ? BGChartHistory.points(from: samples) { $0.smoothedBG } : [] diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index e2949839a..de3a96a77 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -1508,7 +1508,7 @@ private struct BGChartCanvas: View, Equatable { series: .value("series", "overlay-\(series.id)-\(pt.segment)") ) .foregroundStyle(series.color) - .lineStyle(StrokeStyle(lineWidth: 1.5)) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: series.isForecast ? [4, 3] : [])) } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 38e364505..f5048edc0 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -77,6 +77,7 @@ extension MainViewController { latestIOB = iobMetric Observable.shared.iobText.value = iobMetric.formattedValue() } + insulinForecast = InsulinForecast.parseOpenAPS(lastLoopRecord) if let activity = (lastLoopRecord["iob"] as? [String: AnyObject])?["activity"] as? Double { infoManager.updateInsulinActivity(activity * 60) } else { diff --git a/LoopFollow/Controllers/Nightscout/InsulinForecast.swift b/LoopFollow/Controllers/Nightscout/InsulinForecast.swift new file mode 100644 index 000000000..30957e87d --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/InsulinForecast.swift @@ -0,0 +1,47 @@ +// LoopFollow +// InsulinForecast.swift + +import Foundation + +/// Where IOB and insulin activity are heading for the insulin already +/// delivered and the running temp basal until it ends, with no further +/// dosing. OpenAPS/Trio upload it as `openaps.iobForecast`; for Loop it is +/// computed from treatments (see `LoopInsulinActivity`). +struct InsulinForecast: Equatable { + struct Point: Equatable { + let date: TimeInterval + let iob: Double + /// U/h. + let activity: Double + } + + let points: [Point] + + static let step: TimeInterval = 5 * 60 + + /// Reads `openaps.iobForecast` (`iob` in U and `activity` in U/min, every + /// five minutes from `openaps.iob.time`). + static func parseOpenAPS(_ openAPS: [String: Any]) -> InsulinForecast? { + guard let forecast = openAPS["iobForecast"] as? [String: Any], + let iob = forecast["iob"] as? [Any], + let activity = forecast["activity"] as? [Any], + let timeString = (openAPS["iob"] as? [String: Any])?["time"] as? String, + let start = DeviceStatusHistoryParser.parseDate(timeString)?.timeIntervalSince1970 + else { return nil } + + let points = zip(iob, activity).enumerated().compactMap { index, pair -> Point? in + guard let iob = (pair.0 as? NSNumber)?.doubleValue, + let activity = (pair.1 as? NSNumber)?.doubleValue, + iob.isFinite, activity.isFinite + else { return nil } + return Point(date: start + Double(index) * step, iob: iob, activity: activity * 60) + } + return points.count > 1 ? InsulinForecast(points: points) : nil + } + + /// The points up to `end`. + func clipped(to end: TimeInterval) -> InsulinForecast? { + let kept = points.filter { $0.date <= end } + return kept.count > 1 ? InsulinForecast(points: kept) : nil + } +} diff --git a/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift b/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift index ece920d27..f7a848868 100644 --- a/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift +++ b/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift @@ -66,24 +66,32 @@ enum LoopInsulinActivity { return doses } - /// Activity at each of `times` (ascending), from `doses` (any order). - static func activity(at times: [TimeInterval], doses: [Dose], model: LoopInsulinModel) -> [Point] { + /// IOB and activity at each of `times` (ascending), from `doses` (any order). + static func evaluate(at times: [TimeInterval], doses: [Dose], model: LoopInsulinModel) -> [InsulinForecast.Point] { let sorted = doses.sorted { $0.date < $1.date } var first = 0 return times.map { time in while first < sorted.count, sorted[first].date <= time - model.effectDuration { first += 1 } - var value = 0.0 + var iob = 0.0 + var activity = 0.0 var index = first while index < sorted.count, sorted[index].date <= time { - value += sorted[index].units * model.activity(at: time - sorted[index].date) + let age = time - sorted[index].date + iob += sorted[index].units * model.percentEffectRemaining(at: age) + activity += sorted[index].units * model.activity(at: age) index += 1 } - return Point(date: time, value: value) + return InsulinForecast.Point(date: time, iob: iob, activity: activity) } } + /// Activity at each of `times` (ascending), from `doses` (any order). + static func activity(at times: [TimeInterval], doses: [Dose], model: LoopInsulinModel) -> [Point] { + evaluate(at: times, doses: doses, model: model).map { Point(date: $0.date, value: $0.activity) } + } + /// Five-minute steps from `start` to `end`, ending exactly at `end`. static func times(from start: TimeInterval, to end: TimeInterval) -> [TimeInterval] { guard end >= start else { return [] } @@ -94,12 +102,13 @@ enum LoopInsulinActivity { } extension MainViewController { - /// Recomputes Loop's insulin activity after the treatments, the basal - /// schedule or the insulin model changed. + /// Recomputes Loop's insulin activity and insulin forecast after the + /// treatments, the basal schedule or the insulin model changed. func updateLoopInsulinActivity() { guard Storage.shared.device.value == "Loop" else { if !loopInsulinActivity.isEmpty { loopInsulinActivity = [] + insulinForecast = nil chartModel.rebuild() } return @@ -110,13 +119,24 @@ extension MainViewController { // Treatments are fetched from this time on; earlier doses are unknown, // so values start once those would have stopped acting. let fetchStart = now - TimeInterval(Storage.shared.downloadDays.value) * 24 * 3600 - let boluses = (bolusData + smbData).map { LoopInsulinActivity.Dose(date: $0.date, units: $0.value) } + let boluses = (bolusData + smbData).filter { $0.date <= now }.map { LoopInsulinActivity.Dose(date: $0.date, units: $0.value) } let overrides = overrideGraphData.map { LoopInsulinActivity.Override(start: $0.date, end: $0.endDate, insulinNeedsScaleFactor: $0.insulNeedsScaleFactor) } - let basal = LoopInsulinActivity.netBasalDoses(delivered: basalData, scheduled: basalScheduleData, overrides: overrides, until: now) + // The running temp basal counts until its set end. + let basalEnd = max(now, basalData.last?.date ?? now) + let basal = LoopInsulinActivity.netBasalDoses(delivered: basalData, scheduled: basalScheduleData, overrides: overrides, until: basalEnd) + let doses = boluses + basal + let times = LoopInsulinActivity.times(from: fetchStart + model.effectDuration, to: now) - loopInsulinActivity = LoopInsulinActivity.activity(at: times, doses: boluses + basal, model: model) + loopInsulinActivity = LoopInsulinActivity.activity(at: times, doses: doses, model: model) + + if loopInsulinActivity.isEmpty { + insulinForecast = nil + } else { + let forecastTimes = Array(stride(from: now, through: now + model.effectDuration, by: InsulinForecast.step)) + insulinForecast = InsulinForecast(points: LoopInsulinActivity.evaluate(at: forecastTimes, doses: doses, model: model)) + } if let latest = loopInsulinActivity.last { infoManager.updateInsulinActivity(latest.value) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 25cb2e986..d91be687b 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -78,6 +78,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var smbData: [bolusGraphStruct] = [] /// Loop only; see `updateLoopInsulinActivity()`. var loopInsulinActivity: [LoopInsulinActivity.Point] = [] + var insulinForecast: InsulinForecast? var carbData: [carbGraphStruct] = [] // Stats-specific data storage (can hold up to 30 days) diff --git a/Tests/LoopInsulinActivityTests.swift b/Tests/LoopInsulinActivityTests.swift index ec511c955..2255c60e2 100644 --- a/Tests/LoopInsulinActivityTests.swift +++ b/Tests/LoopInsulinActivityTests.swift @@ -87,6 +87,43 @@ struct LoopInsulinActivityTests { #expect(abs(points[2].value - (-0.5 * model.activity(at: model.effectDuration))) < 1e-12) } + @Test("The forecast carries IOB and activity of the doses already given") + func evaluate() { + let model = LoopInsulinModel.rapidActingAdult + let doses: [LoopInsulinActivity.Dose] = [.init(date: 0, units: 2), .init(date: 3600, units: 1)] + let points = LoopInsulinActivity.evaluate(at: [1800, 7200], doses: doses, model: model) + #expect(abs(points[0].iob - 2 * model.percentEffectRemaining(at: 1800)) < 1e-12) + #expect(abs(points[1].iob - (2 * model.percentEffectRemaining(at: 7200) + model.percentEffectRemaining(at: 3600))) < 1e-12) + #expect(abs(points[1].activity - (2 * model.activity(at: 7200) + model.activity(at: 3600))) < 1e-12) + } + + @Test("Trio's uploaded forecast is read every five minutes from the IOB time, activity in U/h") + func parseOpenAPSForecast() throws { + let record: [String: Any] = [ + "iob": ["iob": 2.0, "activity": 0.01, "time": "2026-10-28T12:00:00.000Z"], + "iobForecast": ["iob": [2.0, 1.9, 1.8], "activity": [0.01, 0.02, 0.03]], + ] + let forecast = try #require(InsulinForecast.parseOpenAPS(record)) + let start = try #require(DeviceStatusHistoryParser.parseDate("2026-10-28T12:00:00.000Z")).timeIntervalSince1970 + #expect(forecast.points.map(\.date) == [start, start + 300, start + 600]) + #expect(forecast.points.map(\.iob) == [2.0, 1.9, 1.8]) + #expect(abs(forecast.points[2].activity - 1.8) < 1e-9) + #expect(forecast.clipped(to: start + 300)?.points.count == 2) + #expect(forecast.clipped(to: start) == nil) + #expect(InsulinForecast.parseOpenAPS(["iob": record["iob"]!]) == nil) + } + + @Test("A pane's range and forecast include the forecast values") + func paneForecast() throws { + var sample = DeviceStatusHistorySample(date: 0) + sample.insulinActivity = 1 + let forecast = InsulinForecast(points: [.init(date: 0, iob: 1, activity: 1), .init(date: 300, iob: 1, activity: 3.4)]) + let pane = try #require(BGChartHistory.pane(kind: .insulinActivity, samples: [sample], forecast: forecast)) + #expect(pane.forecast.map(\.value) == [1, 3.4]) + #expect(pane.ticks == [0, 4]) + #expect(BGChartHistory.pane(kind: .cob, samples: [], forecast: forecast) == nil) + } + @Test("The info row rounds to one decimal and never shows minus zero") func infoText() { #expect(InfoManager.insulinActivityText(-0.04) == InfoManager.insulinActivityText(0)) From 1c4c53e4a1d3f7682bf34f6826e4d497c06767f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 28 Oct 2026 20:08:15 +0100 Subject: [PATCH 5/5] Mirror negative insulin activity in its own color in the main graph --- LoopFollow/Charts/BGChartHistory.swift | 24 ++++++++++++++---------- Tests/DeviceStatusHistoryTests.swift | 13 +++++++++++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/LoopFollow/Charts/BGChartHistory.swift b/LoopFollow/Charts/BGChartHistory.swift index accc35e66..929d59d41 100644 --- a/LoopFollow/Charts/BGChartHistory.swift +++ b/LoopFollow/Charts/BGChartHistory.swift @@ -40,6 +40,15 @@ enum BGChartHistory { } } + /// Values below zero, drawn mirrored above the overlay baseline. + var negativeColor: Color? { + switch self { + case .iob: return Color(.systemPink) + case .insulinActivity: return Color(.systemBrown) + case .cob, .sensitivityRatio: return nil + } + } + var fillsArea: Bool { self == .iob || self == .insulinActivity || self == .cob } @@ -114,20 +123,18 @@ enum BGChartHistory { struct Overlay { struct Series: Identifiable { let kind: PaneKind - /// Negative IOB, drawn mirrored above the baseline. + /// Values below zero, drawn mirrored above the baseline. let isNegative: Bool let points: [Point] /// Insulin forecast, drawn dashed. var isForecast = false var id: String { "\(kind.rawValue)-\(isNegative)-\(isForecast)" } - var color: Color { isNegative ? BGChartHistory.negativeIOBColor : kind.color } + var color: Color { isNegative ? kind.negativeColor ?? kind.color : kind.color } } let series: [Series] } - static let negativeIOBColor = Color(.systemPink) - static func overlay( kinds: [PaneKind], samples: [DeviceStatusHistorySample], @@ -142,8 +149,8 @@ enum BGChartHistory { let values = (points + forecastPoints).map(\.value) guard let minValue = values.min(), let maxValue = values.max() else { continue } - if kind == .iob { - // Magnitude scale: negative IOB is mirrored upward in its own color. + if kind.negativeColor != nil { + // Magnitude scale: negative values are mirrored upward in their own color. let magnitude = max(abs(minValue), maxValue, 1).rounded(.up) for (curve, isForecast) in [(points, false), (forecastPoints, true)] where !curve.isEmpty { let (positive, negative) = splitAtZero(curve) @@ -160,9 +167,6 @@ enum BGChartHistory { let lower: Double let upper: Double switch kind { - case .insulinActivity: - lower = min(0, minValue) - upper = max(1, maxValue.rounded(.up)) case .cob: lower = 0 upper = max(10, (maxValue / 10).rounded(.up) * 10) @@ -170,7 +174,7 @@ enum BGChartHistory { // Same range as the ratio graph, so a flat curve sits mid-band. lower = min(minValue, 0.9) upper = max(maxValue, 1.1) - case .iob: + case .iob, .insulinActivity: continue } let span = max(upper - lower, .ulpOfOne) diff --git a/Tests/DeviceStatusHistoryTests.swift b/Tests/DeviceStatusHistoryTests.swift index 5f601427a..460a46226 100644 --- a/Tests/DeviceStatusHistoryTests.swift +++ b/Tests/DeviceStatusHistoryTests.swift @@ -281,6 +281,19 @@ struct BGChartHistoryTests { #expect(overlay.series.flatMap(\.points).allSatisfy { $0.value >= 0 }) } + @Test("The overlay mirrors negative insulin activity in its own color") + func overlayNegativeActivity() throws { + let values = [(0.0, 1.5), (5.0, -0.8)].map { + var s = DeviceStatusHistorySample(date: t0 + $0.0 * 60) + s.insulinActivity = $0.1 + return s + } + let overlay = try #require(BGChartHistory.overlay(kinds: [.iob, .insulinActivity], samples: values, bandTop: 60)) + #expect(overlay.series.map(\.isNegative) == [false, true]) + #expect(overlay.series.flatMap(\.points).allSatisfy { $0.value >= 0 && $0.value <= 60 }) + #expect(BGChartHistory.PaneKind.insulinActivity.negativeColor != BGChartHistory.PaneKind.iob.negativeColor) + } + @Test("A pane without values is not shown") func emptyPane() { #expect(BGChartHistory.pane(kind: .cob, samples: samples([(0, 1)])) == nil)