From 2e358a0bcd36326a6f31f2b80c2acde8c72b5efa Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Tue, 28 Jul 2026 15:21:36 -0600 Subject: [PATCH] Look ahead the full predictive low window The forecast series starts at the current glucose value rather than the first future point, so iterating ceil(predictiveMinutes / 5) entries stopped one step short of the requested horizon. A 20-minute setting only reached 15 minutes ahead, and a 15-minute one reached 10. At every setting the dial behaved as though it were one notch lower. Take one more point so the setting matches its label, and raise the alarm forecast cap from 12 points to 13 so the 60-minute maximum is reachable instead of capping at 55. loopHorizonReachesRequestedMinutes is the regression test: the dip sits at index 4, so a 20-minute look-ahead has to reach it and a 15-minute one must not. The other added cases cover bounds safety when the horizon exceeds the published series and pin the existing behaviour of following the longest forecast rather than the first to run out. --- .../Alarm/AlarmCondition/LowBGCondition.swift | 12 ++--- LoopFollow/Task/AlarmTask.swift | 10 ++-- .../AlarmConditions/LowBGConditionTests.swift | 54 ++++++++++++++++++- .../AlarmConditions/LowestForecastTests.swift | 16 +++++- 4 files changed, 76 insertions(+), 16 deletions(-) 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