Skip to content
Merged
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: 14 additions & 1 deletion LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ protocol AlarmCondition {
init()
/// pure, per-alarm logic against `AlarmData`
func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool
/// `true` when `belowBG`/`aboveBG` are this condition's own trigger
/// threshold, checked in `evaluate`; `false` (the default) treats them
/// as activation limits enforced by `passesBGLimits`.
var checksOwnBGLimits: Bool { get }
}

extension AlarmCondition {
var checksOwnBGLimits: Bool { false }

/// Returns `true` when the alarm is allowed to continue evaluating
/// after BG-limit checks; `false` blocks it immediately.
func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool {
Expand All @@ -24,7 +30,14 @@ extension AlarmCondition {
if alarm.type.isBGBased && !haveBG { return false }

// ────────────────────────────────────
// 2. No limits? we’re done.
// 2. Conditions that check their own threshold in `evaluate`
// skip the limit gate (the data requirement above applies
// regardless).
// ────────────────────────────────────
if checksOwnBGLimits { return true }

// ────────────────────────────────────
// 3. No limits? we’re done.
// ────────────────────────────────────
if alarm.belowBG == nil && alarm.aboveBG == nil { return true }

Expand Down
12 changes: 9 additions & 3 deletions LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,23 @@
import Foundation

/// Fires when:
/// • every BG in `persistentMinutes` (if set) **and** the latest BG are ≤ `threshold`; **or**
/// • any predicted BG within `predictiveMinutes` is ≤ `threshold`.
/// • every BG in `persistentMinutes` (if set) **and** the latest BG are ≤ `belowBG`; **or**
/// • any predicted BG within `predictiveMinutes` is ≤ `belowBG`.
struct LowBGCondition: AlarmCondition {
static let type: AlarmType = .low
init() {}

/// `belowBG` is this alarm's trigger threshold, not an activation limit:
/// the predictive look-ahead must be able to run while the latest BG is
/// still above it, so `evaluate` checks the threshold itself.
var checksOwnBGLimits: Bool { true }

func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool {
// ────────────────────────────────
// 0. sanity checks
// ────────────────────────────────
guard let belowBG = alarm.belowBG else { return false }
guard let latest = data.bgReadings.last, latest.sgv > 0 else { return false }

func isLow(_ g: GlucoseValue) -> Bool {
g.sgv > 0 && Double(g.sgv) <= belowBG
Expand Down Expand Up @@ -73,6 +79,6 @@ struct LowBGCondition: AlarmCondition {
// ────────────────────────────────
// 4. final decision
// ────────────────────────────────
return persistentOK || predictiveTrigger
return (isLow(latest) && persistentOK) || predictiveTrigger
}
}
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 @@ -74,6 +72,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
47 changes: 43 additions & 4 deletions Tests/AlarmConditions/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ extension Alarm {
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
}

static func fastDrop(delta: Double?, window: Int?) -> Self {
var alarm = Alarm(type: .fastDrop)
alarm.delta = delta
Expand Down Expand Up @@ -61,7 +69,8 @@ extension AlarmData {
latestBatteryIsCharging: nil,
latestPumpBattery: nil,
batteryHistory: [],
recentCarbs: []
recentCarbs: [],
dbSizePercentage: nil
)
}

Expand All @@ -86,7 +95,8 @@ extension AlarmData {
latestBatteryIsCharging: nil,
latestPumpBattery: nil,
batteryHistory: [],
recentCarbs: []
recentCarbs: [],
dbSizePercentage: nil
)
}

Expand Down Expand Up @@ -116,9 +126,37 @@ extension AlarmData {
IOB: nil,
recentBoluses: [],
latestBattery: nil,
latestBatteryIsCharging: nil,
latestPumpBattery: nil,
batteryHistory: [],
recentCarbs: [],
dbSizePercentage: nil
)
}

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,
latestBatteryIsCharging: nil,
latestPumpBattery: nil,
batteryHistory: [],
recentCarbs: []
recentCarbs: [],
dbSizePercentage: nil
)
}

Expand All @@ -143,7 +181,8 @@ extension AlarmData {
latestBatteryIsCharging: nil,
latestPumpBattery: nil,
batteryHistory: [],
recentCarbs: carbs
recentCarbs: carbs,
dbSizePercentage: nil
)
}
}
Loading
Loading