Просмотр исходного кода

Port JS oref glucose-get-last.js w/ unit test

Deniz Cengiz 11 месяцев назад
Родитель
Сommit
ae193b16e6

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -636,6 +636,7 @@
 		DD30B9CE2E062AA300DA677C /* SingleForecasting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9CD2E062AA300DA677C /* SingleForecasting.swift */; };
 		DD30B9FE2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */; };
 		DD30BA002E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */; };
+		DD30BA022E074F0F00DA677C /* GlucoseStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */; };
 		DD32CF982CC82463003686D6 /* TrioRemoteControl+Bolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */; };
 		DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */; };
 		DD32CF9C2CC82499003686D6 /* TrioRemoteControl+TempTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */; };
@@ -1543,6 +1544,7 @@
 		DD30B9CD2E062AA300DA677C /* SingleForecasting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleForecasting.swift; sourceTree = "<group>"; };
 		DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalSMBEnablementTests.swift; sourceTree = "<group>"; };
 		DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalDeltaCalculationTests.swift; sourceTree = "<group>"; };
+		DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatus.swift; sourceTree = "<group>"; };
 		DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Bolus.swift"; sourceTree = "<group>"; };
 		DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Meal.swift"; sourceTree = "<group>"; };
 		DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+TempTarget.swift"; sourceTree = "<group>"; };
@@ -2859,6 +2861,7 @@
 		3B5CD2B22D4AEA6600CE213C /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */,
 				DD30B9CB2E062A7000DA677C /* ForecastResult.swift */,
 				3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */,
 				3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */,
@@ -4588,6 +4591,7 @@
 				388E595C25AD948C0019842D /* TrioApp.swift in Sources */,
 				38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */,
 				DD1745352C55AE7E00211FAC /* TargetBehavoirRootView.swift in Sources */,
+				DD30BA022E074F0F00DA677C /* GlucoseStatus.swift in Sources */,
 				DDFF20502DB2C11900AB8A96 /* WatchStateSnapshot.swift in Sources */,
 				5887527C2BD986E1008B081D /* OpenAPSBattery.swift in Sources */,
 				38569348270B5DFB0002C50D /* GlucoseSource.swift in Sources */,

+ 8 - 0
Trio/Sources/APS/Extensions/DecimalExtensions.swift

@@ -5,3 +5,11 @@ extension Decimal {
         max(min(self, pickerSetting.max), pickerSetting.min)
     }
 }
+
+extension Collection where Element == Decimal {
+    /// Returns the arithmetic mean, or zero if empty.
+    var mean: Decimal {
+        guard !isEmpty else { return .zero }
+        return reduce(.zero, +) / Decimal(count)
+    }
+}

+ 23 - 0
Trio/Sources/APS/OpenAPSSwift/Models/GlucoseStatus.swift

@@ -0,0 +1,23 @@
+import Foundation
+
+/// Represents the computed status of the most recent CGM reading,
+/// including delta‐rates over various time windows for our
+/// swift-based Oref`DeterminationGenerator`.
+public struct GlucoseStatus {
+    /// Immediate delta (mg/dL per 5 m) over the last ~5 m
+    public let delta: Decimal
+    /// The (“smoothed”) current glucose value (mg/dL)
+    public let glucose: Decimal
+    /// Sensor noise level
+    public let noise: Int
+    /// Average delta (mg/dL per 5 m) over ~5–15 m ago
+    public let shortAvgDelta: Decimal
+    /// Average delta (mg/dL per 5 m) over ~20–40 m ago
+    public let longAvgDelta: Decimal
+    /// Timestamp of the “now” reading
+    public let date: Date
+    /// Index of the last “cal” record (if any)
+    public let lastCalIndex: Int?
+    /// The original device/type string (e.g. “sgv” or “cal”)
+    public let device: String?
+}

+ 122 - 0
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -23,6 +23,7 @@ protocol GlucoseStorage {
     func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose]
     func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample]
     func getManualGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample]
+    func getGlucoseStatus() async throws -> GlucoseStatus?
     var alarm: GlucoseAlarm? { get }
     func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async
 }
@@ -544,6 +545,127 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         }
     }
 
+    /// Fetches the most recent glucose readings from Core Data, filters and smooths them,
+    /// and computes rolling delta statistics (last, short-term, and long-term).
+    ///
+    /// Mirrors JavaScript oref `glucose-get-last.js` logic.
+    ///
+    /// - Returns: A `GlucoseStatus` containing:
+    ///   - `glucose`: the most recent glucose value (mg/dL),
+    ///   - `delta`: the 5-minute delta (mg/dL per 5m),
+    ///   - `shortAvgDelta`: the average delta over ~5–15 minutes,
+    ///   - `longAvgDelta`: the average delta over ~20–40 minutes,
+    ///   - `noise`: the CGM noise level (if any),
+    ///   - `date`: the timestamp of the “now” reading,
+    ///   - `lastCalIndex`: index of the last calibration record (always `nil` here),
+    ///   - `device`: the source device string.
+    ///
+    /// - Throws: Any `CoreDataError` or other error encountered during fetch or context work.
+    /// - Returns: `nil` if no valid glucose readings are found in the past day.
+    public func getGlucoseStatus() async throws -> GlucoseStatus? {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: context,
+            predicate: NSPredicate(
+                format: "date >= %@ AND isManual == %@",
+                Date.oneDayAgoInMinutes as NSDate,
+                false as NSNumber
+            ),
+            key: "date",
+            ascending: false
+        )
+
+        guard let stored = results as? [GlucoseStored], !stored.isEmpty else {
+            return nil
+        }
+
+        let validReadings: [BloodGlucose] = await context.perform {
+            stored.compactMap { entry in
+                BloodGlucose(
+                    _id: entry.id?.uuidString ?? UUID().uuidString,
+                    sgv: Int(entry.glucose),
+                    direction: BloodGlucose.Direction(from: entry.direction ?? ""),
+                    date: Decimal(entry.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
+                    dateString: entry.date ?? Date(),
+                    unfiltered: Decimal(entry.glucose),
+                    filtered: Decimal(entry.glucose),
+                    noise: nil,
+                    glucose: Int(entry.glucose),
+                    type: "sgv"
+                )
+            }
+        }
+
+        guard !validReadings.isEmpty else {
+            return nil
+        }
+
+        // Sort descending (newest first)
+        let sorted = validReadings.sorted { $0.date > $1.date }
+
+        let mostRecentGlucose = sorted[0]
+        var mostRecentGlucoseReading: Int = mostRecentGlucose.glucose!
+        var mostRecentGlucoseDate: Date = mostRecentGlucose.dateString
+
+        var lastDeltas: [Decimal] = []
+        var shortDeltas: [Decimal] = []
+        var longDeltas: [Decimal] = []
+
+        // Walk older entries to compute deltas
+        for entry in sorted.dropFirst() {
+            // JS oref has logic here around skipping calibration readings.
+            // We never calibration record (never happens here, since type=="sgv")
+            // so we omit this check
+
+            // only use readings >38 mg/dL (to skip code values, <39)
+            guard let glucose = entry.glucose, glucose > 38 else { continue }
+
+            let minutesAgo = mostRecentGlucoseDate.timeIntervalSince(entry.dateString) / 60
+            guard minutesAgo != 0 else { continue }
+            // compute mg/dL per 5 m as a Decimal:
+            let change = Decimal(mostRecentGlucoseReading - glucose)
+            let avgDelta = (change / Decimal(minutesAgo)) * Decimal(5)
+
+            // very-recent (<2.5 m) smooths "now"
+            if minutesAgo > -2, minutesAgo <= 2.5 {
+                mostRecentGlucoseReading = (mostRecentGlucoseReading + glucose) / 2
+                mostRecentGlucoseDate = Date(
+                    timeIntervalSince1970: (
+                        mostRecentGlucoseDate.timeIntervalSince1970 + entry.dateString
+                            .timeIntervalSince1970
+                    ) / 2
+                )
+            }
+            // short window (~5–15 m)
+            else if minutesAgo > 2.5, minutesAgo <= 17.5 {
+                shortDeltas.append(avgDelta)
+                if minutesAgo < 7.5 {
+                    lastDeltas.append(avgDelta)
+                }
+            }
+            // long window (~20–40 m)
+            else if minutesAgo > 17.5, minutesAgo < 42.5 {
+                longDeltas.append(avgDelta)
+            }
+        }
+
+        // compute means (or zero)
+        let lastDelta: Decimal = lastDeltas.mean
+        let shortAvg: Decimal = shortDeltas.mean
+        let longAvg: Decimal = longDeltas.mean
+
+        return GlucoseStatus(
+            delta: lastDelta.rounded(toPlaces: 2),
+            glucose: Decimal(mostRecentGlucoseReading),
+            noise: Int(sorted[0].noise ?? 0),
+            shortAvgDelta: shortAvg.rounded(toPlaces: 2),
+            longAvgDelta: longAvg.rounded(toPlaces: 2),
+            date: mostRecentGlucoseDate,
+            lastCalIndex: nil,
+            device: settingsManager.settings.cgm.rawValue
+        )
+    }
+
     func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
         // Use injected context if available, otherwise create new task context
         let taskContext = context != CoreDataStack.shared.newTaskContext()

+ 41 - 0
TrioTests/CoreDataTests/GlucoseStorageTests.swift

@@ -185,4 +185,45 @@ import Testing
         #expect(storedEntries?.first?.glucose == 100, "Normal glucose value should match")
         #expect(storage.alarm == nil, "Should not trigger any alarm")
     }
+
+    @Test("getGlucoseStatus returns correct deltas for 0/5/15/30m readings") func testGetGlucoseStatusFourPoints() async throws {
+        let now = Date()
+        // Prepare 4 readings: at 0, 5, 15, and 30 minutes ago
+        let specs: [(offset: TimeInterval, value: Int)] = [
+            (0, 100), // now
+            (5 * 60, 110), // 5m ago
+            (15 * 60, 120), // 15m ago
+            (30 * 60, 130) // 30m ago
+        ]
+
+        // Insert them into CoreData so that our fetch predicate picks them up
+        for (offset, value) in specs {
+            await testContext.perform {
+                let glucoseToStore = GlucoseStored(context: testContext)
+                glucoseToStore.id = UUID()
+                glucoseToStore.date = now.addingTimeInterval(-offset)
+                glucoseToStore.glucose = Int16(value)
+            }
+        }
+        try testContext.save()
+
+        // Call the method under test
+        let status = try await storage.getGlucoseStatus()
+        #expect(status != nil, "Expected non‐nil status")
+
+        // “Now” glucose is the 0m reading
+        #expect(status!.glucose == 100)
+
+        // lastDelta: only the 5m point: (100–110)/5*5 = –10
+        #expect(status!.delta == -10)
+
+        // shortAvgDelta: average of 5m and 15m windows:
+        //   5m window:   (100–110)/5*5   = –10
+        //   15m window: (100–120)/15*5 ≈ –6.6667 → –6.67
+        //   avg ≈ (–10 + –6.67)/2 = –8.333… → rounded to –8.33
+        #expect(status!.shortAvgDelta == -8.33)
+
+        // longAvgDelta: only the 30m window: (100–130)/30*5 = –5
+        #expect(status!.longAvgDelta == -5)
+    }
 }