Przeglądaj źródła

Implement clean version of the cob bucketing algorithm

Sam King 11 miesięcy temu
rodzic
commit
f42ec31a49

+ 4 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/TimeExtensions.swift

@@ -14,6 +14,10 @@ extension Decimal {
     var minutesToSeconds: TimeInterval {
         Double(self * 60)
     }
+
+    var hoursToSeconds: TimeInterval {
+        Double(minutesToSeconds * 60)
+    }
 }
 
 extension TimeInterval {

+ 53 - 78
Trio/Sources/APS/OpenAPSSwift/Meal/MealCob.swift

@@ -2,9 +2,17 @@ import Foundation
 
 struct MealCob {
     /// Internal structure to keep track of bucketed glucose values
-    struct BucketedGlucose {
+    struct BucketedGlucose: Codable {
         let glucose: Decimal
         let date: Date
+        let samplesInBucket: Int
+
+        func average(adding glucose: BucketedGlucose) -> BucketedGlucose {
+            let total = Decimal(samplesInBucket) * self.glucose + glucose.glucose
+            let numSamples = samplesInBucket + 1
+            let newGlucoseAverage = total / Decimal(numSamples)
+            return BucketedGlucose(glucose: newGlucoseAverage, date: date, samplesInBucket: numSamples)
+        }
     }
 
     /// Result structure for carb absorption detection
@@ -54,6 +62,22 @@ struct MealCob {
         )
     }
 
+    private static func interpolateGlucose(lastBucket: BucketedGlucose, glucose: BucketedGlucose) -> [BucketedGlucose] {
+        let deltaGlucose = glucose.glucose - lastBucket.glucose
+        let timeBetweenSamples = Decimal(lastBucket.date.timeIntervalSince(glucose.date))
+        let slope = deltaGlucose / timeBetweenSamples
+        let stepSize = Decimal(5.minutesToSeconds)
+
+        // I'm skipping the 4 hour limit from JS
+        let interpolatedValues = stride(from: stepSize, to: timeBetweenSamples, by: stepSize).map { time in
+            let newGlucose = lastBucket.glucose + slope * time
+            let newDate = lastBucket.date - TimeInterval(time)
+            return BucketedGlucose(glucose: newGlucose, date: newDate, samplesInBucket: 1)
+        }
+
+        return interpolatedValues
+    }
+
     /// Groups glucose readings into time buckets with interpolation for missing data points
     /// Make this non-private to expose for test cases
     static func bucketGlucoseForCob(
@@ -62,93 +86,44 @@ struct MealCob {
         mealDate: Date,
         ciDate: Date?
     ) throws -> [BucketedGlucose] {
-        let glucoseData = glucose.compactMap({ (bg: BloodGlucose) -> BucketedGlucose? in
+        var glucoseData = glucose.compactMap({ (bg: BloodGlucose) -> BucketedGlucose? in
             guard let glucose = bg.glucose ?? bg.sgv else { return nil }
-            return BucketedGlucose(glucose: Decimal(glucose), date: bg.dateString)
+            return BucketedGlucose(glucose: Decimal(glucose), date: bg.dateString, samplesInBucket: 1)
         })
 
-        guard let first = glucoseData.first else { return [] }
-
-        var bucketedData = [first]
-        var foundPreMealBG = false
-        var lastBgIndex = 0
+        var bucketedData: [BucketedGlucose] = []
 
-        for i in 1 ..< glucoseData.count {
-            let currentGlucose = glucoseData[i]
-            let bgTime = currentGlucose.date
+        // make sure that all of our samples are later than the meal and
+        // before the maxMealAbsorptionTime expires. We also added a
+        // >= 39 glucose check from Javascript
+        let mealDoneDate = mealDate + profile.maxMealAbsorptionTime.hoursToSeconds
+        glucoseData = glucoseData.filter { $0.date >= mealDate && $0.date <= mealDoneDate && $0.glucose >= 39 }
 
-            // Skip invalid glucose readings
-            guard currentGlucose.glucose >= 39 else {
-                continue
-            }
+        // Only consider last ~45m of data in CI mode
+        // this allows us to calculate deviations for the last ~30m
+        if let ciDate = ciDate {
+            glucoseData = glucoseData.filter { ciDate >= $0.date && ciDate.timeIntervalSince($0.date) <= 45.minutesToSeconds }
+        }
 
-            // Only consider BGs for maxMealAbsorptionTime hours after a meal
-            let hoursAfterMeal = Decimal(bgTime.timeIntervalSince(mealDate)) / 3600
-            if hoursAfterMeal > profile.maxMealAbsorptionTime || foundPreMealBG {
+        for glucose in glucoseData {
+            guard let lastBucket = bucketedData.last else {
+                bucketedData.append(glucose)
                 continue
-            } else if hoursAfterMeal < 0 {
-                foundPreMealBG = true
-            }
-
-            // In CI mode, only consider last ~45m of data
-            if let ciDate = ciDate {
-                let hoursAgo = ciDate.timeIntervalSince(bgTime) / (45 * 60)
-                if hoursAgo > 1 || hoursAgo < 0 {
-                    continue
-                }
             }
-
-            // Determine last BG time
-            let lastBgTime: Date
-            if let lastDate = bucketedData.last?.date {
-                lastBgTime = lastDate
-            } else if lastBgIndex < glucoseData.count, lastBgIndex >= 0 {
-                lastBgTime = glucoseData[lastBgIndex].date
+            let timeBetweenSamples = lastBucket.date.timeIntervalSince(glucose.date)
+            let elapsedTime = timeBetweenSamples > 4.hoursToSeconds ? 4.hoursToSeconds : timeBetweenSamples
+            if elapsedTime > 8.minutesToSeconds {
+                // interpolate
+                let interpolatedGlucose = interpolateGlucose(lastBucket: lastBucket, glucose: glucose)
+                bucketedData.append(contentsOf: interpolatedGlucose)
+            } else if elapsedTime > 2.minutesToSeconds {
+                // add the new sample
+                bucketedData.append(BucketedGlucose(glucose: glucose.glucose, date: glucose.date, samplesInBucket: 1))
             } else {
-                throw CobError.couldNotDetermineLastBgTime
+                // average
+                bucketedData = Array(bucketedData.dropLast())
+                bucketedData.append(lastBucket.average(adding: glucose))
             }
-
-            let elapsedMinutes = bgTime.timeIntervalSince(lastBgTime) / 60
-
-            if abs(elapsedMinutes) > 8 {
-                // Interpolate missing data points
-                let lastBg = bucketedData.last?.glucose ?? glucoseData[lastBgIndex].glucose
-                // Cap interpolation at a maximum of 4h
-                let cappedElapsedMinutes = Decimal(min(240, abs(elapsedMinutes)))
-                var remainingMinutes = cappedElapsedMinutes
-                var interpolationTime = lastBgTime
-                var interpolationBg = lastBg
-
-                while remainingMinutes > 5 {
-                    let previousBgTime = interpolationTime.addingTimeInterval(-5 * 60)
-
-                    // Recalculate gapDelta using the current interpolationBg (like JS updates lastbg)
-                    let gapDelta = currentGlucose.glucose - interpolationBg
-                    let previousBg = interpolationBg + (5 / remainingMinutes * gapDelta)
-
-                    bucketedData.append(BucketedGlucose(
-                        glucose: previousBg.rounded(),
-                        date: previousBgTime
-                    ))
-
-                    remainingMinutes -= 5
-                    interpolationBg = previousBg // Update reference point for next iteration
-                    interpolationTime = previousBgTime
-                }
-            } else if abs(elapsedMinutes) > 2 {
-                bucketedData.append(currentGlucose)
-            } else {
-                // Average with previous reading
-                if let lastIndex = bucketedData.indices.last {
-                    let averageGlucose = (bucketedData[lastIndex].glucose + currentGlucose.glucose) / 2
-                    bucketedData[lastIndex] = BucketedGlucose(
-                        glucose: averageGlucose,
-                        date: bucketedData[lastIndex].date
-                    )
-                }
-            }
-
-            lastBgIndex = i
         }
 
         return bucketedData

+ 13 - 43
TrioTests/OpenAPSSwiftTests/MealCobBucketingTests.swift

@@ -2,7 +2,7 @@ import Foundation
 import Testing
 @testable import Trio
 
-@Suite("bucketGlucoseData") struct MealCobBucketingTests {
+@Suite("Meal glucose bucketing tests") struct MealCobBucketingTests {
     // Default test profile - matches JS exactly
     func createDefaultProfile() -> Profile {
         var profile = Profile()
@@ -82,10 +82,10 @@ import Testing
 
         // Check interpolated values (in reverse chronological order)
         #expect(result[0].glucose == 120) // original (newest)
-        #expect(result[1].glucose == 115) // interpolated
-        #expect(result[2].glucose == 110) // interpolated
-        #expect(result[3].glucose == 105) // interpolated
-        #expect(result[4].glucose == 100) // interpolated
+        #expect(result[1].glucose.isWithin(0.1, of: 115)) // interpolated
+        #expect(result[2].glucose.isWithin(0.1, of: 110)) // interpolated
+        #expect(result[3].glucose.isWithin(0.1, of: 105)) // interpolated
+        #expect(result[4].glucose.isWithin(0.1, of: 100)) // interpolated
 
         // Check that dates are properly set
         #expect(result[1].date == mealTime.addingTimeInterval(16 * 60))
@@ -122,10 +122,11 @@ import Testing
         // Should only process up to 2 hours of data (24 entries + 1 initial = 25)
         // but it keeps the original time as the first entry of the
         // bucket and interpolates, which is broken.
-        #expect(result.count == 72)
+        #expect(result.count == 25)
 
-        #expect(result[0].glucose == 196)
-        #expect(result[result.count - 1].glucose == 100)
+        #expect(result[0].glucose == 124)
+        #expect(result[12].glucose == 112)
+        #expect(result[24].glucose == 100)
     }
 
     @Test("should only process data within 45 minutes in CI mode") func shouldOnlyProcessDataWithin45MinutesInCIMode() async throws {
@@ -154,10 +155,10 @@ import Testing
         // but it keeps the first bucket value and interpolates
         for entry in result {
             let minutesFromCI = abs(ciTime.timeIntervalSince(entry.date)) / 60
-            #expect(minutesFromCI <= 120)
+            #expect(minutesFromCI <= 45)
         }
 
-        #expect(result.count == 21)
+        #expect(result.count == 10)
     }
 
     @Test("should stop processing when pre-meal BG is found") func shouldStopProcessingWhenPreMealBGIsFound() async throws {
@@ -185,13 +186,12 @@ import Testing
         // Should only process from meal time forward (in reverse chronological order)
         // The logic will capture one entry pre meal before
         // it starts filtering (probably a bug)
-        #expect(result.count == 5)
+        #expect(result.count == 4)
         // Values should be unchanged (in reverse chronological order)
         #expect(result[0].glucose == 115)
         #expect(result[1].glucose == 110)
         #expect(result[2].glucose == 105)
         #expect(result[3].glucose == 100)
-        #expect(result[4].glucose == 95)
     }
 
     @Test(
@@ -219,36 +219,6 @@ import Testing
         // Close readings should be averaged (in reverse chronological order)
         #expect(result.count == 2)
         #expect(result[0].glucose == 110)
-        // it averages incorrectly, this should be 102 but it's not
-        #expect(result[1].glucose == 101.5)
-    }
-
-    @Test("should cap interpolation at 240 minutes for very large gaps") func shouldCapInterpolationAt240MinutesForVeryLargeGaps(
-    ) async throws {
-        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
-        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
-
-        // Create data with a 6-hour (360 minute) gap (chronological order)
-        var glucose_data = [
-            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
-            createGlucoseEntry(glucose: 200, timeMs: mealTimeMs + 360 * 60 * 1000) // 6 hour gap
-        ]
-        glucose_data.reverse() // Convert to reverse chronological order
-
-        let result = try MealCob.bucketGlucoseForCob(
-            glucose: glucose_data,
-            profile: createDefaultProfile(),
-            mealDate: mealTime,
-            ciDate: nil
-        )
-
-        // Should interpolate up to 240 minutes only
-        // 240 / 5 = 48 interpolated points + 2 original = 50
-        // But the logic is a bit off
-        #expect(result.count == 48)
-
-        // Check that interpolation stopped at 240 minutes
-        let gapMinutes = result[0].date.timeIntervalSince(result[result.count - 1].date) / 60
-        #expect(gapMinutes == 235)
+        #expect(result[1].glucose == 102)
     }
 }