Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions LoopFollow/Task/AlarmTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 9 additions & 4 deletions Tests/AlarmConditions/LowBGConditionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
]
Expand Down
4 changes: 2 additions & 2 deletions Tests/AlarmConditions/LowestForecastTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down