diff --git a/LoopFollow/Charts/BGChartHistory.swift b/LoopFollow/Charts/BGChartHistory.swift index 8d625d948..929d59d41 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,29 @@ 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) } } + /// 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 == .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 case .cob: return Storage.shared.showCOBGraph.value case .sensitivityRatio: return Storage.shared.showSensitivityRatioGraph.value && Storage.shared.device.value != "Loop" } @@ -53,15 +65,27 @@ 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 } } + /// 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: 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 +97,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) } @@ -87,6 +113,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 } } @@ -95,34 +123,43 @@ 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] - var id: String { "\(kind.rawValue)-\(isNegative)" } - var color: Color { isNegative ? BGChartHistory.negativeIOBColor : kind.color } + /// Insulin forecast, drawn dashed. + var isForecast = false + var id: String { "\(kind.rawValue)-\(isNegative)-\(isForecast)" } + 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], 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. + if kind.negativeColor != nil { + // Magnitude scale: negative values are mirrored upward in their 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 } @@ -137,11 +174,13 @@ 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) - 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 } @@ -223,17 +262,25 @@ 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 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 +301,8 @@ 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, + 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 525fcfcb2..91f38efc0 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -662,16 +662,29 @@ 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 } + } + + // 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/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/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index ceaef95c5..42e6ea267 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -77,6 +77,12 @@ 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 { + infoManager.clearInfoData(type: .insulinActivity) + } // COB if let cobMetric = CarbMetric(from: enactedOrSuggested, key: "COB") { 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 new file mode 100644 index 000000000..f7a848868 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/LoopInsulinActivity.swift @@ -0,0 +1,148 @@ +// 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 + } + + /// 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 iob = 0.0 + var activity = 0.0 + var index = first + while index < sorted.count, sorted[index].date <= time { + 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 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 [] } + 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 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 + } + + 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).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) + } + // 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: 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) + } 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/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 ca127c027..11a6f3ba5 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 "Ins. Activity" } } @@ -61,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 d7a0a7cff..c08045a7a 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 { InfoManager.insulinActivityText($0) }, sample.insulinActivity) 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, InfoManager.insulinActivityText(point.value), 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 493142ca9..2058d75d2 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -18,6 +18,8 @@ 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 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 @@ -80,11 +82,20 @@ 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) } - 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) @@ -93,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 5417df992..d7a65de33 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -148,6 +148,8 @@ 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 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..d91be687b 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -76,6 +76,9 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var basalScheduleData: [basalGraphStruct] = [] var bolusData: [bolusGraphStruct] = [] 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) @@ -243,6 +246,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/DeviceStatusHistoryTests.swift b/Tests/DeviceStatusHistoryTests.swift index 39d3f1770..b64169d91 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 = try 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 = try 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") @@ -291,6 +294,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) diff --git a/Tests/LoopInsulinActivityTests.swift b/Tests/LoopInsulinActivityTests.swift new file mode 100644 index 000000000..2255c60e2 --- /dev/null +++ b/Tests/LoopInsulinActivityTests.swift @@ -0,0 +1,139 @@ +// 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("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)) + #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]) + #expect(LoopInsulinActivity.times(from: 700, to: 100).isEmpty) + } +}