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)