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