diff --git a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift index cb4610f74..1ba07251a 100644 --- a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift @@ -34,15 +34,11 @@ struct LowBGCondition: AlarmCondition { predictiveMinutes > 0, !data.predictionData.isEmpty { - let lookAhead = min( - data.predictionData.count, - Int(ceil(Double(predictiveMinutes) / 5.0)) - ) + // The first point is the current value, so reaching `predictiveMinutes` + // ahead takes ceil(minutes / 5) points beyond it. + let points = Int(ceil(Double(predictiveMinutes) / 5.0)) + 1 - 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/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index c665c738d..dd913b714 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -94,9 +94,10 @@ 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. The + /// first is the current value, so 13 points reach 60 minutes ahead, matching + /// the predictive look-ahead's upper bound. + static let alarmForecastPointCap = 13 /// 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 +105,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..ae30004db 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,44 @@ 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() { + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120])) + + #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) @@ -99,6 +131,24 @@ 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. + // A 25-minute look-ahead has to reach it. + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 25, persistentMinutes: 15) + let forecasts: [[Double]] = [ + [118, 115, 113], // ZT + [118, 106, 95, 85, 76, 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..cdc1cb94d 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, so the series + // has to follow the longest one rather than the first to run out. + 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