Jonas Björkert 1 rok temu
rodzic
commit
667217c449

+ 4 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -117,6 +117,7 @@
 		DD7F4C1D2DD650D500D449E9 /* COBAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F4C1C2DD650D500D449E9 /* COBAlarmEditor.swift */; };
 		DD7F4C1F2DD6648B00D449E9 /* FastRiseCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F4C1E2DD6648B00D449E9 /* FastRiseCondition.swift */; };
 		DD7F4C212DD66BB200D449E9 /* FastRiseAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F4C202DD66BB100D449E9 /* FastRiseAlarmEditor.swift */; };
+		DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7F4C222DD7A62200D449E9 /* AlarmType+SortDirection.swift */; };
 		DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */; };
 		DD85E9952D739CFE001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD85E9942D739CED001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift */; };
 		DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; };
@@ -458,6 +459,7 @@
 		DD7F4C1C2DD650D500D449E9 /* COBAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = COBAlarmEditor.swift; sourceTree = "<group>"; };
 		DD7F4C1E2DD6648B00D449E9 /* FastRiseCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastRiseCondition.swift; sourceTree = "<group>"; };
 		DD7F4C202DD66BB100D449E9 /* FastRiseAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastRiseAlarmEditor.swift; sourceTree = "<group>"; };
+		DD7F4C222DD7A62200D449E9 /* AlarmType+SortDirection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AlarmType+SortDirection.swift"; sourceTree = "<group>"; };
 		DD7FFAFC2A72953000C3A304 /* EKEventStore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EKEventStore+Extensions.swift"; sourceTree = "<group>"; };
 		DD85E9942D739CED001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnipodDashHeartbeatBluetoothTransmitter.swift; sourceTree = "<group>"; };
 		DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseConversion.swift; sourceTree = "<group>"; };
@@ -1036,6 +1038,7 @@
 				DDCF9A8B2D86005E004DF4DD /* AlarmManager.swift */,
 				DDCF9A872D85FD33004DF4DD /* AlarmData.swift */,
 				DDCF9A812D85FD14004DF4DD /* AlarmType.swift */,
+				DD7F4C222DD7A62200D449E9 /* AlarmType+SortDirection.swift */,
 				DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */,
 			);
 			path = Alarm;
@@ -1788,6 +1791,7 @@
 				DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */,
 				DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */,
 				FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
+				DD7F4C232DD7A62200D449E9 /* AlarmType+SortDirection.swift in Sources */,
 				DD0650F72DCFDA26004D3B41 /* InfoBanner.swift in Sources */,
 				DD4AFB612DB68BBC00BB593F /* AlarmListView.swift in Sources */,
 				DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */,

+ 5 - 2
LoopFollow/Alarm/Alarm.swift

@@ -64,8 +64,11 @@ struct Alarm: Identifiable, Codable, Equatable {
     /// If the alarm is manually snoozed, we store the end time for the snooze here
     var snoozedUntil: Date?
 
-    /// Alarm threashold, it can be a bgvalue (in mg/Dl), or day for example
-    /// Also used as bg limit for drop alarms for example
+    /// BG alarm threasholds
+    var aboveBG: Double?
+    var belowBG: Double?
+
+    /// Alarm threashold, it can be a day for example
     var threshold: Double?
 
     /// If the alarm looks at predictions, this is how long into the future to look

+ 40 - 0
LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift

@@ -16,6 +16,44 @@ protocol AlarmCondition {
 }
 
 extension AlarmCondition {
+    /// 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 {
+        let bgReading = data.bgReadings.last?.sgv
+        let haveBG = (bgReading ?? 0) > 0
+        let bgValue = Double(bgReading ?? 0)
+
+        // ────────────────────────────────────
+        // 1. BG-based alarms always need data
+        // ────────────────────────────────────
+        if alarm.type.isBGBased && !haveBG { return false }
+
+        // ────────────────────────────────────
+        // 2. No limits?  we’re done.
+        // ────────────────────────────────────
+        if alarm.belowBG == nil && alarm.aboveBG == nil { return true }
+
+        // If we reach here, there *are* limits.
+        // Non-BG alarms without a reading must fail;
+        // BG-based alarms already bailed out above.
+        guard haveBG else { return false }
+
+        switch (alarm.belowBG, alarm.aboveBG) {
+        case let (lo?, hi?):
+            return lo < hi ? (bgValue <= lo || bgValue >= hi) // fire outside band
+                : (hi <= bgValue && bgValue <= lo) // fire inside band
+
+        case let (lo?, nil):
+            return bgValue <= lo
+
+        case let (nil, hi?):
+            return bgValue >= hi
+
+        default:
+            return true
+        }
+    }
+
     /// applies every global & per-alarm guard exactly once
     func shouldFire(alarm: Alarm, data: AlarmData, now: Date, config: AlarmConfiguration) -> Bool {
         // master on/off
@@ -23,6 +61,8 @@ extension AlarmCondition {
         // per-alarm snooze
         if let snooze = alarm.snoozedUntil, snooze > now { return false }
 
+        if !passesBGLimits(alarm: alarm, data: data) { return false }
+
         // time-of-day guard
         let comps = Calendar.current.dateComponents([.hour, .minute], from: now)
         let nowMin = (comps.hour! * 60) + comps.minute!

+ 1 - 7
LoopFollow/Alarm/AlarmCondition/FastDropCondition.swift

@@ -22,14 +22,9 @@ struct FastDropCondition: AlarmCondition {
             data.bgReadings.count >= dropsNeeded + 1
         else { return false }
 
-        // optional BG-limit guard
-        if let limit = alarm.threshold {
-            guard let latest = data.bgReadings.last, latest.sgv > 0 else { return false }
-            guard Double(latest.sgv) < limit else { return false }
-        }
-
         // ────────────────────────────────
         // 1. compute recent deltas
+        //    (BG-limit check is now handled by passesBGLimits)
         // ────────────────────────────────
         let recent = data.bgReadings.suffix(dropsNeeded + 1)
         let readings = Array(recent)
@@ -38,7 +33,6 @@ struct FastDropCondition: AlarmCondition {
             let delta = Double(readings[i - 1].sgv - readings[i].sgv)
             if delta < dropPerReading { return false }
         }
-
         return true
     }
 }

+ 9 - 14
LoopFollow/Alarm/AlarmCondition/FastRiseCondition.swift

@@ -7,29 +7,24 @@
 
 import Foundation
 
+/// Fires when N consecutive BG deltas are ≥ `delta` mg/dL.
 struct FastRiseCondition: AlarmCondition {
     static let type: AlarmType = .fastRise
     init() {}
 
     func evaluate(alarm: Alarm, data: AlarmData) -> Bool {
         guard
-            let risePerReading = alarm.delta, risePerReading > 0,
-            let risesNeeded = alarm.monitoringWindow, risesNeeded > 0,
-            data.bgReadings.count >= risesNeeded + 1
+            let rise = alarm.delta, rise > 0,
+            let streak = alarm.monitoringWindow, streak > 0,
+            data.bgReadings.count >= streak + 1
         else { return false }
 
-        if let limit = alarm.threshold {
-            guard let latest = data.bgReadings.last, latest.sgv > 0 else { return false }
-            guard Double(latest.sgv) > limit else { return false }
-        }
-
-        let recent = data.bgReadings.suffix(risesNeeded + 1)
-        let readings = Array(recent)
+        // grab the last (streak + 1) readings, newest last
+        let recent = data.bgReadings.suffix(streak + 1).map(\.sgv)
 
-        for i in 1 ... risesNeeded {
-            let delta = Double(readings[i].sgv - readings[i - 1].sgv)
-            if delta < risePerReading { return false }
+        // every forward delta must hit the threshold
+        return zip(recent.dropFirst(), recent).allSatisfy {
+            Double($0 - $1) >= rise
         }
-        return true
     }
 }

+ 7 - 15
LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift

@@ -8,27 +8,23 @@
 
 import Foundation
 
-/// Fires when **every** BG in `persistentMinutes` (if set) **and** the latest BG
-/// are ≥ `threshold`.
-/// — No predictive branch for highs.
+/// Fires when the latest BG – and, if requested, every BG in a persistent-window – is **≥ aboveBG**.
 struct HighBGCondition: AlarmCondition {
     static let type: AlarmType = .high
     init() {}
 
     func evaluate(alarm: Alarm, data: AlarmData) -> Bool {
         // ────────────────────────────────
-        // 0. sanity checks
+        // 0. get the limit
         // ────────────────────────────────
-        guard let threshold = alarm.threshold else { return false }
-        guard let latest = data.bgReadings.last, latest.sgv > 0 else { return false }
+        guard let high = alarm.aboveBG else { return false }
 
         func isHigh(_ g: GlucoseValue) -> Bool {
-            g.sgv > 0 && Double(g.sgv) >= threshold
+            g.sgv > 0 && Double(g.sgv) >= high
         }
 
-        // ────────────────────────────────
-        // 1. persistent-window guard
-        // ────────────────────────────────
+        // we already know from `passesBGLimits` that the **latest** reading is ≥ high,
+        // but we still need to honour the “persistent for N minutes” option.
         var persistentOK = true
         if let persistentMinutes = alarm.persistentMinutes,
            persistentMinutes > 0
@@ -44,10 +40,6 @@ struct HighBGCondition: AlarmCondition {
             }
         }
 
-        // ────────────────────────────────
-        // 2. final decision
-        // ────────────────────────────────
-        let currentHigh = isHigh(latest)
-        return currentHigh && persistentOK
+        return persistentOK
     }
 }

+ 3 - 5
LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift

@@ -19,11 +19,10 @@ struct LowBGCondition: AlarmCondition {
         // ────────────────────────────────
         // 0. sanity checks
         // ────────────────────────────────
-        guard let threshold = alarm.threshold else { return false }
-        guard let latest = data.bgReadings.last, latest.sgv > 0 else { return false }
+        guard let belowBG = alarm.belowBG else { return false }
 
         func isLow(_ g: GlucoseValue) -> Bool {
-            g.sgv > 0 && Double(g.sgv) <= threshold
+            g.sgv > 0 && Double(g.sgv) <= belowBG
         }
 
         // ────────────────────────────────
@@ -66,7 +65,6 @@ struct LowBGCondition: AlarmCondition {
         // ────────────────────────────────
         // 3. final decision
         // ────────────────────────────────
-        let currentLow = isLow(latest)
-        return (currentLow && persistentOK) || predictiveTrigger
+        return persistentOK || predictiveTrigger
     }
 }

+ 1 - 1
LoopFollow/Alarm/AlarmEditing/Editors/FastDropAlarmEditor.swift

@@ -50,7 +50,7 @@ struct FastDropAlarmEditor: View {
                 toggleText: "Use BG Limit",
                 pickerTitle: "Dropping below",
                 range: 40 ... 300,
-                value: $alarm.threshold
+                value: $alarm.belowBG
             )
 
             AlarmActiveSection(alarm: $alarm)

+ 1 - 1
LoopFollow/Alarm/AlarmEditing/Editors/FastRiseAlarmEditor.swift

@@ -53,7 +53,7 @@ struct FastRiseAlarmEditor: View {
                 toggleText: "Use BG Limit",
                 pickerTitle: "Rising above",
                 range: 40 ... 300,
-                value: $alarm.threshold
+                value: $alarm.aboveBG
             )
 
             AlarmActiveSection(alarm: $alarm)

+ 2 - 2
LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift

@@ -26,8 +26,8 @@ struct HighBgAlarmEditor: View {
                 title: "BG",
                 range: 120 ... 350,
                 value: Binding(
-                    get: { alarm.threshold ?? 180 },
-                    set: { alarm.threshold = $0 }
+                    get: { alarm.aboveBG ?? 180 },
+                    set: { alarm.aboveBG = $0 }
                 )
             )
 

+ 2 - 2
LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift

@@ -24,8 +24,8 @@ struct LowBgAlarmEditor: View {
                 title: "BG",
                 range: 40 ... 150,
                 value: Binding(
-                    get: { alarm.threshold ?? 80 },
-                    set: { alarm.threshold = $0 }
+                    get: { alarm.belowBG ?? 80 },
+                    set: { alarm.belowBG = $0 }
                 )
             )
 

+ 14 - 21
LoopFollow/Alarm/AlarmManager.swift

@@ -43,34 +43,27 @@ class AlarmManager {
         let alarms = Storage.shared.alarms.value
 
         let sorted = alarms.sorted { lhs, rhs in
-            // 1) by type priority
+            // 1) type-level priority (hard-coded table in AlarmType)
             if lhs.type.priority != rhs.type.priority {
                 return lhs.type.priority < rhs.type.priority
             }
 
-            // 2) by “main” value for that type
-            if let asc = lhs.type.thresholdSortAscending {
-                // pick the right field:
-                let leftVal: Double
-                let rightVal: Double
-
-                switch lhs.type {
-                // TODO: Make a alarm type setting of this, sortedBy or something like that
-                case .fastDrop, .fastRise:
-                    // sort on the per-reading delta
-                    leftVal = lhs.delta ?? (asc ? Double.infinity : -Double.infinity)
-                    rightVal = rhs.delta ?? (asc ? Double.infinity : -Double.infinity)
-
-                default:
-                    // sort on the BG limit threshold
-                    leftVal = lhs.threshold ?? (asc ? Double.infinity : -Double.infinity)
-                    rightVal = rhs.threshold ?? (asc ? Double.infinity : -Double.infinity)
+            // 2) per-type “main value” ordering
+            if lhs.type == rhs.type, // only makes sense within the same type
+               let spec = lhs.type.sortSpec
+            { // (direction, key extractor)
+                let lv = spec.key(lhs)
+                let rv = spec.key(rhs)
+
+                switch spec.direction {
+                case .ascending: // smaller ⇒ more urgent
+                    return (lv ?? Double.infinity) < (rv ?? Double.infinity)
+                case .descending: // bigger  ⇒ more urgent
+                    return (lv ?? -Double.infinity) > (rv ?? -Double.infinity)
                 }
-
-                return asc ? (leftVal < rightVal) : (leftVal > rightVal)
             }
 
-            // 3) fallback
+            // 3) fallback – keep original insertion order
             return false
         }
         var skipType: AlarmType?

+ 49 - 0
LoopFollow/Alarm/AlarmType+SortDirection.swift

@@ -0,0 +1,49 @@
+//
+//  AlarmType+SortDirection.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-05-16.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+
+// MARK: – Sorting helpers
+
+extension AlarmType {
+    /// Asc ⇢ “smaller number = more urgent”,  Desc ⇢ “bigger number = more urgent”
+    enum SortDirection { case ascending, descending }
+
+    /// Convenience tuple type
+    typealias SortSpec = (direction: SortDirection, key: (Alarm) -> Double?)
+
+    /// The single place that says “sort _this_ type on _that_ field, in _this_ direction”.
+    var sortSpec: SortSpec? {
+        switch self {
+        case .low:
+            return (direction: .ascending,
+                    key: { $0.belowBG ?? Double.nan })
+
+        case .high:
+            return (direction: .descending,
+                    key: { $0.aboveBG ?? -Double.infinity })
+
+        case .fastDrop, .fastRise:
+            return (direction: .descending,
+                    key: { guard let d = $0.delta else { return nil }
+                        return abs(d)
+                    })
+
+        case .missedReading, .notLooping, .missedBolus, .buildExpire:
+            return (direction: .ascending,
+                    key: { $0.threshold })
+
+        case .iob, .cob:
+            return (direction: .descending,
+                    key: { $0.threshold })
+
+        default:
+            return nil
+        }
+    }
+}

+ 0 - 14
LoopFollow/Alarm/AlarmType.swift

@@ -41,20 +41,6 @@ extension AlarmType {
 }
 
 extension AlarmType {
-    /// Should alarms of this type sort their thresholds ascending (true) or descending (false)
-    var thresholdSortAscending: Bool? {
-        switch self {
-        case .low, .fastDrop, .fastRise, .missedReading, .notLooping, .missedBolus, .buildExpire:
-            return true
-        case .high, .iob, .cob:
-            return false
-        default:
-            return nil
-        }
-    }
-}
-
-extension AlarmType {
     /// What “unit” we use for snoozeDuration for this alarmType.
     var timeUnit: TimeUnit {
         switch self {