Forráskód Böngészése

Port cob.js, fix a few small bugs on the meal oref function

Sam King 11 hónapja
szülő
commit
59718576ec

+ 21 - 21
Trio.xcodeproj/project.pbxproj

@@ -217,7 +217,6 @@
 		3B2F77862D7E52ED005ED9FA /* TDD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77852D7E52ED005ED9FA /* TDD.swift */; };
 		3B2F77882D7E5387005ED9FA /* CurrentTDDSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */; };
 		3B3B57C92DA07B3400849D16 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3B57C82DA07B3400849D16 /* GoogleService-Info.plist */; };
-		3B3B57C92DA07B3400849D16 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3B57C82DA07B3400849D16 /* GoogleService-Info.plist */; };
 		3B4196E02D8C4BC00091DFF7 /* HomeStateModel+CGM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4196DF2D8C4BBB0091DFF7 /* HomeStateModel+CGM.swift */; };
 		3B4550532D862C0000551B0D /* PumpHistoryEvent+Duplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4550522D862BF200551B0D /* PumpHistoryEvent+Duplicates.swift */; };
 		3B47C6102DA0A28F00B0E5EF /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 3B47C60F2DA0A28F00B0E5EF /* FirebaseCrashlytics */; };
@@ -284,15 +283,16 @@
 		3BA8D1B32DDB87150006191F /* DecimalExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA8D1B22DDB870F0006191F /* DecimalExtensions.swift */; };
 		3BAD36B22D7CDC1A00CC298D /* MainLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */; };
 		3BAD36CC2D7D420E00CC298D /* CoreDataInitializationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */; };
+		3BBB76AA2E01C70B0040977D /* MealCob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BBB76A92E01C7070040977D /* MealCob.swift */; };
 		3BC0AA3B2DA74C87000DF7B7 /* iob-total.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BC0AA3A2DA74C87000DF7B7 /* iob-total.js */; };
 		3BC0AA3E2DA817EC000DF7B7 /* iob-calculate.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BC0AA3C2DA817EC000DF7B7 /* iob-calculate.js */; };
 		3BC0AA3F2DA817EC000DF7B7 /* iob-history.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BC0AA3D2DA817EC000DF7B7 /* iob-history.js */; };
 		3BC0AA412DA8B900000DF7B7 /* iob-history-prepare.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BC0AA402DA8B8F7000DF7B7 /* iob-history-prepare.js */; };
 		3BC26E552D7418830066ACD6 /* IobSuspendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */; };
 		3BC4053B2D931620006A03E9 /* IobJsonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC4053A2D931620006A03E9 /* IobJsonTests.swift */; };
+		3BCA5F7C2DC7B16400A7EAC7 /* pumphistory-with-external.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */; };
 		3BCE75B32D4B38AE009E9453 /* InsulinSensitivities+Convert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */; };
 		3BCE75B52D4B391F009E9453 /* Decimal+rounding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */; };
-		3BCA5F7C2DC7B16400A7EAC7 /* pumphistory-with-external.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */; };
 		3BD6CE262DC24CFD00FA0472 /* pumphistory-24h-zoned.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */; };
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
@@ -1162,15 +1162,16 @@
 		3BA8D1B22DDB870F0006191F /* DecimalExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecimalExtensions.swift; sourceTree = "<group>"; };
 		3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainLoadingView.swift; sourceTree = "<group>"; };
 		3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataInitializationCoordinator.swift; sourceTree = "<group>"; };
+		3BBB76A92E01C7070040977D /* MealCob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealCob.swift; sourceTree = "<group>"; };
 		3BC0AA3A2DA74C87000DF7B7 /* iob-total.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "iob-total.js"; sourceTree = "<group>"; };
 		3BC0AA3C2DA817EC000DF7B7 /* iob-calculate.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "iob-calculate.js"; sourceTree = "<group>"; };
 		3BC0AA3D2DA817EC000DF7B7 /* iob-history.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "iob-history.js"; sourceTree = "<group>"; };
 		3BC0AA402DA8B8F7000DF7B7 /* iob-history-prepare.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "iob-history-prepare.js"; sourceTree = "<group>"; };
 		3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobSuspendTests.swift; sourceTree = "<group>"; };
 		3BC4053A2D931620006A03E9 /* IobJsonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobJsonTests.swift; sourceTree = "<group>"; };
+		3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-with-external.json"; sourceTree = "<group>"; };
 		3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InsulinSensitivities+Convert.swift"; sourceTree = "<group>"; };
 		3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+rounding.swift"; sourceTree = "<group>"; };
-		3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-with-external.json"; sourceTree = "<group>"; };
 		3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-24h-zoned.json"; sourceTree = "<group>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlgorithmComparison.swift; sourceTree = "<group>"; };
@@ -2705,10 +2706,8 @@
 				BD8FC0552D66187700B95AED /* CoreDataTests */,
 				38FCF3F125E9028E0078B0D1 /* Info.plist */,
 				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
-				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */,
-				3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
 				3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
@@ -2835,6 +2834,20 @@
 			path = OpenAPSSwiftTests;
 			sourceTree = "<group>";
 		};
+		3B997DD22DC02AEF006B6BB2 /* JSONImporterData */ = {
+			isa = PBXGroup;
+			children = (
+				3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */,
+				DD3C47B22DC5608A003DD20D /* newerSuggested.json */,
+				DDD78AD72DC421B500AC63F3 /* enacted.json */,
+				DDD78AD82DC421B500AC63F3 /* suggested.json */,
+				DDD78A902DC4064800AC63F3 /* carbhistory.json */,
+				3B997DD12DC02AEF006B6BB2 /* glucose.json */,
+				3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */,
+			);
+			path = JSONImporterData;
+			sourceTree = "<group>";
+		};
 		3BEA3ADF2D58F79700A67A1D /* Logging */ = {
 			isa = PBXGroup;
 			children = (
@@ -2882,20 +2895,6 @@
 			path = mocks;
 			sourceTree = "<group>";
 		};
-		3B997DD22DC02AEF006B6BB2 /* JSONImporterData */ = {
-			isa = PBXGroup;
-			children = (
-				3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */,
-				DD3C47B22DC5608A003DD20D /* newerSuggested.json */,
-				DDD78AD72DC421B500AC63F3 /* enacted.json */,
-				DDD78AD82DC421B500AC63F3 /* suggested.json */,
-				DDD78A902DC4064800AC63F3 /* carbhistory.json */,
-				3B997DD12DC02AEF006B6BB2 /* glucose.json */,
-				3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */,
-			);
-			path = JSONImporterData;
-			sourceTree = "<group>";
-		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -3682,9 +3681,10 @@
 		DD9E6DA02D59A11200514CEC /* Meal */ = {
 			isa = PBXGroup;
 			children = (
-				DD9E6DA62D5A694900514CEC /* MealTotal.swift */,
+				3BBB76A92E01C7070040977D /* MealCob.swift */,
 				DD9E6DA42D5A66B500514CEC /* MealGenerator.swift */,
 				DD9E6DA12D59A12200514CEC /* MealHistory.swift */,
+				DD9E6DA62D5A694900514CEC /* MealTotal.swift */,
 			);
 			path = Meal;
 			sourceTree = "<group>";
@@ -4265,7 +4265,6 @@
 				3BF92F322D86DEE9006B545A /* glucose-get-last.js in Resources */,
 				3BF92F332D86DEE9006B545A /* iob.js in Resources */,
 				3BEF6AB52D9750330076089D /* meal-input-sim.json in Resources */,
-				3BF92F342D86DEE9006B545A /* iob-history.js in Resources */,
 				3BF92F352D86DEE9006B545A /* basal-set-temp.js in Resources */,
 				3BF92F362D86DEE9006B545A /* autotune-core.js in Resources */,
 				3BC0AA3B2DA74C87000DF7B7 /* iob-total.js in Resources */,
@@ -4957,6 +4956,7 @@
 				DDE1795A2C910127003CDDB7 /* CarbEntryStored+CoreDataClass.swift in Sources */,
 				DDE1795B2C910127003CDDB7 /* CarbEntryStored+CoreDataProperties.swift in Sources */,
 				DDE1795E2C910127003CDDB7 /* PumpEventStored+CoreDataClass.swift in Sources */,
+				3BBB76AA2E01C70B0040977D /* MealCob.swift in Sources */,
 				BDDAF9EF2D00554500B34E7A /* SelectionPopoverView.swift in Sources */,
 				DDE1795F2C910127003CDDB7 /* PumpEventStored+CoreDataProperties.swift in Sources */,
 				DDE179602C910127003CDDB7 /* StatsData+CoreDataClass.swift in Sources */,

+ 254 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealCob.swift

@@ -0,0 +1,254 @@
+import Foundation
+
+struct MealCob {
+    /// Internal structure to keep track of bucketed glucose values
+    struct BucketedGlucose {
+        let glucose: Decimal
+        let date: Date
+    }
+
+    /// Result structure for carb absorption detection
+    struct CobResult {
+        let carbsAbsorbed: Decimal
+        let currentDeviation: Decimal
+        let maxDeviation: Decimal
+        let minDeviation: Decimal
+        let slopeFromMaxDeviation: Decimal
+        let slopeFromMinDeviation: Decimal
+        let allDeviations: [Decimal]
+    }
+
+    /// Detects carb absorption by analyzing glucose deviations from expected insulin activity
+    ///
+    /// This is the main COB detection algorithm entry point
+    static func detectCarbAbsorption(
+        glucose: [BloodGlucose],
+        pumpHistory: [PumpHistoryEvent],
+        basalProfile: [BasalProfileEntry],
+        profile: Profile,
+        mealDate: Date,
+        ciDate: Date?
+    ) throws -> CobResult {
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory.map { $0.computedEvent() },
+            profile: profile,
+            clock: mealDate,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        let bucketedData = try bucketGlucoseForCob(
+            glucose: glucose,
+            profile: profile,
+            mealDate: mealDate,
+            ciDate: ciDate
+        )
+
+        return try calculateCarbAbsorption(
+            bucketedData: bucketedData,
+            treatments: treatments,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealDate,
+            ciDate: ciDate
+        )
+    }
+
+    /// Groups glucose readings into time buckets with interpolation for missing data points
+    private static func bucketGlucoseForCob(
+        glucose: [BloodGlucose],
+        profile: Profile,
+        mealDate: Date,
+        ciDate: Date?
+    ) throws -> [BucketedGlucose] {
+        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)
+        })
+
+        guard let first = glucoseData.first else { return [] }
+
+        var bucketedData = [first]
+        var foundPreMealBG = false
+        var lastBgIndex = 0
+
+        for i in 1 ..< glucoseData.count {
+            let currentGlucose = glucoseData[i]
+            let bgTime = currentGlucose.date
+
+            // Skip invalid glucose readings
+            guard currentGlucose.glucose >= 39 else {
+                continue
+            }
+
+            // Only consider BGs for maxMealAbsorptionTime hours after a meal
+            let hoursAfterMeal = Decimal(bgTime.timeIntervalSince(mealDate)) / 3600
+            if hoursAfterMeal > profile.maxMealAbsorptionTime || foundPreMealBG {
+                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
+            } else {
+                throw CobError.couldNotDetermineLastBgTime
+            }
+
+            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)
+                    let gapDelta = currentGlucose.glucose - lastBg
+                    let previousBg = interpolationBg + (5 / cappedElapsedMinutes * gapDelta)
+
+                    bucketedData.append(BucketedGlucose(
+                        glucose: previousBg.rounded(),
+                        date: previousBgTime
+                    ))
+
+                    remainingMinutes -= 5
+                    interpolationBg = previousBg
+                    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
+    }
+
+    /// Calculates carb absorption and related metrics from bucketed glucose data
+    private static func calculateCarbAbsorption(
+        bucketedData: [BucketedGlucose],
+        treatments: [ComputedPumpHistoryEvent],
+        basalProfile: [BasalProfileEntry],
+        profile: Profile,
+        mealDate: Date,
+        ciDate: Date?
+    ) throws -> CobResult {
+        var carbsAbsorbed: Decimal = 0
+        var currentDeviation: Decimal = 0
+        var slopeFromMaxDeviation: Decimal = 0
+        var slopeFromMinDeviation: Decimal = 999
+        var maxDeviation: Decimal = 0
+        var minDeviation: Decimal = 999
+        var allDeviations: [Decimal] = []
+
+        // Process bucketed data (excluding last 3 entries to avoid incomplete deltas)
+        for i in 0 ..< (bucketedData.count - 3) {
+            let bgTime = bucketedData[i].date
+            let bg = bucketedData[i].glucose
+
+            // Skip invalid glucose readings
+            guard bg >= 39, bucketedData[i + 3].glucose >= 39 else {
+                continue
+            }
+
+            guard let isfProfile = profile.isfProfile?.toInsulinSensitivities() else {
+                throw CobError.missingIsfProfile
+            }
+            let (sensitivity, _) = try Isf.isfLookup(isfDataInput: isfProfile, timestamp: bgTime)
+            guard sensitivity > 0 else {
+                throw CobError.isfLookupError
+            }
+
+            let avgDelta = (bg - bucketedData[i + 3].glucose) / 3
+            let delta = bg - bucketedData[i + 1].glucose
+
+            var simulationProfile = profile
+            simulationProfile.currentBasal = try Basal.basalLookup(basalProfile, now: bgTime)
+
+            let iob = try IobCalculation.iobTotal(treatments: treatments, profile: simulationProfile, time: bgTime)
+
+            // Copying Javascript rounding
+            let bgi: Decimal = (-iob.activity * sensitivity * 5 * 100 + 0.5).rounded(scale: 0, roundingMode: .down) / 100
+            let deviation = delta - bgi
+
+            // Calculate the deviation right now, for use in min_5m
+            if i == 0 {
+                currentDeviation = ((avgDelta - bgi) * 1000).rounded() / 1000
+                if let ciDate = ciDate, ciDate > bgTime {
+                    allDeviations.append(currentDeviation.rounded())
+                }
+            } else if let ciDate = ciDate, ciDate > bgTime {
+                let avgDeviation = ((avgDelta - bgi) * 1000).rounded() / 1000
+                let deviationSlope = (avgDeviation - currentDeviation) / Decimal(bgTime.timeIntervalSince(ciDate)) * 1000 * 60 * 5
+
+                if avgDeviation > maxDeviation {
+                    slopeFromMaxDeviation = min(0, deviationSlope)
+                    maxDeviation = avgDeviation
+                }
+                if avgDeviation < minDeviation {
+                    slopeFromMinDeviation = max(0, deviationSlope)
+                    minDeviation = avgDeviation
+                }
+
+                allDeviations.append(avgDeviation.rounded())
+            }
+
+            // If bgTime is more recent than mealTime
+            if bgTime > mealDate {
+                guard let carbRatio = profile.carbRatio else {
+                    throw CobError.missingCarbRatioInProfile
+                }
+
+                // Figure out how many carbs that represents
+                let ci = max(deviation, currentDeviation / 2, profile.min5mCarbImpact)
+                let absorbed = ci * carbRatio / sensitivity
+                carbsAbsorbed += absorbed
+            }
+        }
+
+        return CobResult(
+            carbsAbsorbed: carbsAbsorbed,
+            currentDeviation: currentDeviation,
+            maxDeviation: maxDeviation,
+            minDeviation: minDeviation,
+            slopeFromMaxDeviation: slopeFromMaxDeviation,
+            slopeFromMinDeviation: slopeFromMinDeviation,
+            allDeviations: allDeviations
+        )
+    }
+}
+
+/// Error types for COB calculation
+enum CobError: Error {
+    case missingIsfProfile
+    case isfLookupError
+    case missingCarbRatioInProfile
+    case couldNotDetermineLastBgTime
+}

+ 2 - 2
Trio/Sources/APS/OpenAPSSwift/Meal/MealGenerator.swift

@@ -8,12 +8,12 @@ enum MealGeneratorError {
         clock: Date,
         carbHistory: [CarbsEntry],
         glucoseHistory: [BloodGlucose]
-    ) -> ComputedCarbs? {
+    ) throws -> ComputedCarbs? {
         var treatments: [MealInput] = MealHistory.findMealInputs(pumpHistory: pumpHistory, carbHistory: carbHistory)
 
         // TODO: do we need to handle the clock timezone handling? We'll parse in a proper Swift Date anyhow
 
-        return MealTotal.recentCarbs(
+        return try MealTotal.recentCarbs(
             treatments: treatments,
             pumpHistory: pumpHistory,
             profile: profile,

+ 42 - 29
Trio/Sources/APS/OpenAPSSwift/Meal/MealTotal.swift

@@ -25,8 +25,8 @@ struct COBInputs {
     let glucoseData: [BloodGlucose]
     let iobInputs: IOBInput
     let basalProfile: [BasalProfileEntry]
-    var mealTime: TimeInterval?
-    var ciTime: TimeInterval?
+    var mealDate: Date
+    var ciDate: Date?
 }
 
 enum MealTotal {
@@ -37,7 +37,7 @@ enum MealTotal {
         basalProfile: [BasalProfileEntry],
         glucose: [BloodGlucose],
         time: Date
-    ) -> ComputedCarbs? {
+    ) throws -> ComputedCarbs? {
         guard treatments.isNotEmpty else { return nil }
 
         var _treatments = treatments
@@ -50,7 +50,12 @@ enum MealTotal {
         var bwFound: Bool = false
 
         let iobInputs = IOBInput(profile: profile, history: pumpHistory)
-        var cobInputs = COBInputs(glucoseData: glucose, iobInputs: iobInputs, basalProfile: basalProfile, mealTime: mealCarbTime)
+        var cobInputs = COBInputs(
+            glucoseData: glucose,
+            iobInputs: iobInputs,
+            basalProfile: basalProfile,
+            mealDate: Date(timeIntervalSince1970: mealCarbTime)
+        )
         var mealCOB = Decimal(0)
 
         _treatments.sort(by: {
@@ -72,13 +77,13 @@ enum MealTotal {
             let treatmentTime = treatmentDate.timeIntervalSince1970
 
             if treatmentTime > carbWindow, treatmentTime <= now {
-                if var _carbs = treatment.carbs, carbs >= 1 {
-                    if var _nsCarbs = treatment.nsCarbs, nsCarbs >= 1 {
+                if var _carbs = treatment.carbs, _carbs >= 1 {
+                    if var _nsCarbs = treatment.nsCarbs, _nsCarbs >= 1 {
                         nsCarbs += _nsCarbs
-                    } else if var _bwCarbs = treatment.bwCarbs, bwCarbs >= 1 {
+                    } else if var _bwCarbs = treatment.bwCarbs, _bwCarbs >= 1 {
                         bwCarbs += _bwCarbs
                         bwFound = true
-                    } else if var _journalCarbs = treatment.journalCarbs, journalCarbs >= 1 {
+                    } else if var _journalCarbs = treatment.journalCarbs, _journalCarbs >= 1 {
                         journalCarbs += _journalCarbs
                     } else {
                         print("Treatment carbs unclassified: \(treatment)")
@@ -86,10 +91,17 @@ enum MealTotal {
 
                     carbs += _carbs
 
-                    cobInputs.mealTime = treatmentTime
+                    cobInputs.mealDate = treatmentDate
                     lastCarbTime = max(lastCarbTime, treatmentTime)
 
-                    let myCarbsAbsorbed = Decimal(0) // TODO: call perted cob method here
+                    let myCarbsAbsorbed = try MealCob.detectCarbAbsorption(
+                        glucose: cobInputs.glucoseData,
+                        pumpHistory: cobInputs.iobInputs.history,
+                        basalProfile: cobInputs.basalProfile,
+                        profile: cobInputs.iobInputs.profile,
+                        mealDate: cobInputs.mealDate,
+                        ciDate: cobInputs.ciDate
+                    ).carbsAbsorbed
 
                     // TODO: add logging?
                     let myMealCOB = max(0, carbs - myCarbsAbsorbed)
@@ -120,25 +132,26 @@ enum MealTotal {
         journalCarbs -= journalCarbsToRemove
 
         // calculate the current deviation and steepest deviation downslope over the last hour
-        cobInputs.ciTime = time.timeIntervalSince1970
-        cobInputs.mealTime = TimeInterval(hours: Double(truncating: profile.maxMealAbsorptionTime as NSNumber))
+        cobInputs.ciDate = time
+        cobInputs.mealDate = time - Double(profile.maxMealAbsorptionTime) * 3600
 
         // set a hard upper limit on COB to mitigate impact of erroneous or malicious carb entry
         mealCOB = min(profile.maxCOB, mealCOB)
         /// omiting maxCOB check here, the setting is not Optional in Swift and must be part of profile
 
+        let finalCobResult = try MealCob.detectCarbAbsorption(
+            glucose: cobInputs.glucoseData,
+            pumpHistory: cobInputs.iobInputs.history,
+            basalProfile: cobInputs.basalProfile,
+            profile: cobInputs.iobInputs.profile,
+            mealDate: cobInputs.mealDate,
+            ciDate: cobInputs.ciDate
+        )
+
         // if currentDeviation is null or maxDeviation is 0, set mealCOB to 0 for zombie-carb safety
-        // TODO: make these adjustments once we have cob.js ported
-//        if (typeof(c.currentDeviation) === 'undefined' || c.currentDeviation === null) {
-//            console.error("");
-//            console.error("Warning: setting mealCOB to 0 because currentDeviation is null/undefined");
-//            mealCOB = 0;
-//        }
-//        if (typeof(c.maxDeviation) === 'undefined' || c.maxDeviation === null) {
-//            console.error("");
-//            console.error("Warning: setting mealCOB to 0 because maxDeviation is 0 or undefined");
-//            mealCOB = 0;
-//        }
+        if finalCobResult.maxDeviation == 0 || finalCobResult.allDeviations.isEmpty {
+            mealCOB = 0
+        }
 
         return ComputedCarbs(
             carbs: carbs,
@@ -146,12 +159,12 @@ enum MealTotal {
             bwCarbs: bwCarbs,
             journalCarbs: journalCarbs,
             mealCOB: mealCOB,
-            currentDeviation: 0,
-            maxDeviation: 0,
-            minDeviation: 0,
-            slopeFromMaxDeviation: 0,
-            slopeFromMinDeviation: 0,
-            allDeviations: [],
+            currentDeviation: finalCobResult.currentDeviation.rounded(scale: 2),
+            maxDeviation: finalCobResult.maxDeviation.rounded(scale: 2),
+            minDeviation: finalCobResult.minDeviation.rounded(scale: 2),
+            slopeFromMaxDeviation: finalCobResult.slopeFromMaxDeviation.rounded(scale: 3),
+            slopeFromMinDeviation: finalCobResult.slopeFromMinDeviation.rounded(scale: 3),
+            allDeviations: finalCobResult.allDeviations,
             lastCarbTime: lastCarbTime,
             bwFound: bwFound
         )

+ 6 - 0
Trio/Sources/APS/OpenAPSSwift/Models/ComputedInsulinSensitivities.swift

@@ -4,6 +4,12 @@ struct ComputedInsulinSensitivities: Codable {
     let units: GlucoseUnits
     let userPreferredUnits: GlucoseUnits
     let sensitivities: [ComputedInsulinSensitivityEntry]
+
+    func toInsulinSensitivities() -> InsulinSensitivities {
+        let sensitivities = self.sensitivities
+            .map { InsulinSensitivityEntry(sensitivity: $0.sensitivity, offset: $0.offset, start: $0.start) }
+        return InsulinSensitivities(units: units, userPreferredUnits: userPreferredUnits, sensitivities: sensitivities)
+    }
 }
 
 extension ComputedInsulinSensitivities {

+ 11 - 4
TrioTests/OpenAPSSwiftTests/MealJsonTests.swift

@@ -2,7 +2,7 @@ import Foundation
 import Testing
 @testable import Trio
 
-@Suite("Testing meal using JSON inputs") struct MealJsonTests {
+@Suite("Meal testing using JSON inputs") struct MealJsonTests {
     @Test("Test against simulator inputs") func simulatorInputs() throws {
         let testBundle = Bundle(for: BundleReference.self)
         let path = testBundle.path(forResource: "meal-input-sim", ofType: "json")!
@@ -26,7 +26,7 @@ import Testing
         jsonData = (jsonInputs["meal"] as! String).data(using: .utf8)!
         let mealResultFromJs = try decoder.decode(ComputedCarbs.self, from: jsonData)
 
-        let mealResult = MealGeneratorError.generate(
+        let mealResult = try MealGeneratorError.generate(
             pumpHistory: pumpHistory,
             profile: profile,
             basalProfile: basalProfile,
@@ -35,7 +35,14 @@ import Testing
             glucoseHistory: glucoseHistory
         )
 
-        // we need something like this
-        // #expect(mealResult == mealResultFromJs)
+        #expect(mealResult?.mealCOB == mealResultFromJs.mealCOB)
+        #expect(mealResult?.carbs == mealResultFromJs.carbs)
+        #expect(mealResult?.nsCarbs == mealResultFromJs.nsCarbs)
+        #expect(mealResult?.currentDeviation == mealResultFromJs.currentDeviation)
+        // https://github.com/nightscout/Trio-dev/issues/539
+        // Ignore this check due to Issue 539
+        //#expect(mealResult?.allDeviations == mealResultFromJs.allDeviations)
+        #expect(mealResult?.maxDeviation == mealResultFromJs.maxDeviation)
+        #expect(mealResult?.minDeviation == mealResultFromJs.minDeviation)
     }
 }

+ 13 - 12
TrioTests/OpenAPSSwiftTests/MealTotalTests.swift

@@ -43,7 +43,7 @@ import Testing
         return result.reversed()
     }
 
-    @Test("should calculate carb absorption correctly") func calculateCarbAbsorption() async {
+    @Test("should calculate carb absorption correctly") func calculateCarbAbsorption() async throws {
         let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
         let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
         let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00") // 1 hour after meal
@@ -90,9 +90,10 @@ import Testing
         ]
 
         let profile = createBasicProfile()
+
         let basalProfile = createBasicBasalProfile()
 
-        let result = MealTotal.recentCarbs(
+        let result = try MealTotal.recentCarbs(
             treatments: treatments,
             pumpHistory: pumpHistory,
             profile: profile,
@@ -107,7 +108,7 @@ import Testing
         #expect(result?.currentDeviation.isWithin(0.1, of: 3) == true)
     }
 
-    @Test("should return nil when no treatments provided") func emptyObjectWhenNoTreatments() async {
+    @Test("should return nil when no treatments provided") func emptyObjectWhenNoTreatments() async throws {
         let time = Date.from(isoString: "2016-06-19T13:00:00-04:00")
         let glucoseData = [
             BloodGlucose(
@@ -120,7 +121,7 @@ import Testing
         let profile = createBasicProfile()
         let basalProfile = createBasicBasalProfile()
 
-        let result = MealTotal.recentCarbs(
+        let result = try MealTotal.recentCarbs(
             treatments: [],
             pumpHistory: [],
             profile: profile,
@@ -132,7 +133,7 @@ import Testing
         #expect(result == nil)
     }
 
-    @Test("should calculate carbs correctly for treatments within the meal window") func calcCarbsWithinMealWindow() async {
+    @Test("should calculate carbs correctly for treatments within the meal window") func calcCarbsWithinMealWindow() async throws {
         let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
         let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
 
@@ -169,7 +170,7 @@ import Testing
         let profile = createBasicProfile()
         let basalProfile = createBasicBasalProfile()
 
-        let result = MealTotal.recentCarbs(
+        let result = try MealTotal.recentCarbs(
             treatments: treatments,
             pumpHistory: [],
             profile: profile,
@@ -185,7 +186,7 @@ import Testing
         #expect(result?.mealCOB == 14)
     }
 
-    @Test("should ignore treatments outside the meal window") func ignoreTreatmentsOutsideMealWindow() async {
+    @Test("should ignore treatments outside the meal window") func ignoreTreatmentsOutsideMealWindow() async throws {
         let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
         let treatmentTime = Date.from(isoString: "2016-06-19T06:00:00-04:00") // 6 hours before
         let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
@@ -223,7 +224,7 @@ import Testing
         let profile = createBasicProfile()
         let basalProfile = createBasicBasalProfile()
 
-        let result = MealTotal.recentCarbs(
+        let result = try MealTotal.recentCarbs(
             treatments: treatments,
             pumpHistory: [],
             profile: profile,
@@ -238,7 +239,7 @@ import Testing
         #expect(result?.currentDeviation.isWithin(0.1, of: 0.67) == true)
     }
 
-    @Test("should respect maxMealAbsorptionTime from profile") func respectMaxMealAbsorptionTime() async {
+    @Test("should respect maxMealAbsorptionTime from profile") func respectMaxMealAbsorptionTime() async throws {
         let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
         let treatmentTime = Date.from(isoString: "2016-06-19T10:00:00-04:00") // 2 hours before
         let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
@@ -277,7 +278,7 @@ import Testing
         profile.maxMealAbsorptionTime = 2 // 2 hour window
         let basalProfile = createBasicBasalProfile()
 
-        let result = MealTotal.recentCarbs(
+        let result = try MealTotal.recentCarbs(
             treatments: treatments,
             pumpHistory: [],
             profile: profile,
@@ -291,7 +292,7 @@ import Testing
         #expect(result?.mealCOB == 0)
     }
 
-    @Test("should respect maxCOB from profile") func respectMaxCOB() async {
+    @Test("should respect maxCOB from profile") func respectMaxCOB() async throws {
         let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
         let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
 
@@ -328,7 +329,7 @@ import Testing
         let profile = createBasicProfile()
         let basalProfile = createBasicBasalProfile()
 
-        let result = MealTotal.recentCarbs(
+        let result = try MealTotal.recentCarbs(
             treatments: treatments,
             pumpHistory: [],
             profile: profile,