Skip to content

Commit 01f689b

Browse files
authored
Add configurable CGM sensor lifetime for sensor change alarm (#579)
* Add configurable CGM sensor lifetime for sensor change alarm The sensor change alarm was hardcoded to a 10-day lifetime, causing false alerts for users with 15-day G7 sensors. Add a per-alarm sensorLifetimeDays setting (7-15 days, default 10) so users can match their actual sensor duration. Also fix a bug where the sensor and pump change alarms would fire when no insert time was available (defaulting to epoch 0). * Update comment for SensorAgeCondition behavior
1 parent 5dac0e5 commit 01f689b

File tree

6 files changed

+154
-10
lines changed

6 files changed

+154
-10
lines changed

LoopFollow/Alarm/Alarm.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ struct Alarm: Identifiable, Codable, Equatable {
107107
case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits
108108
case missedBolusIgnoreUnderGrams, missedBolusIgnoreUnderBG
109109
case bolusCountThreshold, bolusWindowMinutes
110+
case sensorLifetimeDays
110111
}
111112

112113
init(from decoder: Decoder) throws {
@@ -137,6 +138,7 @@ struct Alarm: Identifiable, Codable, Equatable {
137138
missedBolusIgnoreUnderBG = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderBG)
138139
bolusCountThreshold = try container.decodeIfPresent(Int.self, forKey: .bolusCountThreshold)
139140
bolusWindowMinutes = try container.decodeIfPresent(Int.self, forKey: .bolusWindowMinutes)
141+
sensorLifetimeDays = try container.decodeIfPresent(Int.self, forKey: .sensorLifetimeDays)
140142
}
141143

142144
func encode(to encoder: Encoder) throws {
@@ -165,6 +167,7 @@ struct Alarm: Identifiable, Codable, Equatable {
165167
try container.encodeIfPresent(missedBolusIgnoreUnderBG, forKey: .missedBolusIgnoreUnderBG)
166168
try container.encodeIfPresent(bolusCountThreshold, forKey: .bolusCountThreshold)
167169
try container.encodeIfPresent(bolusWindowMinutes, forKey: .bolusWindowMinutes)
170+
try container.encodeIfPresent(sensorLifetimeDays, forKey: .sensorLifetimeDays)
168171
}
169172

170173
// ─────────────────────────────────────────────────────────────
@@ -191,6 +194,12 @@ struct Alarm: Identifiable, Codable, Equatable {
191194
/// ...within this many minutes
192195
var bolusWindowMinutes: Int?
193196

197+
// ─────────────────────────────────────────────────────────────
198+
// Sensor‑Change fields ─
199+
// ─────────────────────────────────────────────────────────────
200+
/// CGM sensor lifetime in days (e.g. 10 for Dexcom G6, 15 for G7 15-day)
201+
var sensorLifetimeDays: Int?
202+
194203
/// Function for when the alarm is triggered.
195204
/// If this alarm, all alarms is disabled or snoozed, then should not be called. This or all alarmd could be muted, then this function will just generate a notification.
196205
func trigger(config: AlarmConfiguration, now: Date) {
@@ -318,6 +327,7 @@ struct Alarm: Identifiable, Codable, Equatable {
318327
case .sensorChange:
319328
soundFile = .wakeUpWillYou
320329
threshold = 12
330+
sensorLifetimeDays = 10
321331
case .pumpChange:
322332
soundFile = .wakeUpWillYou
323333
threshold = 12

LoopFollow/Alarm/AlarmCondition/PumpChangeCondition.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ struct PumpChangeCondition: AlarmCondition {
2222
func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool {
2323
// 0. sanity guards
2424
guard let warnAheadHrs = alarm.threshold, warnAheadHrs > 0 else { return false }
25-
guard let insertTS = data.pumpInsertTime else { return false }
25+
guard let insertTS = data.pumpInsertTime, insertTS > 0 else { return false }
2626

2727
// convert UNIX timestamp → Date
2828
let insertedAt = Date(timeIntervalSince1970: insertTS)

LoopFollow/Alarm/AlarmCondition/SensorAgeCondition.swift

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,23 @@
33

44
import Foundation
55

6-
/// Fires once when we are **≤ threshold hours** away from the
7-
/// Dexcom 10-day hard-stop. No repeats once triggered.
6+
/// Fires when we are **≤ threshold hours** away from the
7+
/// sensor's configured lifetime.
88
struct SensorAgeCondition: AlarmCondition {
99
static let type: AlarmType = .sensorChange
1010
init() {}
1111

12-
/// Dexcom hard-stop = 10 days = 240 h
13-
private let lifetime: TimeInterval = 10 * 24 * 60 * 60
14-
1512
func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool {
1613
// 0. basic guards
1714
guard let warnAheadHrs = alarm.threshold, warnAheadHrs > 0 else { return false }
18-
guard let insertTS = data.sageInsertTime else { return false }
15+
guard let insertTS = data.sageInsertTime, insertTS > 0 else { return false }
1916

2017
// convert UNIX timestamp to Date
2118
let insertedAt = Date(timeIntervalSince1970: insertTS)
2219

23-
// 1. compute trigger moment
20+
// 1. compute trigger moment using configurable lifetime (default 10 days)
21+
let lifetimeDays = alarm.sensorLifetimeDays ?? 10
22+
let lifetime: TimeInterval = Double(lifetimeDays) * 24 * 60 * 60
2423
let expiry = insertedAt.addingTimeInterval(lifetime)
2524
let trigger = expiry.addingTimeInterval(-warnAheadHrs * 3600)
2625

LoopFollow/Alarm/AlarmEditing/Editors/SensorAgeAlarmEditor.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,40 @@ import SwiftUI
66
struct SensorAgeAlarmEditor: View {
77
@Binding var alarm: Alarm
88

9+
private var lifetimeDays: Int {
10+
alarm.sensorLifetimeDays ?? 10
11+
}
12+
13+
private var lifetimeBinding: Binding<Int?> {
14+
Binding(
15+
get: { alarm.sensorLifetimeDays ?? 10 },
16+
set: { alarm.sensorLifetimeDays = $0 }
17+
)
18+
}
19+
920
var body: some View {
1021
Group {
1122
InfoBanner(
12-
text: "Warn me this many hours before the sensor’s 10-day change-over.",
23+
text: "Warn me before the sensor’s \(lifetimeDays)-day change-over.",
1324
alarmType: alarm.type
1425
)
1526

1627
AlarmGeneralSection(alarm: $alarm)
1728

29+
AlarmStepperSection(
30+
header: "Sensor Lifetime",
31+
footer: "Number of days your CGM sensor lasts " +
32+
"(e.g. 10 for Dexcom G6, 15 for G7 15-day).",
33+
title: "Lifetime",
34+
range: 7 ... 15,
35+
step: 1,
36+
unitLabel: "days",
37+
value: lifetimeBinding
38+
)
39+
1840
AlarmStepperSection(
1941
header: "Early Reminder",
20-
footer: "Number of hours before the 10-day mark that the alert " +
42+
footer: "Number of hours before the \(lifetimeDays)-day mark that the alert " +
2143
"will fire.",
2244
title: "Reminder Time",
2345
range: 1 ... 24,

Tests/AlarmConditions/Helpers.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ extension Alarm {
1515
return alarm
1616
}
1717

18+
static func sensorChange(threshold: Double?, lifetimeDays: Int? = 10) -> Self {
19+
var alarm = Alarm(type: .sensorChange)
20+
alarm.threshold = threshold
21+
alarm.sensorLifetimeDays = lifetimeDays
22+
return alarm
23+
}
24+
1825
static func futureCarbs(threshold: Double = 45, delta: Double = 5) -> Self {
1926
var alarm = Alarm(type: .futureCarbs)
2027
alarm.threshold = threshold
@@ -50,6 +57,30 @@ extension AlarmData {
5057
)
5158
}
5259

60+
static func withSensorInsertTime(_ insertTime: TimeInterval?) -> Self {
61+
AlarmData(
62+
bgReadings: [],
63+
predictionData: [],
64+
expireDate: nil,
65+
lastLoopTime: nil,
66+
latestOverrideStart: nil,
67+
latestOverrideEnd: nil,
68+
latestTempTargetStart: nil,
69+
latestTempTargetEnd: nil,
70+
recBolus: nil,
71+
COB: nil,
72+
sageInsertTime: insertTime,
73+
pumpInsertTime: nil,
74+
latestPumpVolume: nil,
75+
IOB: nil,
76+
recentBoluses: [],
77+
latestBattery: nil,
78+
latestPumpBattery: nil,
79+
batteryHistory: [],
80+
recentCarbs: []
81+
)
82+
}
83+
5384
static func withCarbs(_ carbs: [CarbSample]) -> Self {
5485
AlarmData(
5586
bgReadings: [],
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// LoopFollow
2+
// SensorAgeConditionTests.swift
3+
4+
@testable import LoopFollow
5+
import Testing
6+
7+
struct SensorAgeConditionTests {
8+
let cond = SensorAgeCondition()
9+
10+
// MARK: - 10-day lifetime (default)
11+
12+
@Test("fires when within threshold hours of 10-day expiry")
13+
func firesNear10DayExpiry() {
14+
let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 10)
15+
// Sensor inserted 9 days and 13 hours ago → 11 hours until 10-day mark → within 12-hour window
16+
let insertTime = Date().addingTimeInterval(-9 * 86400 - 13 * 3600).timeIntervalSince1970
17+
let data = AlarmData.withSensorInsertTime(insertTime)
18+
#expect(cond.evaluate(alarm: alarm, data: data, now: .init()))
19+
}
20+
21+
@Test("does NOT fire when far from 10-day expiry")
22+
func doesNotFireEarly() {
23+
let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 10)
24+
// Sensor inserted 8 days ago → 2 days until 10-day mark → outside 12-hour window
25+
let insertTime = Date().addingTimeInterval(-8 * 86400).timeIntervalSince1970
26+
let data = AlarmData.withSensorInsertTime(insertTime)
27+
#expect(!cond.evaluate(alarm: alarm, data: data, now: .init()))
28+
}
29+
30+
// MARK: - 15-day lifetime
31+
32+
@Test("fires when within threshold hours of 15-day expiry")
33+
func firesNear15DayExpiry() {
34+
let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 15)
35+
// Sensor inserted 14 days and 13 hours ago → 11 hours until 15-day mark
36+
let insertTime = Date().addingTimeInterval(-14 * 86400 - 13 * 3600).timeIntervalSince1970
37+
let data = AlarmData.withSensorInsertTime(insertTime)
38+
#expect(cond.evaluate(alarm: alarm, data: data, now: .init()))
39+
}
40+
41+
@Test("does NOT fire at day 10 when lifetime is 15 days")
42+
func doesNotFireAtDay10With15DayLifetime() {
43+
let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 15)
44+
// Sensor inserted 10 days ago → 5 days until 15-day mark → should NOT fire
45+
let insertTime = Date().addingTimeInterval(-10 * 86400).timeIntervalSince1970
46+
let data = AlarmData.withSensorInsertTime(insertTime)
47+
#expect(!cond.evaluate(alarm: alarm, data: data, now: .init()))
48+
}
49+
50+
// MARK: - Edge cases
51+
52+
@Test("does NOT fire when sageInsertTime is nil")
53+
func ignoresMissingSensor() {
54+
let alarm = Alarm.sensorChange(threshold: 12)
55+
let data = AlarmData.withSensorInsertTime(nil)
56+
#expect(!cond.evaluate(alarm: alarm, data: data, now: .init()))
57+
}
58+
59+
@Test("does NOT fire when sageInsertTime is zero (no sensor data)")
60+
func ignoresZeroInsertTime() {
61+
let alarm = Alarm.sensorChange(threshold: 12)
62+
let data = AlarmData.withSensorInsertTime(0)
63+
#expect(!cond.evaluate(alarm: alarm, data: data, now: .init()))
64+
}
65+
66+
@Test("does NOT fire when threshold is nil")
67+
func ignoresNilThreshold() {
68+
let alarm = Alarm.sensorChange(threshold: nil)
69+
let insertTime = Date().addingTimeInterval(-11 * 86400).timeIntervalSince1970
70+
let data = AlarmData.withSensorInsertTime(insertTime)
71+
#expect(!cond.evaluate(alarm: alarm, data: data, now: .init()))
72+
}
73+
74+
@Test("defaults to 10-day lifetime when sensorLifetimeDays is nil")
75+
func defaultsTo10Days() {
76+
let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: nil)
77+
// Sensor inserted 9 days and 13 hours ago → within 12-hour window of 10-day mark
78+
let insertTime = Date().addingTimeInterval(-9 * 86400 - 13 * 3600).timeIntervalSince1970
79+
let data = AlarmData.withSensorInsertTime(insertTime)
80+
#expect(cond.evaluate(alarm: alarm, data: data, now: .init()))
81+
}
82+
}

0 commit comments

Comments
 (0)