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] 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)