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
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ struct LowBgAlarmEditor: View {

var body: some View {
Group {
InfoBanner(text: "This warns you if the glucose is too low now or might be soon, based on predictions. Note: predictions is currently not available for Trio.")
InfoBanner(text: "This warns you if the glucose is too low now or might be soon, based on the forecast.")

AlarmGeneralSection(alarm: $alarm)

AlarmBGSection(
header: "Low Limit",
footer: "Alert when any reading or prediction is at or below this value.",
footer: "Alert when any reading or forecast is at or below this value.",
title: "BG",
range: 40 ... 150,
value: $alarm.belowBG
Expand All @@ -33,7 +33,7 @@ struct LowBgAlarmEditor: View {

AlarmStepperSection(
header: "PREDICTION",
footer: "Look ahead this many minutes in Loop’s prediction; "
footer: "Look ahead this many minutes in the forecast; "
+ "if any future value is at or below the threshold, "
+ "you’ll be warned early. Set 0 to disable.",
title: "Predictive",
Expand Down
57 changes: 54 additions & 3 deletions LoopFollow/Task/AlarmTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ extension MainViewController {
bgReadings: self.bgData
.suffix(24)
.map { GlucoseValue(sgv: $0.sgv, date: Date(timeIntervalSince1970: $0.date)) }, /// These are oldest .. newest
predictionData: self.predictionData
.prefix(12)
.map { GlucoseValue(sgv: $0.sgv, date: Date(timeIntervalSince1970: $0.date)) }, /// These are oldest .. newest, Predictions not currently available for Trio
predictionData: self.alarmPredictionData(), /// These are oldest .. newest
expireDate: Storage.shared.expirationDate.value,
lastLoopTime: Observable.shared.alertLastLoopTime.value,
latestOverrideStart: latestOverrideStart,
Expand Down Expand Up @@ -72,6 +70,59 @@ extension MainViewController {
}
}

/// Builds the forward glucose series the low alarm looks ahead in,
/// oldest .. newest at 5-minute spacing.
///
/// Loop reports a single forecast, already stored in `predictionData`. Trio
/// reports four forecasts, collapsed into the lowest value per point in time
/// by `lowestForecast(forecasts:start:cap:)`.
func alarmPredictionData() -> [GlucoseValue] {
if Storage.shared.device.value == "Loop" {
return predictionData
.prefix(MainViewController.alarmForecastPointCap)
.map { GlucoseValue(sgv: $0.sgv, date: Date(timeIntervalSince1970: $0.date)) }
}

guard let predBGs = openAPSPredBGs else { return [] }

let forecasts = ["ZT", "IOB", "COB", "UAM"].compactMap { predBGs[$0] }
return MainViewController.lowestForecast(
forecasts: forecasts,
start: openAPSPredUpdatedTime ?? Date().timeIntervalSince1970
)
}

/// 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

/// 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.
static func lowestForecast(
forecasts: [[Double]],
start: TimeInterval,
cap: Int = alarmForecastPointCap
) -> [GlucoseValue] {
let nonEmpty = forecasts.filter { !$0.isEmpty }
guard !nonEmpty.isEmpty else { return [] }

let count = min(nonEmpty.map { $0.count }.max() ?? 0, cap)

return (0 ..< count).compactMap { i in
let valuesAtIndex = nonEmpty.compactMap { i < $0.count ? $0[i] : nil }
guard let lowest = valuesAtIndex.min() else { return nil }
return GlucoseValue(
sgv: Int(lowest.rounded()),
date: Date(timeIntervalSince1970: start + Double(i) * 300)
)
}
}

func saveLatestAlarmDataToFile(_ alarmData: AlarmData) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
Expand Down
32 changes: 32 additions & 0 deletions Tests/AlarmConditions/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ extension Alarm {
alarm.delta = delta
return alarm
}

static func low(belowBG: Double?, predictiveMinutes: Int? = nil, persistentMinutes: Int? = nil) -> Self {
var alarm = Alarm(type: .low)
alarm.belowBG = belowBG
alarm.predictiveMinutes = predictiveMinutes
alarm.persistentMinutes = persistentMinutes
return alarm
}
}

// MARK: - AlarmData helpers
Expand Down Expand Up @@ -81,6 +89,30 @@ extension AlarmData {
)
}

static func withGlucose(readings: [GlucoseValue] = [], prediction: [GlucoseValue] = []) -> Self {
AlarmData(
bgReadings: readings,
predictionData: prediction,
expireDate: nil,
lastLoopTime: nil,
latestOverrideStart: nil,
latestOverrideEnd: nil,
latestTempTargetStart: nil,
latestTempTargetEnd: nil,
recBolus: nil,
COB: nil,
sageInsertTime: nil,
pumpInsertTime: nil,
latestPumpVolume: nil,
IOB: nil,
recentBoluses: [],
latestBattery: nil,
latestPumpBattery: nil,
batteryHistory: [],
recentCarbs: []
)
}

static func withCarbs(_ carbs: [CarbSample]) -> Self {
AlarmData(
bgReadings: [],
Expand Down
150 changes: 150 additions & 0 deletions Tests/AlarmConditions/LowBGConditionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// LoopFollow
// LowBGConditionTests.swift

import Foundation
@testable import LoopFollow
import Testing

@Suite(.serialized)
struct LowBGConditionTests {
let cond = LowBGCondition()

/// Builds a forward prediction series at 5-minute spacing.
private func pred(_ values: [Int], from start: Date = Date()) -> [GlucoseValue] {
values.enumerated().map { i, v in
GlucoseValue(sgv: v, date: start.addingTimeInterval(Double(i) * 300))
}
}

/// Builds a recent BG history (oldest .. newest) at 5-minute spacing ending now.
private func history(_ values: [Int], endingAt now: Date = Date()) -> [GlucoseValue] {
values.enumerated().map { i, v in
let offset = Double(values.count - 1 - i) * 300
return GlucoseValue(sgv: v, date: now.addingTimeInterval(-offset))
}
}

/// Recent readings that are clearly above any low threshold. Used by the
/// predictive tests so the persistence branch evaluates to `false` and the
/// result reflects the predictive look-ahead alone.
private var recentHigh: [GlucoseValue] { history([120, 120, 120]) }

// MARK: - Loop (single forecast)

@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
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 — 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
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 — forecast staying above threshold does not fire")
func loopForecastAboveThreshold() {
let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15)
let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 95, 90, 85]))

#expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
}

@Test("#loop — predictiveMinutes 0 disables look-ahead")
func loopPredictiveDisabled() {
let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 0, persistentMinutes: 15)
let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([60, 60, 60]))

#expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
}

// MARK: - Trio (lowest of four forecasts)

@Test("#trio — combined forecast fires when one forecast dips low")
func trioCombinedForecastFires() {
let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15)
// ZT stays high, but the IOB forecast dips to 70 — the per-point minimum
// must surface that dip so the alarm fires.
let forecasts: [[Double]] = [
[150, 150, 150, 150, 150, 150], // ZT
[120, 110, 100, 90, 80, 70], // IOB
[130, 125, 120, 118, 116, 115], // COB
[140, 138, 136, 134, 132, 130], // UAM
]
let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970)
let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined)

#expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
}

@Test("#trio — combined forecast does not fire when all forecasts stay high")
func trioCombinedForecastNoFire() {
let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15)
let forecasts: [[Double]] = [
[150, 150, 150, 150, 150, 150],
[120, 110, 100, 95, 92, 90],
[130, 125, 120, 118, 116, 115],
[140, 138, 136, 134, 132, 130],
]
let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970)
let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined)

#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)
// A forecast dipping to 30 (below the 39 display floor) is passed through
// raw, so the predictive-low alarm still fires on a genuine deep low.
let forecasts: [[Double]] = [
[150, 150, 150, 150],
[120, 100, 60, 30],
]
let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970)
let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined)

#expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
}

// MARK: - Persistent / immediate low (device-agnostic)

@Test("#persistent — all readings in window low fires")
func persistentLowFires() {
let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15) // window = 3 readings
let data = AlarmData.withGlucose(readings: history([75, 72, 70]))

#expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
}

@Test("#persistent — one reading above threshold does not fire")
func persistentLowOneAbove() {
let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15)
let data = AlarmData.withGlucose(readings: history([75, 95, 70]))

#expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
}

@Test("#persistent — not enough samples does not fire")
func persistentNotEnoughSamples() {
let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15) // needs 3
let data = AlarmData.withGlucose(readings: history([70, 70]))

#expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
}

@Test("#no belowBG threshold never fires")
func noThresholdNoFire() {
let alarm = Alarm.low(belowBG: nil, predictiveMinutes: 30, persistentMinutes: 15)
let data = AlarmData.withGlucose(readings: history([40, 40, 40]), prediction: pred([40, 40, 40]))

#expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
}
}
98 changes: 98 additions & 0 deletions Tests/AlarmConditions/LowestForecastTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// LoopFollow
// LowestForecastTests.swift

import Foundation
@testable import LoopFollow
import Testing

@Suite(.serialized)
struct LowestForecastTests {
private let start: TimeInterval = 1_000_000

// MARK: - Combining

@Test("#takes the per-point minimum across forecasts")
func perPointMinimum() {
let forecasts: [[Double]] = [
[150, 140, 130, 120],
[120, 130, 100, 160],
[130, 110, 140, 90],
]
let result = MainViewController.lowestForecast(forecasts: forecasts, start: start)

#expect(result.map(\.sgv) == [120, 110, 100, 90])
}

@Test("#empty forecasts are ignored")
func emptyForecastsIgnored() {
let forecasts: [[Double]] = [
[],
[120, 110, 100],
[],
]
let result = MainViewController.lowestForecast(forecasts: forecasts, start: start)

#expect(result.map(\.sgv) == [120, 110, 100])
}

@Test("#all empty yields no points")
func allEmpty() {
let result = MainViewController.lowestForecast(forecasts: [[], []], start: start)
#expect(result.isEmpty)
}

@Test("#no forecasts yields no points")
func noForecasts() {
let result = MainViewController.lowestForecast(forecasts: [], start: start)
#expect(result.isEmpty)
}

@Test("#uneven lengths use whichever forecasts still extend")
func unevenLengths() {
let forecasts: [[Double]] = [
[100, 90], // shorter
[110, 95, 80, 70], // longer
]
let result = MainViewController.lowestForecast(forecasts: forecasts, start: start)

// Points 0-1 use the min of both; points 2-3 use only the longer forecast.
#expect(result.map(\.sgv) == [100, 90, 80, 70])
}

// MARK: - Capping

@Test("#caps the number of points")
func capsPoints() {
let long = Array(repeating: 100.0, count: 30)
let result = MainViewController.lowestForecast(forecasts: [long], start: start, cap: 12)

#expect(result.count == 12)
}

@Test("#default cap is 12 points")
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)
}

// MARK: - Timestamps & rounding

@Test("#points are spaced 5 minutes from start")
func timestampSpacing() {
let forecasts: [[Double]] = [[100, 100, 100]]
let result = MainViewController.lowestForecast(forecasts: forecasts, start: start)

#expect(result.map { $0.date.timeIntervalSince1970 } == [start, start + 300, start + 600])
}

@Test("#values are rounded to the nearest integer")
func rounding() {
let forecasts: [[Double]] = [[100.4, 100.6, 99.5]]
let result = MainViewController.lowestForecast(forecasts: forecasts, start: start)

#expect(result.map(\.sgv) == [100, 101, 100])
}
}
1 change: 1 addition & 0 deletions Tests/AlarmConditions/SensorAgeConditionTests.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// LoopFollow
// SensorAgeConditionTests.swift

import Foundation
@testable import LoopFollow
import Testing

Expand Down
Loading