Parcourir la source

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
Jonas Björkert il y a 3 mois
Parent
commit
01f689bf5a

+ 10 - 0
LoopFollow/Alarm/Alarm.swift

@@ -107,6 +107,7 @@ struct Alarm: Identifiable, Codable, Equatable {
         case missedBolusPrebolusWindow, missedBolusIgnoreSmallBolusUnits
         case missedBolusIgnoreUnderGrams, missedBolusIgnoreUnderBG
         case bolusCountThreshold, bolusWindowMinutes
+        case sensorLifetimeDays
     }
 
     init(from decoder: Decoder) throws {
@@ -137,6 +138,7 @@ struct Alarm: Identifiable, Codable, Equatable {
         missedBolusIgnoreUnderBG = try container.decodeIfPresent(Double.self, forKey: .missedBolusIgnoreUnderBG)
         bolusCountThreshold = try container.decodeIfPresent(Int.self, forKey: .bolusCountThreshold)
         bolusWindowMinutes = try container.decodeIfPresent(Int.self, forKey: .bolusWindowMinutes)
+        sensorLifetimeDays = try container.decodeIfPresent(Int.self, forKey: .sensorLifetimeDays)
     }
 
     func encode(to encoder: Encoder) throws {
@@ -165,6 +167,7 @@ struct Alarm: Identifiable, Codable, Equatable {
         try container.encodeIfPresent(missedBolusIgnoreUnderBG, forKey: .missedBolusIgnoreUnderBG)
         try container.encodeIfPresent(bolusCountThreshold, forKey: .bolusCountThreshold)
         try container.encodeIfPresent(bolusWindowMinutes, forKey: .bolusWindowMinutes)
+        try container.encodeIfPresent(sensorLifetimeDays, forKey: .sensorLifetimeDays)
     }
 
     // ─────────────────────────────────────────────────────────────
@@ -191,6 +194,12 @@ struct Alarm: Identifiable, Codable, Equatable {
     /// ...within this many minutes
     var bolusWindowMinutes: Int?
 
+    // ─────────────────────────────────────────────────────────────
+    // Sensor‑Change fields ─
+    // ─────────────────────────────────────────────────────────────
+    /// CGM sensor lifetime in days (e.g. 10 for Dexcom G6, 15 for G7 15-day)
+    var sensorLifetimeDays: Int?
+
     /// Function for when the alarm is triggered.
     /// 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.
     func trigger(config: AlarmConfiguration, now: Date) {
@@ -318,6 +327,7 @@ struct Alarm: Identifiable, Codable, Equatable {
         case .sensorChange:
             soundFile = .wakeUpWillYou
             threshold = 12
+            sensorLifetimeDays = 10
         case .pumpChange:
             soundFile = .wakeUpWillYou
             threshold = 12

+ 1 - 1
LoopFollow/Alarm/AlarmCondition/PumpChangeCondition.swift

@@ -22,7 +22,7 @@ struct PumpChangeCondition: AlarmCondition {
     func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool {
         // 0. sanity guards
         guard let warnAheadHrs = alarm.threshold, warnAheadHrs > 0 else { return false }
-        guard let insertTS = data.pumpInsertTime else { return false }
+        guard let insertTS = data.pumpInsertTime, insertTS > 0 else { return false }
 
         // convert UNIX timestamp → Date
         let insertedAt = Date(timeIntervalSince1970: insertTS)

+ 6 - 7
LoopFollow/Alarm/AlarmCondition/SensorAgeCondition.swift

@@ -3,24 +3,23 @@
 
 import Foundation
 
-/// Fires once when we are **≤ threshold hours** away from the
-/// Dexcom 10-day hard-stop.  No repeats once triggered.
+/// Fires when we are **≤ threshold hours** away from the
+/// sensor's configured lifetime.
 struct SensorAgeCondition: AlarmCondition {
     static let type: AlarmType = .sensorChange
     init() {}
 
-    /// Dexcom hard-stop = 10 days = 240 h
-    private let lifetime: TimeInterval = 10 * 24 * 60 * 60
-
     func evaluate(alarm: Alarm, data: AlarmData, now _: Date) -> Bool {
         // 0. basic guards
         guard let warnAheadHrs = alarm.threshold, warnAheadHrs > 0 else { return false }
-        guard let insertTS = data.sageInsertTime else { return false }
+        guard let insertTS = data.sageInsertTime, insertTS > 0 else { return false }
 
         // convert UNIX timestamp to Date
         let insertedAt = Date(timeIntervalSince1970: insertTS)
 
-        // 1. compute trigger moment
+        // 1. compute trigger moment using configurable lifetime (default 10 days)
+        let lifetimeDays = alarm.sensorLifetimeDays ?? 10
+        let lifetime: TimeInterval = Double(lifetimeDays) * 24 * 60 * 60
         let expiry = insertedAt.addingTimeInterval(lifetime)
         let trigger = expiry.addingTimeInterval(-warnAheadHrs * 3600)
 

+ 24 - 2
LoopFollow/Alarm/AlarmEditing/Editors/SensorAgeAlarmEditor.swift

@@ -6,18 +6,40 @@ import SwiftUI
 struct SensorAgeAlarmEditor: View {
     @Binding var alarm: Alarm
 
+    private var lifetimeDays: Int {
+        alarm.sensorLifetimeDays ?? 10
+    }
+
+    private var lifetimeBinding: Binding<Int?> {
+        Binding(
+            get: { alarm.sensorLifetimeDays ?? 10 },
+            set: { alarm.sensorLifetimeDays = $0 }
+        )
+    }
+
     var body: some View {
         Group {
             InfoBanner(
-                text: "Warn me this many hours before the sensor’s 10-day change-over.",
+                text: "Warn me before the sensor’s \(lifetimeDays)-day change-over.",
                 alarmType: alarm.type
             )
 
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
+                header: "Sensor Lifetime",
+                footer: "Number of days your CGM sensor lasts " +
+                    "(e.g. 10 for Dexcom G6, 15 for G7 15-day).",
+                title: "Lifetime",
+                range: 7 ... 15,
+                step: 1,
+                unitLabel: "days",
+                value: lifetimeBinding
+            )
+
+            AlarmStepperSection(
                 header: "Early Reminder",
-                footer: "Number of hours before the 10-day mark that the alert " +
+                footer: "Number of hours before the \(lifetimeDays)-day mark that the alert " +
                     "will fire.",
                 title: "Reminder Time",
                 range: 1 ... 24,

+ 31 - 0
Tests/AlarmConditions/Helpers.swift

@@ -15,6 +15,13 @@ extension Alarm {
         return alarm
     }
 
+    static func sensorChange(threshold: Double?, lifetimeDays: Int? = 10) -> Self {
+        var alarm = Alarm(type: .sensorChange)
+        alarm.threshold = threshold
+        alarm.sensorLifetimeDays = lifetimeDays
+        return alarm
+    }
+
     static func futureCarbs(threshold: Double = 45, delta: Double = 5) -> Self {
         var alarm = Alarm(type: .futureCarbs)
         alarm.threshold = threshold
@@ -50,6 +57,30 @@ extension AlarmData {
         )
     }
 
+    static func withSensorInsertTime(_ insertTime: TimeInterval?) -> Self {
+        AlarmData(
+            bgReadings: [],
+            predictionData: [],
+            expireDate: nil,
+            lastLoopTime: nil,
+            latestOverrideStart: nil,
+            latestOverrideEnd: nil,
+            latestTempTargetStart: nil,
+            latestTempTargetEnd: nil,
+            recBolus: nil,
+            COB: nil,
+            sageInsertTime: insertTime,
+            pumpInsertTime: nil,
+            latestPumpVolume: nil,
+            IOB: nil,
+            recentBoluses: [],
+            latestBattery: nil,
+            latestPumpBattery: nil,
+            batteryHistory: [],
+            recentCarbs: []
+        )
+    }
+
     static func withCarbs(_ carbs: [CarbSample]) -> Self {
         AlarmData(
             bgReadings: [],

+ 82 - 0
Tests/AlarmConditions/SensorAgeConditionTests.swift

@@ -0,0 +1,82 @@
+// LoopFollow
+// SensorAgeConditionTests.swift
+
+@testable import LoopFollow
+import Testing
+
+struct SensorAgeConditionTests {
+    let cond = SensorAgeCondition()
+
+    // MARK: - 10-day lifetime (default)
+
+    @Test("fires when within threshold hours of 10-day expiry")
+    func firesNear10DayExpiry() {
+        let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 10)
+        // Sensor inserted 9 days and 13 hours ago → 11 hours until 10-day mark → within 12-hour window
+        let insertTime = Date().addingTimeInterval(-9 * 86400 - 13 * 3600).timeIntervalSince1970
+        let data = AlarmData.withSensorInsertTime(insertTime)
+        #expect(cond.evaluate(alarm: alarm, data: data, now: .init()))
+    }
+
+    @Test("does NOT fire when far from 10-day expiry")
+    func doesNotFireEarly() {
+        let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 10)
+        // Sensor inserted 8 days ago → 2 days until 10-day mark → outside 12-hour window
+        let insertTime = Date().addingTimeInterval(-8 * 86400).timeIntervalSince1970
+        let data = AlarmData.withSensorInsertTime(insertTime)
+        #expect(!cond.evaluate(alarm: alarm, data: data, now: .init()))
+    }
+
+    // MARK: - 15-day lifetime
+
+    @Test("fires when within threshold hours of 15-day expiry")
+    func firesNear15DayExpiry() {
+        let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 15)
+        // Sensor inserted 14 days and 13 hours ago → 11 hours until 15-day mark
+        let insertTime = Date().addingTimeInterval(-14 * 86400 - 13 * 3600).timeIntervalSince1970
+        let data = AlarmData.withSensorInsertTime(insertTime)
+        #expect(cond.evaluate(alarm: alarm, data: data, now: .init()))
+    }
+
+    @Test("does NOT fire at day 10 when lifetime is 15 days")
+    func doesNotFireAtDay10With15DayLifetime() {
+        let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: 15)
+        // Sensor inserted 10 days ago → 5 days until 15-day mark → should NOT fire
+        let insertTime = Date().addingTimeInterval(-10 * 86400).timeIntervalSince1970
+        let data = AlarmData.withSensorInsertTime(insertTime)
+        #expect(!cond.evaluate(alarm: alarm, data: data, now: .init()))
+    }
+
+    // MARK: - Edge cases
+
+    @Test("does NOT fire when sageInsertTime is nil")
+    func ignoresMissingSensor() {
+        let alarm = Alarm.sensorChange(threshold: 12)
+        let data = AlarmData.withSensorInsertTime(nil)
+        #expect(!cond.evaluate(alarm: alarm, data: data, now: .init()))
+    }
+
+    @Test("does NOT fire when sageInsertTime is zero (no sensor data)")
+    func ignoresZeroInsertTime() {
+        let alarm = Alarm.sensorChange(threshold: 12)
+        let data = AlarmData.withSensorInsertTime(0)
+        #expect(!cond.evaluate(alarm: alarm, data: data, now: .init()))
+    }
+
+    @Test("does NOT fire when threshold is nil")
+    func ignoresNilThreshold() {
+        let alarm = Alarm.sensorChange(threshold: nil)
+        let insertTime = Date().addingTimeInterval(-11 * 86400).timeIntervalSince1970
+        let data = AlarmData.withSensorInsertTime(insertTime)
+        #expect(!cond.evaluate(alarm: alarm, data: data, now: .init()))
+    }
+
+    @Test("defaults to 10-day lifetime when sensorLifetimeDays is nil")
+    func defaultsTo10Days() {
+        let alarm = Alarm.sensorChange(threshold: 12, lifetimeDays: nil)
+        // Sensor inserted 9 days and 13 hours ago → within 12-hour window of 10-day mark
+        let insertTime = Date().addingTimeInterval(-9 * 86400 - 13 * 3600).timeIntervalSince1970
+        let data = AlarmData.withSensorInsertTime(insertTime)
+        #expect(cond.evaluate(alarm: alarm, data: data, now: .init()))
+    }
+}