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 1/2] 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 From 803e17668f0a53e22645b3c5a38a4aa9e7f31f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 19 Jul 2026 22:59:23 +0200 Subject: [PATCH 2/2] Evaluate the low alarm's threshold in the condition so predictive low can fire The BG-limit gate treated the low alarm's belowBG as an activation limit, blocking evaluation until the latest reading was already at or below it, which made the predictive look-ahead unreachable. Conditions can now declare that they check their own threshold; LowBGCondition does, evaluating the latest reading, persistence, and forecast in one place. --- .../Alarm/AlarmCondition/AlarmCondition.swift | 15 ++++- .../Alarm/AlarmCondition/LowBGCondition.swift | 12 +++- .../AlarmConditions/LowBGConditionTests.swift | 61 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift index eb1011443..8e60bcb9d 100644 --- a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift @@ -8,9 +8,15 @@ protocol AlarmCondition { init() /// pure, per-alarm logic against `AlarmData` func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool + /// `true` when `belowBG`/`aboveBG` are this condition's own trigger + /// threshold, checked in `evaluate`; `false` (the default) treats them + /// as activation limits enforced by `passesBGLimits`. + var checksOwnBGLimits: Bool { get } } extension AlarmCondition { + var checksOwnBGLimits: Bool { false } + /// Returns `true` when the alarm is allowed to continue evaluating /// after BG-limit checks; `false` blocks it immediately. func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool { @@ -24,7 +30,14 @@ extension AlarmCondition { if alarm.type.isBGBased && !haveBG { return false } // ──────────────────────────────────── - // 2. No limits? we’re done. + // 2. Conditions that check their own threshold in `evaluate` + // skip the limit gate (the data requirement above applies + // regardless). + // ──────────────────────────────────── + if checksOwnBGLimits { return true } + + // ──────────────────────────────────── + // 3. No limits? we’re done. // ──────────────────────────────────── if alarm.belowBG == nil && alarm.aboveBG == nil { return true } diff --git a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift index 77abc55a1..31094ad66 100644 --- a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift @@ -4,17 +4,23 @@ import Foundation /// Fires when: -/// • every BG in `persistentMinutes` (if set) **and** the latest BG are ≤ `threshold`; **or** -/// • any predicted BG within `predictiveMinutes` is ≤ `threshold`. +/// • every BG in `persistentMinutes` (if set) **and** the latest BG are ≤ `belowBG`; **or** +/// • any predicted BG within `predictiveMinutes` is ≤ `belowBG`. struct LowBGCondition: AlarmCondition { static let type: AlarmType = .low init() {} + /// `belowBG` is this alarm's trigger threshold, not an activation limit: + /// the predictive look-ahead must be able to run while the latest BG is + /// still above it, so `evaluate` checks the threshold itself. + var checksOwnBGLimits: Bool { true } + func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool { // ──────────────────────────────── // 0. sanity checks // ──────────────────────────────── guard let belowBG = alarm.belowBG else { return false } + guard let latest = data.bgReadings.last, latest.sgv > 0 else { return false } func isLow(_ g: GlucoseValue) -> Bool { g.sgv > 0 && Double(g.sgv) <= belowBG @@ -60,6 +66,6 @@ struct LowBGCondition: AlarmCondition { // ──────────────────────────────── // 3. final decision // ──────────────────────────────── - return persistentOK || predictiveTrigger + return (isLow(latest) && persistentOK) || predictiveTrigger } } diff --git a/Tests/AlarmConditions/LowBGConditionTests.swift b/Tests/AlarmConditions/LowBGConditionTests.swift index 91ab02c9a..287799d82 100644 --- a/Tests/AlarmConditions/LowBGConditionTests.swift +++ b/Tests/AlarmConditions/LowBGConditionTests.swift @@ -147,4 +147,65 @@ struct LowBGConditionTests { #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) } + + // MARK: - Immediate low (no persistence, no prediction) + + @Test("#immediate — latest reading at/below threshold fires") + func immediateLowFires() { + let alarm = Alarm.low(belowBG: 80) + let data = AlarmData.withGlucose(readings: history([100, 90, 78])) + + #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#immediate — latest reading above threshold does not fire") + func immediateAboveThreshold() { + let alarm = Alarm.low(belowBG: 80) + let data = AlarmData.withGlucose(readings: history([85, 83, 82])) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#immediate — no readings does not fire even with a low forecast") + func noReadingsNoFire() { + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30) + let data = AlarmData.withGlucose(readings: [], prediction: pred([60, 60])) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + // MARK: - Full pipeline (shouldFire, including the BG-limit gate) + + @Test("#pipeline — predictive low fires while BG is still above the threshold") + func predictiveFiresThroughGate() { + let alarm = Alarm.low(belowBG: 108, predictiveMinutes: 30, persistentMinutes: 0) + // The scenario predictive low exists for: BG hovering above the + // threshold while the forecast dips below it. + let data = AlarmData.withGlucose( + readings: history([110, 109, 110]), + prediction: pred([110, 101, 92, 82]) + ) + + #expect(cond.shouldFire(alarm: alarm, data: data, now: Date(), config: .default)) + } + + @Test("#pipeline — BG above threshold with a high forecast does not fire") + func aboveThresholdHighForecastNoFire() { + let alarm = Alarm.low(belowBG: 108, predictiveMinutes: 30, persistentMinutes: 0) + let data = AlarmData.withGlucose( + readings: history([110, 109, 110]), + prediction: pred([110, 112, 115, 118]) + ) + + #expect(!cond.shouldFire(alarm: alarm, data: data, now: Date(), config: .default)) + } + + @Test("#pipeline — BG-limit gate blocks other alarm types") + func gateAppliesToOtherTypes() { + var alarm = Alarm(type: .temporary) + alarm.belowBG = 108 + let data = AlarmData.withGlucose(readings: history([110, 109, 110])) + + #expect(!TemporaryCondition().shouldFire(alarm: alarm, data: data, now: Date(), config: .default)) + } }