浏览代码

Merge pull request #548 from nightscout/update-cob-with-js-bugs

Update CoB bucketing and algorithm to match JS
Sam King 10 月之前
父节点
当前提交
b41362f8f3

+ 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)"
         )
     }