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
21 changes: 13 additions & 8 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,15 +45,9 @@ struct LowBGCondition: AlarmCondition {
predictiveMinutes > 0,
!data.predictionData.isEmpty
{
let lookAhead = min(
data.predictionData.count,
Int(ceil(Double(predictiveMinutes) / 5.0))
)
let points = Self.forecastPoints(forMinutes: predictiveMinutes)

for i in 0 ..< lookAhead where isLow(data.predictionData[i]) {
predictiveTrigger = true
break
}
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
9 changes: 5 additions & 4 deletions LoopFollow/Task/AlarmTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,18 @@ 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:
/// 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.
///
/// 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,
Expand Down
59 changes: 57 additions & 2 deletions Tests/AlarmConditions/LowBGConditionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand All @@ -43,12 +43,48 @@ 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() {
// 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 high = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120]))
let low = AlarmData.withGlucose(readings: recentHigh, prediction: pred([75]))

#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")
func loopForecastAboveThreshold() {
let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15)
Expand Down Expand Up @@ -99,6 +135,25 @@ 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.
// 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, 82, 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)
Expand Down
16 changes: 14 additions & 2 deletions Tests/AlarmConditions/LowestForecastTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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; 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)

#expect(result.count == MainViewController.alarmForecastPointCap)
}

// MARK: - Timestamps & rounding
Expand Down
Loading