From 14d86fdff5e284bd446bbb0714b8afde69cc0fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 20 Jun 2026 17:37:30 +0200 Subject: [PATCH] Extend predictive low alarm to Trio The low alarm's predictive look-ahead only worked with Loop, which publishes a single forecast. Trio publishes four separate forecasts (ZT, IOB, COB, UAM), so the predictive series was empty and the look-ahead never fired. Build the alarm's forward series from the lowest of the four forecasts at each point in time, so the alarm fires if any forecast dips to or below the threshold. Update the low alarm editor wording to refer to the forecast rather than to Loop's prediction. Add tests for the forecast combining and the low condition for both Loop and Trio inputs. --- .../Editors/LowBgAlarmEditor.swift | 6 +- LoopFollow/Task/AlarmTask.swift | 57 ++++++- Tests/AlarmConditions/Helpers.swift | 32 ++++ .../AlarmConditions/LowBGConditionTests.swift | 150 ++++++++++++++++++ .../AlarmConditions/LowestForecastTests.swift | 98 ++++++++++++ .../SensorAgeConditionTests.swift | 1 + 6 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 Tests/AlarmConditions/LowBGConditionTests.swift create mode 100644 Tests/AlarmConditions/LowestForecastTests.swift diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift index 555db90cd..b22242a56 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift @@ -8,13 +8,13 @@ struct LowBgAlarmEditor: View { var body: some View { Group { - InfoBanner(text: "This warns you if the glucose is too low now or might be soon, based on predictions. Note: predictions is currently not available for Trio.") + InfoBanner(text: "This warns you if the glucose is too low now or might be soon, based on the forecast.") AlarmGeneralSection(alarm: $alarm) AlarmBGSection( header: "Low Limit", - footer: "Alert when any reading or prediction is at or below this value.", + footer: "Alert when any reading or forecast is at or below this value.", title: "BG", range: 40 ... 150, value: $alarm.belowBG @@ -33,7 +33,7 @@ struct LowBgAlarmEditor: View { AlarmStepperSection( header: "PREDICTION", - footer: "Look ahead this many minutes in Loop’s prediction; " + footer: "Look ahead this many minutes in the forecast; " + "if any future value is at or below the threshold, " + "you’ll be warned early. Set 0 to disable.", title: "Predictive", diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index 0102d66ed..cb63c20e8 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -33,9 +33,7 @@ extension MainViewController { bgReadings: self.bgData .suffix(24) .map { GlucoseValue(sgv: $0.sgv, date: Date(timeIntervalSince1970: $0.date)) }, /// These are oldest .. newest - predictionData: self.predictionData - .prefix(12) - .map { GlucoseValue(sgv: $0.sgv, date: Date(timeIntervalSince1970: $0.date)) }, /// These are oldest .. newest, Predictions not currently available for Trio + predictionData: self.alarmPredictionData(), /// These are oldest .. newest expireDate: Storage.shared.expirationDate.value, lastLoopTime: Observable.shared.alertLastLoopTime.value, latestOverrideStart: latestOverrideStart, @@ -72,6 +70,59 @@ extension MainViewController { } } + /// Builds the forward glucose series the low alarm looks ahead in, + /// oldest .. newest at 5-minute spacing. + /// + /// Loop reports a single forecast, already stored in `predictionData`. Trio + /// reports four forecasts, collapsed into the lowest value per point in time + /// by `lowestForecast(forecasts:start:cap:)`. + func alarmPredictionData() -> [GlucoseValue] { + if Storage.shared.device.value == "Loop" { + return predictionData + .prefix(MainViewController.alarmForecastPointCap) + .map { GlucoseValue(sgv: $0.sgv, date: Date(timeIntervalSince1970: $0.date)) } + } + + guard let predBGs = openAPSPredBGs else { return [] } + + let forecasts = ["ZT", "IOB", "COB", "UAM"].compactMap { predBGs[$0] } + return MainViewController.lowestForecast( + forecasts: forecasts, + start: openAPSPredUpdatedTime ?? Date().timeIntervalSince1970 + ) + } + + /// Maximum number of forward points (5-minute spacing) the low alarm looks at: + /// 12 points = 60 minutes, matching the predictive look-ahead's upper bound. + static let alarmForecastPointCap = 12 + + /// Collapses several forecasts into a single series by taking the **lowest** + /// value at each point in time, oldest .. newest at 5-minute spacing. + /// + /// Trio/OpenAPS reports four forecasts (ZT, IOB, COB, UAM) rather than the + /// single one Loop provides, so this lets the predictive-low alarm fire if + /// *any* forecast dips to or below the threshold. Empty forecasts are ignored, + /// and each point uses whichever forecasts still extend that far. + static func lowestForecast( + forecasts: [[Double]], + start: TimeInterval, + cap: Int = alarmForecastPointCap + ) -> [GlucoseValue] { + let nonEmpty = forecasts.filter { !$0.isEmpty } + guard !nonEmpty.isEmpty else { return [] } + + let count = min(nonEmpty.map { $0.count }.max() ?? 0, cap) + + return (0 ..< count).compactMap { i in + let valuesAtIndex = nonEmpty.compactMap { i < $0.count ? $0[i] : nil } + guard let lowest = valuesAtIndex.min() else { return nil } + return GlucoseValue( + sgv: Int(lowest.rounded()), + date: Date(timeIntervalSince1970: start + Double(i) * 300) + ) + } + } + func saveLatestAlarmDataToFile(_ alarmData: AlarmData) { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index d6b05630c..c8d15bfd6 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -28,6 +28,14 @@ extension Alarm { alarm.delta = delta return alarm } + + static func low(belowBG: Double?, predictiveMinutes: Int? = nil, persistentMinutes: Int? = nil) -> Self { + var alarm = Alarm(type: .low) + alarm.belowBG = belowBG + alarm.predictiveMinutes = predictiveMinutes + alarm.persistentMinutes = persistentMinutes + return alarm + } } // MARK: - AlarmData helpers @@ -81,6 +89,30 @@ extension AlarmData { ) } + static func withGlucose(readings: [GlucoseValue] = [], prediction: [GlucoseValue] = []) -> Self { + AlarmData( + bgReadings: readings, + predictionData: prediction, + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: nil, + latestTempTargetStart: nil, + latestTempTargetEnd: nil, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: [] + ) + } + static func withCarbs(_ carbs: [CarbSample]) -> Self { AlarmData( bgReadings: [], diff --git a/Tests/AlarmConditions/LowBGConditionTests.swift b/Tests/AlarmConditions/LowBGConditionTests.swift new file mode 100644 index 000000000..91ab02c9a --- /dev/null +++ b/Tests/AlarmConditions/LowBGConditionTests.swift @@ -0,0 +1,150 @@ +// LoopFollow +// LowBGConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct LowBGConditionTests { + let cond = LowBGCondition() + + /// Builds a forward prediction series at 5-minute spacing. + private func pred(_ values: [Int], from start: Date = Date()) -> [GlucoseValue] { + values.enumerated().map { i, v in + GlucoseValue(sgv: v, date: start.addingTimeInterval(Double(i) * 300)) + } + } + + /// Builds a recent BG history (oldest .. newest) at 5-minute spacing ending now. + private func history(_ values: [Int], endingAt now: Date = Date()) -> [GlucoseValue] { + values.enumerated().map { i, v in + let offset = Double(values.count - 1 - i) * 300 + return GlucoseValue(sgv: v, date: now.addingTimeInterval(-offset)) + } + } + + /// Recent readings that are clearly above any low threshold. Used by the + /// predictive tests so the persistence branch evaluates to `false` and the + /// result reflects the predictive look-ahead alone. + private var recentHigh: [GlucoseValue] { history([120, 120, 120]) } + + // MARK: - Loop (single forecast) + + @Test("#loop — predictive low within window fires") + func loopPredictiveLowFires() { + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15) + // ceil(30/5) = 6 points looked at; index 5 dips to 75 + let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75])) + + #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#loop — forecast low beyond window does not fire") + func loopPredictiveLowBeyondWindow() { + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 15, persistentMinutes: 15) + // ceil(15/5) = 3 points looked at (120, 110, 100); the low only appears later + let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75])) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#loop — forecast staying above threshold does not fire") + func loopForecastAboveThreshold() { + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 95, 90, 85])) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#loop — predictiveMinutes 0 disables look-ahead") + func loopPredictiveDisabled() { + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 0, persistentMinutes: 15) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([60, 60, 60])) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + // MARK: - Trio (lowest of four forecasts) + + @Test("#trio — combined forecast fires when one forecast dips low") + func trioCombinedForecastFires() { + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15) + // ZT stays high, but the IOB forecast dips to 70 — the per-point minimum + // must surface that dip so the alarm fires. + let forecasts: [[Double]] = [ + [150, 150, 150, 150, 150, 150], // ZT + [120, 110, 100, 90, 80, 70], // IOB + [130, 125, 120, 118, 116, 115], // COB + [140, 138, 136, 134, 132, 130], // UAM + ] + let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined) + + #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#trio — combined forecast does not fire when all forecasts stay high") + func trioCombinedForecastNoFire() { + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15) + let forecasts: [[Double]] = [ + [150, 150, 150, 150, 150, 150], + [120, 110, 100, 95, 92, 90], + [130, 125, 120, 118, 116, 115], + [140, 138, 136, 134, 132, 130], + ] + let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#trio — deep forecast below display floor still fires (not masked)") + func trioDeepLowNotMasked() { + let alarm = Alarm.low(belowBG: 70, predictiveMinutes: 30, persistentMinutes: 15) + // A forecast dipping to 30 (below the 39 display floor) is passed through + // raw, so the predictive-low alarm still fires on a genuine deep low. + let forecasts: [[Double]] = [ + [150, 150, 150, 150], + [120, 100, 60, 30], + ] + let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined) + + #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + // MARK: - Persistent / immediate low (device-agnostic) + + @Test("#persistent — all readings in window low fires") + func persistentLowFires() { + let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15) // window = 3 readings + let data = AlarmData.withGlucose(readings: history([75, 72, 70])) + + #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#persistent — one reading above threshold does not fire") + func persistentLowOneAbove() { + let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15) + let data = AlarmData.withGlucose(readings: history([75, 95, 70])) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#persistent — not enough samples does not fire") + func persistentNotEnoughSamples() { + let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15) // needs 3 + let data = AlarmData.withGlucose(readings: history([70, 70])) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#no belowBG threshold never fires") + func noThresholdNoFire() { + let alarm = Alarm.low(belowBG: nil, predictiveMinutes: 30, persistentMinutes: 15) + let data = AlarmData.withGlucose(readings: history([40, 40, 40]), prediction: pred([40, 40, 40])) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) + } +} diff --git a/Tests/AlarmConditions/LowestForecastTests.swift b/Tests/AlarmConditions/LowestForecastTests.swift new file mode 100644 index 000000000..3e821aa7d --- /dev/null +++ b/Tests/AlarmConditions/LowestForecastTests.swift @@ -0,0 +1,98 @@ +// LoopFollow +// LowestForecastTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct LowestForecastTests { + private let start: TimeInterval = 1_000_000 + + // MARK: - Combining + + @Test("#takes the per-point minimum across forecasts") + func perPointMinimum() { + let forecasts: [[Double]] = [ + [150, 140, 130, 120], + [120, 130, 100, 160], + [130, 110, 140, 90], + ] + let result = MainViewController.lowestForecast(forecasts: forecasts, start: start) + + #expect(result.map(\.sgv) == [120, 110, 100, 90]) + } + + @Test("#empty forecasts are ignored") + func emptyForecastsIgnored() { + let forecasts: [[Double]] = [ + [], + [120, 110, 100], + [], + ] + let result = MainViewController.lowestForecast(forecasts: forecasts, start: start) + + #expect(result.map(\.sgv) == [120, 110, 100]) + } + + @Test("#all empty yields no points") + func allEmpty() { + let result = MainViewController.lowestForecast(forecasts: [[], []], start: start) + #expect(result.isEmpty) + } + + @Test("#no forecasts yields no points") + func noForecasts() { + let result = MainViewController.lowestForecast(forecasts: [], start: start) + #expect(result.isEmpty) + } + + @Test("#uneven lengths use whichever forecasts still extend") + func unevenLengths() { + let forecasts: [[Double]] = [ + [100, 90], // shorter + [110, 95, 80, 70], // longer + ] + let result = MainViewController.lowestForecast(forecasts: forecasts, start: start) + + // Points 0-1 use the min of both; points 2-3 use only the longer forecast. + #expect(result.map(\.sgv) == [100, 90, 80, 70]) + } + + // MARK: - Capping + + @Test("#caps the number of points") + func capsPoints() { + let long = Array(repeating: 100.0, count: 30) + let result = MainViewController.lowestForecast(forecasts: [long], start: start, cap: 12) + + #expect(result.count == 12) + } + + @Test("#default cap is 12 points") + func defaultCap() { + let long = Array(repeating: 100.0, count: 30) + let result = MainViewController.lowestForecast(forecasts: [long], start: start) + + #expect(result.count == MainViewController.alarmForecastPointCap) + #expect(result.count == 12) + } + + // MARK: - Timestamps & rounding + + @Test("#points are spaced 5 minutes from start") + func timestampSpacing() { + let forecasts: [[Double]] = [[100, 100, 100]] + let result = MainViewController.lowestForecast(forecasts: forecasts, start: start) + + #expect(result.map { $0.date.timeIntervalSince1970 } == [start, start + 300, start + 600]) + } + + @Test("#values are rounded to the nearest integer") + func rounding() { + let forecasts: [[Double]] = [[100.4, 100.6, 99.5]] + let result = MainViewController.lowestForecast(forecasts: forecasts, start: start) + + #expect(result.map(\.sgv) == [100, 101, 100]) + } +} diff --git a/Tests/AlarmConditions/SensorAgeConditionTests.swift b/Tests/AlarmConditions/SensorAgeConditionTests.swift index ec407a916..dba9c66b3 100644 --- a/Tests/AlarmConditions/SensorAgeConditionTests.swift +++ b/Tests/AlarmConditions/SensorAgeConditionTests.swift @@ -1,6 +1,7 @@ // LoopFollow // SensorAgeConditionTests.swift +import Foundation @testable import LoopFollow import Testing