Explorar el Código

Clamp glucose at 39 mg/dL, add add'l unit tests

Deniz Cengiz hace 2 días
padre
commit
f98b0e7641

+ 30 - 3
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -41,6 +41,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     private enum Config {
         static let filterTime: TimeInterval = 3.5 * 60
+        static let minimumGlucose: Int = 39
     }
 
     private let context: NSManagedObjectContext
@@ -75,10 +76,11 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     ///  it isn't within 3.5 minutes of an existing glucose reading, which is simple but not perfect.
     ///  But since this is a corner case that really shouldn't happen often, it's good enough.
     func backfillGlucose(_ glucose: [BloodGlucose]) async throws {
+        let clamped = clampToMinimum(glucose)
         try await context.perform {
             // remove already deleted glucose values
             let withoutDeletedGlucose = self.filterGlucoseValues(
-                glucose,
+                clamped,
                 fetchRequest: DeletedGlucoseStored.fetchRequest(),
                 timeBuffer: 1
             )
@@ -105,9 +107,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func storeGlucose(_ glucose: [BloodGlucose]) async throws {
+        let clamped = clampToMinimum(glucose)
         try await context.perform {
             // Get new glucose values that don't exist yet
-            let newGlucose = self.filterGlucoseValues(glucose, fetchRequest: GlucoseStored.fetchRequest(), timeBuffer: 1)
+            let newGlucose = self.filterGlucoseValues(clamped, fetchRequest: GlucoseStored.fetchRequest(), timeBuffer: 1)
             guard !newGlucose.isEmpty else { return }
 
             do {
@@ -121,7 +124,31 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             }
 
             // Store CGM state if needed
-            self.storeCGMState(glucose)
+            self.storeCGMState(clamped)
+        }
+    }
+
+    /// Clamps CGM-sourced glucose readings to a minimum of `Config.minimumGlucose`
+    /// (39 mg/dL — the official Libre/Dexcom algorithmic floor). Some CGM plugins
+    /// (notably LibreTransmitter) deliberately bypass the vendor floor and forward
+    /// values down to 1 mg/dL; the JS oref `glucose-get-last` filter then drops them
+    /// (`> 38`) and the loop has no fresh BG during the most dangerous range. We
+    /// clamp here so determination always has a usable value and emit a debug log
+    /// line so the raw reading survives for diagnostics.
+    private func clampToMinimum(_ glucose: [BloodGlucose]) -> [BloodGlucose] {
+        glucose.map { entry in
+            var clamped = entry
+            if let raw = entry.glucose, raw < Config.minimumGlucose {
+                debug(
+                    .deviceManager,
+                    "Clamping sub-\(Config.minimumGlucose) glucose: raw=\(raw) at \(entry.dateString) -> \(Config.minimumGlucose)"
+                )
+                clamped.glucose = Config.minimumGlucose
+            }
+            if let raw = entry.sgv, raw < Config.minimumGlucose {
+                clamped.sgv = Config.minimumGlucose
+            }
+            return clamped
         }
     }
 

+ 53 - 0
TrioTests/CoreDataTests/GlucoseStorageTests.swift

@@ -127,6 +127,59 @@ import Testing
         #expect(notUploadedEntries[0].glucose == 160, "Glucose value should match")
     }
 
+    @Test("Sub-39 glucose is clamped to 39 on storeGlucose") func testStoreGlucoseClampsBelowMinimum() async throws {
+        // Given a CGM reading below the 39 mg/dL floor (e.g. LibreTransmitter delivering 23)
+        let testGlucose = [
+            BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 23)
+        ]
+
+        // When
+        try await storage.storeGlucose(testGlucose)
+
+        // Then the stored row should be clamped to 39, not 23
+        let clampedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "glucose == 39"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored]
+
+        #expect(clampedEntries?.count == 1, "Sub-39 glucose should be clamped and stored as 39")
+
+        let rawEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "glucose == 23"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored]
+
+        #expect(rawEntries?.isEmpty == true, "Raw sub-39 value must not be persisted")
+    }
+
+    @Test("Sub-39 glucose is clamped to 39 on backfillGlucose") func testBackfillGlucoseClampsBelowMinimum() async throws {
+        // Given a backfilled CGM reading below the 39 mg/dL floor
+        let backfillDate = Date().addingTimeInterval(-30 * 60)
+        let testGlucose = [
+            BloodGlucose(direction: BloodGlucose.Direction.flat, date: 456, dateString: backfillDate, glucose: 28)
+        ]
+
+        // When
+        try await storage.backfillGlucose(testGlucose)
+
+        // Then the backfilled row should be clamped to 39
+        let clampedEntries = try await coreDataStack.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "glucose == 39"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored]
+
+        #expect(clampedEntries?.count == 1, "Sub-39 backfilled glucose should be clamped and stored as 39")
+    }
+
     @Test(
         "Test glucose alarms",
         .enabled(if: false, "Flaky test, disabled while investigating")