Przeglądaj źródła

Update CoB bucketing algorithm to match JS

This commit includes an updated CoB algorithm that implements the JS
logic faithfully. The big changes include using the JS bucketing
algorithm and mutating inputs for the CoB algorithm.

It also includes updates to the unit tests to accommodate the new code
and updates after fixing the JS unit tests.
Sam King 10 miesięcy temu
rodzic
commit
fc65d7ab91

+ 8 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/Decimal+rounding.swift

@@ -17,6 +17,14 @@ extension Decimal {
         rounded(scale: 0)
     }
 
+    /// Implement Math.round from JS on Decimals. The JS implementation will add 0.5
+    /// and do a floor operation, which is what we're doing here. This ends up mattering
+    /// for values that are negative and end with .5 exactly
+    func jsRounded(scale: Int) -> Decimal {
+        var multiplier = (0 ..< scale).reduce(Decimal(1)) { result, _ in result * 10 }
+        return (self * multiplier + 0.5).rounded(scale: 0, roundingMode: .down) / multiplier
+    }
+
     func clamp(lowerBound: Decimal, upperBound: Decimal) -> Decimal {
         if self < lowerBound {
             return lowerBound

+ 146 - 96
Trio/Sources/APS/OpenAPSSwift/Meal/MealCob.swift

@@ -5,13 +5,11 @@ struct MealCob {
     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)
+            // BUG: simple average of two values
+            let newGlucose = (self.glucose + glucose.glucose) / 2
+            return BucketedGlucose(glucose: newGlucose, date: date)
         }
     }
 
@@ -29,12 +27,17 @@ struct MealCob {
     /// Detects carb absorption by analyzing glucose deviations from expected insulin activity
     ///
     /// This is the main COB detection algorithm entry point
+    ///
+    /// IMPORTANT: This implementation faithfully reproduces JavaScript bugs where:
+    /// - clock gets mutated to the last bgTime processed
+    /// - profile.currentBasal gets mutated to the basal rate at that time
+    /// These mutations persist between calls, affecting subsequent COB calculations
     static func detectCarbAbsorption(
-        clock: Date,
+        clock: inout Date, // Made inout to match JS mutation bug
         glucose: [BloodGlucose],
         pumpHistory: [PumpHistoryEvent],
         basalProfile: [BasalProfileEntry],
-        profile: Profile,
+        profile: inout Profile, // Made inout to match JS mutation bug
         mealDate: Date,
         carbImpactDate: Date?
     ) throws -> CobResult {
@@ -57,92 +60,133 @@ struct MealCob {
             bucketedData: bucketedData,
             treatments: treatments,
             basalProfile: basalProfile,
-            profile: profile,
+            profile: &profile,
             mealDate: mealDate,
-            carbImpactDate: carbImpactDate
+            carbImpactDate: carbImpactDate,
+            clock: &clock
         )
     }
 
-    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
-        // Note: in the JS implementation it does not add the `glucose`
-        // value to the bucket, so we will retain this behavior here
-        // to ensure mostly consistent timing between samples. In other
-        // words, JS only adds interpolated values, not the actual reading
-        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
+    /// Faithful port of JS bucketing logic including all quirks
     static func bucketGlucoseForCob(
         glucose: [BloodGlucose],
         profile: Profile,
         mealDate: Date,
         carbImpactDate: Date?
     ) throws -> [BucketedGlucose] {
-        var glucoseData = glucose.compactMap({ (bg: BloodGlucose) -> BucketedGlucose? in
+        // Map glucose data like JS does
+        let 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, samplesInBucket: 1)
+            return BucketedGlucose(glucose: Decimal(glucose), date: bg.dateString)
         })
 
         var bucketedData: [BucketedGlucose] = []
+        var foundPreMealBG = false
+        var lastbgi = 0
+
+        // Initialize first bucket if we have data
+        guard !glucoseData.isEmpty else { return [] }
 
-        // 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 }
-
-        // Only consider last ~45m of data in CI mode
-        // this allows us to calculate deviations for the last ~30m
-        if let carbImpactDate = carbImpactDate {
-            glucoseData = glucoseData
-                .filter { carbImpactDate >= $0.date && carbImpactDate.timeIntervalSince($0.date) <= 45.minutesToSeconds }
+        // JS behavior: check if first glucose is valid
+        if glucoseData[0].glucose < 39 {
+            lastbgi = -1
         }
 
-        for glucose in glucoseData {
-            guard let lastBucket = bucketedData.last else {
-                bucketedData.append(glucose)
+        bucketedData.append(glucoseData[0])
+        var j = 0
+
+        for i in 1 ..< glucoseData.count {
+            let bgTime = glucoseData[i].date
+            var lastbgTime: Date
+
+            // Skip invalid glucose
+            if glucoseData[i].glucose < 39 {
+                continue
+            }
+
+            // JS: only consider BGs for maxMealAbsorptionTime after a meal
+            let hoursAfterMeal = bgTime.timeIntervalSince(mealDate) / (60 * 60)
+            if hoursAfterMeal > Double(profile.maxMealAbsorptionTime) || foundPreMealBG {
+                continue
+            } else if hoursAfterMeal < 0 {
+                foundPreMealBG = true
+            }
+
+            // Only consider last ~45m of data in CI mode
+            if let carbImpactDate = carbImpactDate {
+                let hoursAgo = carbImpactDate.timeIntervalSince(bgTime) / (45 * 60)
+                if hoursAgo > 1 || hoursAgo < 0 {
+                    continue
+                }
+            }
+
+            // Get last bg time - JS logic
+            // Note display_time isn't set in Trio so this is the
+            // only logic that will trigger
+            if lastbgi >= 0, lastbgi < glucoseData.count {
+                lastbgTime = glucoseData[lastbgi].date
+            } else {
                 continue
             }
-            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))
+
+            var elapsedMinutes = bgTime.timeIntervalSince(lastbgTime) / 60
+
+            if abs(elapsedMinutes) > 8 {
+                // Interpolate missing data points - JS logic with all its quirks
+                var lastbg = lastbgi >= 0 && lastbgi < glucoseData.count ? glucoseData[lastbgi].glucose : bucketedData[j].glucose
+                // Cap at 4 hours like JS AND modify the variable
+                elapsedMinutes = min(240, abs(elapsedMinutes))
+
+                while elapsedMinutes > 5 {
+                    // JS creates previousbgTime by subtracting from lastbgTime
+                    let previousbgTime = lastbgTime.addingTimeInterval(-5 * 60)
+                    j += 1
+
+                    let gapDelta = glucoseData[i].glucose - lastbg
+                    // JS uses the capped elapsed_minutes value
+                    let previousbg = lastbg + (5 / Decimal(elapsedMinutes)) * gapDelta
+
+                    let interpolatedBucket = BucketedGlucose(
+                        glucose: previousbg.rounded(scale: 0),
+                        date: previousbgTime
+                    )
+                    bucketedData.append(interpolatedBucket)
+
+                    elapsedMinutes -= 5
+                    lastbg = previousbg
+                    lastbgTime = previousbgTime
+                }
+                // JS behavior: Do NOT add the actual glucose reading after interpolation
+
+            } else if abs(elapsedMinutes) > 2 {
+                // Add new sample
+                j += 1
+                bucketedData.append(BucketedGlucose(
+                    glucose: glucoseData[i].glucose,
+                    date: bgTime
+                ))
             } else {
-                // average
-                bucketedData = Array(bucketedData.dropLast())
-                bucketedData.append(lastBucket.average(adding: glucose))
+                // Average with previous
+                bucketedData[j] = bucketedData[j].average(adding: glucoseData[i])
             }
+
+            lastbgi = i
         }
 
         return bucketedData
     }
 
     /// Calculates carb absorption and related metrics from bucketed glucose data
+    /// Faithful port including JS bugs where clock and profile are mutated
     private static func calculateCarbAbsorption(
         bucketedData: [BucketedGlucose],
         treatments: [ComputedPumpHistoryEvent],
         basalProfile: [BasalProfileEntry],
-        profile: Profile,
+        profile: inout Profile, // Mutated to match JS bug
         mealDate: Date,
-        carbImpactDate: Date?
+        carbImpactDate: Date?,
+        clock: inout Date // Mutated to match JS bug
     ) throws -> CobResult {
         var carbsAbsorbed: Decimal = 0
         var currentDeviation: Decimal = 0
@@ -152,50 +196,54 @@ struct MealCob {
         var minDeviation: Decimal = 999
         var allDeviations: [Decimal] = []
 
-        // Process bucketed data (excluding last 3 entries to avoid incomplete deltas)
-        // If bucketed data < 4, skips loop and just returns default values, matching JS behavior
-        for bucketCount in 0 ..< max(0, bucketedData.count - 3) {
-            let glucoseTime = bucketedData[bucketCount].date
-            let glucose = bucketedData[bucketCount].glucose
+        // Process bucketed data (excluding last 3 entries)
+        for i in 0 ..< max(0, bucketedData.count - 3) {
+            let bgTime = bucketedData[i].date
+            let bg = bucketedData[i].glucose
 
-            // Skip invalid glucose readings
-            guard glucose >= 39, bucketedData[bucketCount + 3].glucose >= 39 else {
+            // Skip if glucose values are invalid
+            guard bg >= 39, bucketedData[i + 3].glucose >= 39 else {
                 continue
             }
 
+            let avgDelta = (bg - bucketedData[i + 3].glucose) / 3
+            let delta = bg - bucketedData[i + 1].glucose
+
+            // Get ISF
             guard let isfProfile = profile.isfProfile?.toInsulinSensitivities() else {
                 throw CobError.missingIsfProfile
             }
-            let (sensitivity, _) = try Isf.isfLookup(isfDataInput: isfProfile, timestamp: glucoseTime)
-            guard sensitivity > 0 else {
-                throw CobError.isfLookupError
-            }
+            let (sens, _) = try Isf.isfLookup(isfDataInput: isfProfile, timestamp: bgTime)
 
-            let avgDelta = (glucose - bucketedData[bucketCount + 3].glucose) / 3
-            let delta = glucose - bucketedData[bucketCount + 1].glucose
+            // JS BUGS: These mutations persist!
+            clock = bgTime // Mutates the clock
+            profile.currentBasal = try Basal.basalLookup(basalProfile, now: bgTime) // Mutates the profile
 
-            var simulationProfile = profile
-            simulationProfile.currentBasal = try Basal.basalLookup(basalProfile, now: glucoseTime)
+            // Calculate IOB with mutated values
+            let iob = try IobCalculation.iobTotal(
+                treatments: treatments,
+                profile: profile,
+                time: clock // Uses the mutated clock
+            )
 
-            let iob = try IobCalculation.iobTotal(treatments: treatments, profile: simulationProfile, time: glucoseTime)
+            // JS: bgi = Math.round(( -iob.activity * sens * 5 )*100)/100
+            let bgi: Decimal = (-iob.activity * sens * 5).jsRounded(scale: 2)
+            let deviation = delta - bgi
 
-            // Copying Javascript rounding
-            // JS oref calls this "bgi" = "blood glucose impact"
-            let glucoseImpact: Decimal = (-iob.activity * sensitivity * 5 * 100 + 0.5)
-                .rounded(scale: 0, roundingMode: .down) / 100
-            let deviation = delta - glucoseImpact
-
-            // Calculate the deviation right now, for use in min_5m
-            if bucketCount == 0 {
-                currentDeviation = ((avgDelta - glucoseImpact) * 1000).rounded() / 1000
-                if let carbImpactDate = carbImpactDate, carbImpactDate > glucoseTime {
+            // Calculate current deviation
+            if i == 0 {
+                // JS: currentDeviation = Math.round((avgDelta-bgi)*1000)/1000
+                currentDeviation = (avgDelta - bgi).jsRounded(scale: 3)
+                if let carbImpactDate = carbImpactDate, carbImpactDate > bgTime {
                     allDeviations.append(currentDeviation.rounded())
                 }
-            } else if let carbImpactDate = carbImpactDate, carbImpactDate > glucoseTime {
-                let avgDeviation = ((avgDelta - glucoseImpact) * 1000).rounded() / 1000
-                // we remove the * 1000 because we're already using seconds, not ms
-                let deviationSlope = (avgDeviation - currentDeviation) / Decimal(glucoseTime.timeIntervalSince(carbImpactDate)) *
-                    60 * 5
+            } else if let carbImpactDate = carbImpactDate, carbImpactDate > bgTime {
+                // JS: avgDeviation = Math.round((avgDelta-bgi)*1000)/1000
+                let avgDeviation = (avgDelta - bgi).jsRounded(scale: 3)
+                // JS: deviationSlope = (avgDeviation-currentDeviation)/(bgTime-ciTime)*1000*60*5
+                // we can drop the *1000 since we're already in seconds
+                let deviationSlope = (avgDeviation - currentDeviation) /
+                    Decimal(bgTime.timeIntervalSince(carbImpactDate)) * 60 * 5
 
                 if avgDeviation > maxDeviation {
                     slopeFromMaxDeviation = min(0, deviationSlope)
@@ -209,19 +257,21 @@ struct MealCob {
                 allDeviations.append(avgDeviation.rounded())
             }
 
-            // If glucoseTime is more recent than mealTime
-            if glucoseTime > mealDate {
+            // Calculate carbs absorbed
+            if bgTime > mealDate {
                 guard let carbRatio = profile.carbRatio else {
                     throw CobError.missingCarbRatioInProfile
                 }
 
-                // Figure out how many carbs that represents
-                let carbImpact = max(deviation, currentDeviation / 2, profile.min5mCarbImpact)
-                let absorbed = carbImpact * carbRatio / sensitivity
+                // JS: ci = Math.max(deviation, currentDeviation/2, profile.min_5m_carbimpact)
+                let ci = max(deviation, currentDeviation / 2, profile.min5mCarbImpact)
+                let absorbed = ci * carbRatio / sens
                 carbsAbsorbed += absorbed
             }
         }
 
+        // IMPORTANT: clock and profile.currentBasal remain mutated after this function returns!
+
         return CobResult(
             carbsAbsorbed: carbsAbsorbed,
             currentDeviation: currentDeviation,

+ 10 - 7
Trio/Sources/APS/OpenAPSSwift/Meal/MealTotal.swift

@@ -13,14 +13,16 @@ struct ComputedCarbs: Codable {
 }
 
 struct IOBInput {
-    let profile: Profile
+    var profile: Profile
     let history: [PumpHistoryEvent]
-    let clock: Date
+    // var to enable input mutation
+    var clock: Date
 }
 
 struct COBInputs {
     let glucoseData: [BloodGlucose]
-    let iobInputs: IOBInput
+    // var to enable input mutations
+    var iobInputs: IOBInput
     let basalProfile: [BasalProfileEntry]
     var mealDate: Date
     var carbImpactDate: Date?
@@ -73,6 +75,7 @@ enum MealTotal {
 
         // Re-assign to a var, so it can be sorted
         var _treatments = treatments
+        var profile = profile
 
         // Define defaults
         var carbs = Decimal(0)
@@ -111,11 +114,11 @@ enum MealTotal {
                     lastCarbTime = max(lastCarbTime, treatmentTime)
 
                     let myCarbsAbsorbed = try MealCob.detectCarbAbsorption(
-                        clock: cobInputs.iobInputs.clock,
+                        clock: &cobInputs.iobInputs.clock,
                         glucose: cobInputs.glucoseData,
                         pumpHistory: cobInputs.iobInputs.history,
                         basalProfile: cobInputs.basalProfile,
-                        profile: cobInputs.iobInputs.profile,
+                        profile: &cobInputs.iobInputs.profile,
                         mealDate: cobInputs.mealDate,
                         carbImpactDate: cobInputs.carbImpactDate
                     ).carbsAbsorbed
@@ -145,11 +148,11 @@ enum MealTotal {
         /// omiting maxCOB check here, the setting is not Optional in Swift and must be part of profile
 
         let finalCobResult = try MealCob.detectCarbAbsorption(
-            clock: cobInputs.iobInputs.clock,
+            clock: &cobInputs.iobInputs.clock,
             glucose: cobInputs.glucoseData,
             pumpHistory: cobInputs.iobInputs.history,
             basalProfile: cobInputs.basalProfile,
-            profile: cobInputs.iobInputs.profile,
+            profile: &cobInputs.iobInputs.profile,
             mealDate: cobInputs.mealDate,
             carbImpactDate: cobInputs.carbImpactDate
         )

+ 50 - 22
TrioTests/OpenAPSSwiftTests/MealCobBucketingTests.swift

@@ -82,10 +82,10 @@ import Testing
 
         // Check interpolated values (in reverse chronological order)
         #expect(result[0].glucose == 120) // original (newest)
-        #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
+        #expect(result[1].glucose == 115) // interpolated
+        #expect(result[2].glucose == 110) // interpolated
+        #expect(result[3].glucose == 105) // interpolated
+        #expect(result[4].glucose == 100) // interpolated
 
         // Check that dates are properly set
         #expect(result[1].date == mealTime.addingTimeInterval(16 * 60))
@@ -119,14 +119,15 @@ import Testing
             carbImpactDate: nil
         )
 
-        // 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 == 25)
+        // JS test expects 72 entries (not 25 as in original Swift test)
+        print(result)
+        #expect(result.count == 72)
 
-        #expect(result[0].glucose == 124)
-        #expect(result[12].glucose == 112)
-        #expect(result[24].glucose == 100)
+        // Check specific values to match JS test
+        #expect(result[0].glucose == 196)
+        #expect(result[1].glucose == 195)
+        #expect(result[12].glucose == 178)
+        #expect(result[24].glucose == 160)
     }
 
     @Test("should only process data within 45 minutes in CI mode") func shouldOnlyProcessDataWithin45MinutesInCIMode() async throws {
@@ -151,14 +152,14 @@ import Testing
             carbImpactDate: ciTime
         )
 
-        // Should only include data within 45 minutes of ciTime
-        // but it keeps the first bucket value and interpolates
+        // JS test shows this captures more than 45 minutes due to the bucketing logic
         for entry in result {
             let minutesFromCI = abs(ciTime.timeIntervalSince(entry.date)) / 60
-            #expect(minutesFromCI <= 45)
+            #expect(minutesFromCI <= 120) // JS test uses 120, not 45
         }
 
-        #expect(result.count == 10)
+        // JS test expects 21 entries
+        #expect(result.count == 21)
     }
 
     @Test("should stop processing when pre-meal BG is found") func shouldStopProcessingWhenPreMealBGIsFound() async throws {
@@ -167,8 +168,8 @@ import Testing
 
         // Create data that includes pre-meal values (chronological order)
         var glucose_data = [
-            createGlucoseEntry(glucose: 90, timeMs: mealTimeMs - 10 * 60 * 1000), // 30 min before meal
-            createGlucoseEntry(glucose: 95, timeMs: mealTimeMs - 5 * 60 * 1000), // 15 min before meal
+            createGlucoseEntry(glucose: 90, timeMs: mealTimeMs - 10 * 60 * 1000), // 10 min before meal
+            createGlucoseEntry(glucose: 95, timeMs: mealTimeMs - 5 * 60 * 1000), // 5 min before meal
             createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
             createGlucoseEntry(glucose: 105, timeMs: mealTimeMs + 5 * 60 * 1000),
             createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 10 * 60 * 1000),
@@ -183,15 +184,14 @@ import Testing
             carbImpactDate: nil
         )
 
-        // 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 == 4)
+        // JS test expects 5 entries (includes one pre-meal entry due to bug)
+        #expect(result.count == 5)
         // 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) // This pre-meal entry is included due to JS bug
     }
 
     @Test(
@@ -219,6 +219,34 @@ import Testing
         // Close readings should be averaged (in reverse chronological order)
         #expect(result.count == 2)
         #expect(result[0].glucose == 110)
-        #expect(result[1].glucose == 102)
+        // JS test shows averaging bug results in 101.5, not 102
+        #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,
+            carbImpactDate: nil
+        )
+
+        // JS test expects 48 entries due to capping at 240 minutes
+        #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)
     }
 }

+ 26 - 32
TrioTests/OpenAPSSwiftTests/MealCobTests.swift

@@ -38,23 +38,23 @@ import Testing
 
     @Test("should detect carb absorption with rising glucose") func detectCarbAbsorptionWithRisingGlucose() async throws {
         let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
-        let carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+        var carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
 
         // Create glucose data showing significant rise after meal
         let glucoseValues = [100, 105, 110, 115, 120, 130, 140, 150, 155, 160, 160, 160, 160]
         let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
 
-        let profile = createBasicProfile()
+        var profile = createBasicProfile()
         let basalProfile = createBasalProfile()
         let pumpHistory: [PumpHistoryEvent] = []
 
         // Test with carbImpactTime
         var result = try MealCob.detectCarbAbsorption(
-            clock: carbImpactTime, // no pump events, set to whatever
+            clock: &carbImpactTime, // no pump events, set to whatever
             glucose: glucoseData,
             pumpHistory: pumpHistory,
             basalProfile: basalProfile,
-            profile: profile,
+            profile: &profile,
             mealDate: mealTime,
             carbImpactDate: carbImpactTime
         )
@@ -63,11 +63,11 @@ import Testing
 
         // Test without carbImpactTime
         result = try MealCob.detectCarbAbsorption(
-            clock: carbImpactTime, // no pump events, set to whatever
+            clock: &carbImpactTime, // no pump events, set to whatever
             glucose: glucoseData,
             pumpHistory: pumpHistory,
             basalProfile: basalProfile,
-            profile: profile,
+            profile: &profile,
             mealDate: mealTime,
             carbImpactDate: nil
         )
@@ -77,22 +77,22 @@ import Testing
 
     @Test("should handle stable glucose (no carb absorption)") func handleStableGlucose() async throws {
         let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
-        let carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+        var carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
 
         // Create stable glucose data
         let glucoseValues = [100, 100, 100, 100, 100, 100]
         let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
 
-        let profile = createBasicProfile()
+        var profile = createBasicProfile()
         let basalProfile = createBasalProfile()
         let pumpHistory: [PumpHistoryEvent] = []
 
         let result = try MealCob.detectCarbAbsorption(
-            clock: carbImpactTime, // no pump events, set to whatever
+            clock: &carbImpactTime, // no pump events, set to whatever
             glucose: glucoseData,
             pumpHistory: pumpHistory,
             basalProfile: basalProfile,
-            profile: profile,
+            profile: &profile,
             mealDate: mealTime,
             carbImpactDate: carbImpactTime
         )
@@ -102,22 +102,22 @@ import Testing
 
     @Test("should handle falling glucose (negative deviation)") func handleFallingGlucose() async throws {
         let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
-        let carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+        var carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
 
         // Create falling glucose data: 150 -> 125
         let glucoseValues = [150, 145, 140, 135, 130, 125]
         let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
 
-        let profile = createBasicProfile()
+        var profile = createBasicProfile()
         let basalProfile = createBasalProfile()
         let pumpHistory: [PumpHistoryEvent] = []
 
         let result = try MealCob.detectCarbAbsorption(
-            clock: carbImpactTime, // no pump events, set to whatever
+            clock: &carbImpactTime, // no pump events, set to whatever
             glucose: glucoseData,
             pumpHistory: pumpHistory,
             basalProfile: basalProfile,
-            profile: profile,
+            profile: &profile,
             mealDate: mealTime,
             carbImpactDate: carbImpactTime
         )
@@ -127,7 +127,7 @@ import Testing
 
     @Test("should stop processing when pre-meal BG is found") func stopProcessingWhenPreMealBGFound() async throws {
         let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
-        let carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+        var carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
 
         // Include glucose data from before meal time
         let glucoseData = [
@@ -149,16 +149,16 @@ import Testing
             )
         ]
 
-        let profile = createBasicProfile()
+        var profile = createBasicProfile()
         let basalProfile = createBasalProfile()
         let pumpHistory: [PumpHistoryEvent] = []
 
         let result = try MealCob.detectCarbAbsorption(
-            clock: carbImpactTime, // no pump events, set to whatever
+            clock: &carbImpactTime, // no pump events, set to whatever
             glucose: glucoseData,
             pumpHistory: pumpHistory,
             basalProfile: basalProfile,
-            profile: profile,
+            profile: &profile,
             mealDate: mealTime,
             carbImpactDate: carbImpactTime
         )
@@ -168,7 +168,7 @@ import Testing
 
     @Test("should respect maxMealAbsorptionTime") func respectMaxMealAbsorptionTime() async throws {
         let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
-        let carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+        var carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
 
         // Create glucose data spanning longer than maxMealAbsorptionTime
         var glucoseValues: [Int] = []
@@ -188,26 +188,20 @@ import Testing
         let pumpHistory: [PumpHistoryEvent] = []
 
         let result = try MealCob.detectCarbAbsorption(
-            clock: carbImpactTime, // no pump events, set to whatever
+            clock: &carbImpactTime, // no pump events, set to whatever
             glucose: glucoseData,
             pumpHistory: pumpHistory,
             basalProfile: basalProfile,
-            profile: profile,
+            profile: &profile,
             mealDate: mealTime,
             carbImpactDate: carbImpactTime
         )
 
-        // For this check, the Swift implementation is very
-        // different from Javascript. I believe that the difference
-        // lies in the incorrect handling of maxMealAbsorption
-        // https://github.com/nightscout/Trio/issues/672
-        // #expect(result.carbsAbsorbed.isWithin(0.01, of: 40.5))
-        #expect(result.carbsAbsorbed.isWithin(0.01, of: 5.25))
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 40.5))
     }
 
     @Test("should handle minimum carb impact from profile") func handleMinimumCarbImpactFromProfile() async throws {
-        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
-        let carbImpactTime: Date? = nil
+        var mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
 
         // Create glucose data with slight rise to trigger carb absorption
         let glucoseValues = [100, 101, 102, 103, 104, 105]
@@ -219,13 +213,13 @@ import Testing
         let pumpHistory: [PumpHistoryEvent] = []
 
         let result = try MealCob.detectCarbAbsorption(
-            clock: mealTime, // no pump events, set to whatever
+            clock: &mealTime, // no pump events, set to whatever
             glucose: glucoseData,
             pumpHistory: pumpHistory,
             basalProfile: basalProfile,
-            profile: profile,
+            profile: &profile,
             mealDate: mealTime,
-            carbImpactDate: carbImpactTime
+            carbImpactDate: nil
         )
 
         #expect(result.carbsAbsorbed.isWithin(0.01, of: 3.75))

+ 6 - 10
TrioTests/OpenAPSSwiftTests/MealTotalTests.swift

@@ -75,11 +75,6 @@ import Testing
                 timestamp: mealTime,
                 carbs: 30,
                 bolus: nil
-            ),
-            MealInput(
-                timestamp: mealTime,
-                carbs: nil,
-                bolus: 3
             )
         ]
 
@@ -98,9 +93,10 @@ import Testing
 
         // After 1 hour, we should see partial carb absorption
         #expect(result != nil)
-        #expect(result!.mealCOB.isWithin(12 * 0.25, of: 12) == true, "mealCOB: \(result!.mealCOB.description)")
+        // at this level JS is rounding, thus the 0.5
+        #expect(result!.mealCOB.isWithin(0.5, of: 10) == true, "mealCOB: \(result!.mealCOB.description)")
         #expect(
-            result!.currentDeviation.isWithin(3 * 0.25, of: 3),
+            result!.currentDeviation == 3.6,
             "currentDeviation: \(result!.currentDeviation.description)"
         )
     }
@@ -176,10 +172,10 @@ import Testing
         #expect(result != nil)
         #expect(result!.carbs == 20)
         #expect(
-            result!.currentDeviation.isWithin(0.67 * 0.25, of: 0.67) == true,
+            result!.currentDeviation.isWithin(0.02, of: 0.67) == true,
             "currentDeviation: \(result!.currentDeviation.description)"
         )
-        #expect(result!.mealCOB.isWithin(14 * 0.25, of: 14) == true, "mealCOB: \(result!.mealCOB.description)")
+        #expect(result!.mealCOB.isWithin(0.25, of: 14) == true, "mealCOB: \(result!.mealCOB.description)")
     }
 
     @Test("should ignore treatments outside the meal window") func ignoreTreatmentsOutsideMealWindow() async throws {
@@ -230,7 +226,7 @@ import Testing
         #expect(result?.carbs == 0)
         #expect(result?.mealCOB == 0)
         #expect(
-            result?.currentDeviation.isWithin(0.67 * 0.25, of: 0.67) == true,
+            result?.currentDeviation.isWithin(0.02, of: 0.67) == true,
             "currentDeviation: \(result!.currentDeviation.description)"
         )
     }