From 2e358a0bcd36326a6f31f2b80c2acde8c72b5efa Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Tue, 28 Jul 2026 15:21:36 -0600 Subject: [PATCH 1/2] 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 From 2a5f8643f78ea1fbf7038f910d6b92de31b8c8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 26 Aug 2026 14:49:11 +0200 Subject: [PATCH 2/2] Share the horizon arithmetic and harden the look-ahead tests LowBGCondition owns the horizon arithmetic: forecastPoints(forMinutes:) converts a look-ahead in minutes into a point count, and maxPredictiveMinutes is the longest look-ahead the alarm editor offers. Both alarmForecastPointCap and the editor's Predictive stepper range derive from these, keeping the formula, the cap, and the UI bound in step. trioShortForecastKeepsHorizon keeps every combined point before index 5 above the threshold, so its assertion holds only when the 25-minute look-ahead reaches the full horizon. loopSinglePointForecast pins both sides of the current-value convention: a lone high point stays silent and a lone low point fires. --- .../Alarm/AlarmCondition/LowBGCondition.swift | 15 ++++++++++++--- .../AlarmEditing/Editors/LowBgAlarmEditor.swift | 2 +- LoopFollow/Task/AlarmTask.swift | 7 +++---- Tests/AlarmConditions/LowBGConditionTests.swift | 13 +++++++++---- Tests/AlarmConditions/LowestForecastTests.swift | 4 ++-- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift index 1ba07251a..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,9 +45,7 @@ struct LowBGCondition: AlarmCondition { predictiveMinutes > 0, !data.predictionData.isEmpty { - // 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 + let points = Self.forecastPoints(forMinutes: predictiveMinutes) 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 dd913b714..6c6b341ae 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -94,10 +94,9 @@ extension MainViewController { ) } - /// 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 + /// 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. diff --git a/Tests/AlarmConditions/LowBGConditionTests.swift b/Tests/AlarmConditions/LowBGConditionTests.swift index ae30004db..c7dad6bec 100644 --- a/Tests/AlarmConditions/LowBGConditionTests.swift +++ b/Tests/AlarmConditions/LowBGConditionTests.swift @@ -75,10 +75,14 @@ struct LowBGConditionTests { @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 data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120])) + let high = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120])) + let low = AlarmData.withGlucose(readings: recentHigh, prediction: pred([75])) - #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) + #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") @@ -134,11 +138,12 @@ struct LowBGConditionTests { @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. + // 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, 76, 69], // IOB + [118, 106, 95, 85, 82, 69], // IOB [118, 116, 114, 113, 112, 111], // COB [118, 112, 108, 105, 103, 101], // UAM ] diff --git a/Tests/AlarmConditions/LowestForecastTests.swift b/Tests/AlarmConditions/LowestForecastTests.swift index cdc1cb94d..acd6662fe 100644 --- a/Tests/AlarmConditions/LowestForecastTests.swift +++ b/Tests/AlarmConditions/LowestForecastTests.swift @@ -81,8 +81,8 @@ struct LowestForecastTests { @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. + // 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)