diff --git a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift index cb4610f74..2d48fe25f 100644 --- a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift @@ -8,6 +8,17 @@ import Foundation /// • any predicted BG within `predictiveMinutes` is ≤ `belowBG`. struct LowBGCondition: AlarmCondition { static let type: AlarmType = .low + + /// Longest predictive look-ahead offered by the alarm editor, in minutes. + static let maxPredictiveMinutes = 60 + + /// Number of forecast points (5-minute spacing) needed to look `minutes` + /// ahead: the first point is the current value, so the horizon takes + /// ceil(minutes / 5) points beyond it. + static func forecastPoints(forMinutes minutes: Int) -> Int { + Int(ceil(Double(minutes) / 5.0)) + 1 + } + init() {} /// `belowBG` is this alarm's trigger threshold, not an activation limit: @@ -34,15 +45,9 @@ struct LowBGCondition: AlarmCondition { predictiveMinutes > 0, !data.predictionData.isEmpty { - let lookAhead = min( - data.predictionData.count, - Int(ceil(Double(predictiveMinutes) / 5.0)) - ) + let points = Self.forecastPoints(forMinutes: predictiveMinutes) - for i in 0 ..< lookAhead where isLow(data.predictionData[i]) { - predictiveTrigger = true - break - } + predictiveTrigger = data.predictionData.prefix(points).contains(where: isLow) } // ──────────────────────────────── diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift index a21db2239..a71f7f60b 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift @@ -37,7 +37,7 @@ struct LowBgAlarmEditor: View { + "if any future value is at or below the threshold, " + "you’ll be warned early. Set 0 to disable.", title: "Predictive", - range: 0 ... 60, + range: 0 ... Double(LowBGCondition.maxPredictiveMinutes), step: 5, unitLabel: alarm.type.snoozeTimeUnit.label, value: $alarm.predictiveMinutes diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index c665c738d..6c6b341ae 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -94,9 +94,9 @@ extension MainViewController { ) } - /// 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 + /// Maximum number of points (5-minute spacing) the low alarm looks at: + /// enough to reach the longest predictive look-ahead the editor offers. + static let alarmForecastPointCap = LowBGCondition.forecastPoints(forMinutes: LowBGCondition.maxPredictiveMinutes) /// Collapses several forecasts into a single series by taking the **lowest** /// value at each point in time, oldest .. newest at 5-minute spacing. @@ -104,7 +104,8 @@ extension MainViewController { /// 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. + /// and each point uses whichever forecasts still extend that far, so one short + /// forecast does not shorten the look-ahead. static func lowestForecast( forecasts: [[Double]], start: TimeInterval, diff --git a/Tests/AlarmConditions/LowBGConditionTests.swift b/Tests/AlarmConditions/LowBGConditionTests.swift index 287799d82..c7dad6bec 100644 --- a/Tests/AlarmConditions/LowBGConditionTests.swift +++ b/Tests/AlarmConditions/LowBGConditionTests.swift @@ -34,7 +34,7 @@ struct LowBGConditionTests { @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 + // 30 minutes ahead is index 6; the dip to 75 sits at index 5 let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75])) #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) @@ -43,12 +43,48 @@ struct LowBGConditionTests { @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 + // 15 minutes ahead is index 3, so 120, 110, 100 and 90; 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 — the look-ahead reaches the full requested horizon") + func loopHorizonReachesRequestedMinutes() { + // The dip sits exactly 20 minutes out, at index 4. A 20-minute + // look-ahead must reach it; a 15-minute one must stop short. + let forecast = pred([118, 106, 95, 85, 70, 62]) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: forecast) + + let twenty = Alarm.low(belowBG: 80, predictiveMinutes: 20, persistentMinutes: 15) + let fifteen = Alarm.low(belowBG: 80, predictiveMinutes: 15, persistentMinutes: 15) + + #expect(cond.evaluate(alarm: twenty, data: data, now: Date())) + #expect(!cond.evaluate(alarm: fifteen, data: data, now: Date())) + } + + @Test("#loop — a horizon longer than the forecast uses what is published") + func loopHorizonBeyondSeriesLength() { + // 60 minutes asks for 13 points but only 3 exist. The look-ahead must + // stay in bounds and still see the low at the end of what is published. + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 100, 75])) + + #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#loop — a single forecast point is the current value only") + func loopSinglePointForecast() { + // Index 0 is the current value: a lone point is examined, so it fires + // exactly when it is at or below the threshold. + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15) + let high = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120])) + let low = AlarmData.withGlucose(readings: recentHigh, prediction: pred([75])) + + #expect(!cond.evaluate(alarm: alarm, data: high, now: Date())) + #expect(cond.evaluate(alarm: alarm, data: low, now: Date())) + } + @Test("#loop — forecast staying above threshold does not fire") func loopForecastAboveThreshold() { let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15) @@ -99,6 +135,25 @@ struct LowBGConditionTests { #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) } + @Test("#trio — a forecast running short does not shorten the look-ahead") + func trioShortForecastKeepsHorizon() { + // ZT stops after three points while IOB keeps falling to 69 at index 5. + // Every earlier combined point stays above the threshold, so the alarm + // fires only if the 25-minute look-ahead reaches index 5. + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 25, persistentMinutes: 15) + let forecasts: [[Double]] = [ + [118, 115, 113], // ZT + [118, 106, 95, 85, 82, 69], // IOB + [118, 116, 114, 113, 112, 111], // COB + [118, 112, 108, 105, 103, 101], // UAM + ] + let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined) + + #expect(combined.count == 6) + #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) diff --git a/Tests/AlarmConditions/LowestForecastTests.swift b/Tests/AlarmConditions/LowestForecastTests.swift index 3e821aa7d..acd6662fe 100644 --- a/Tests/AlarmConditions/LowestForecastTests.swift +++ b/Tests/AlarmConditions/LowestForecastTests.swift @@ -69,13 +69,25 @@ struct LowestForecastTests { #expect(result.count == 12) } - @Test("#default cap is 12 points") + @Test("#the default cap reaches 60 minutes ahead") 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) + // The first point is the current value, so the last one is 60 minutes out. + #expect(result.last?.date.timeIntervalSince1970 == start + 3600) + } + + @Test("#a short forecast does not cap the rest") + func shortForecastDoesNotCapTheRest() { + // Which forecast runs shortest varies from cycle to cycle; the series + // follows the longest one. + let short = Array(repeating: 100.0, count: 8) + let long = Array(repeating: 100.0, count: 20) + let result = MainViewController.lowestForecast(forecasts: [short, long], start: start) + + #expect(result.count == MainViewController.alarmForecastPointCap) } // MARK: - Timestamps & rounding