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
12 changes: 4 additions & 8 deletions LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

// ────────────────────────────────
Expand Down
10 changes: 6 additions & 4 deletions LoopFollow/Task/AlarmTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,19 @@ 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.
///
/// 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
54 changes: 52 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,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)
Expand Down Expand Up @@ -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)
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, 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
Expand Down