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

Merge pull request #541 from kingst/oref-meal-to-swift

[Part 1 of 2] Oref-Swift Port of Meal module and COB algorithm
Sam King 11 месяцев назад
Родитель
Сommit
3da7a87612

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit 89062b019687a61976a077293ee5a3928cf63900
+Subproject commit ca240f9df3cb5dbda9ad574161c9bbf9612908b2

+ 49 - 0
Trio.xcodeproj/project.pbxproj

@@ -293,6 +293,7 @@
 		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 */; };
 		3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BBC22622DF5B93900169236 /* AutosensTests.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 */; };
@@ -306,10 +307,16 @@
 		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 */; };
+		3BE2F1E82E030E2F009E2900 /* MealCobTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE2F1E72E030E2F009E2900 /* MealCobTests.swift */; };
+		3BE2F1EA2E031951009E2900 /* MealCobBucketingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BE2F1E92E031951009E2900 /* MealCobBucketingTests.swift */; };
 		3BEA3AE02D58F79700A67A1D /* OrefFunction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADE2D58F79700A67A1D /* OrefFunction.swift */; };
 		3BEA3AE12D58F79700A67A1D /* AlgorithmComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */; };
 		3BEA3AE22D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */; };
 		3BEA3AE32D58F79700A67A1D /* JSONCompare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADC2D58F79700A67A1D /* JSONCompare.swift */; };
+		3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEF6AB02D9731530076089D /* MealHistoryTests.swift */; };
+		3BEF6AB32D97316F0076089D /* MealTotalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEF6AB22D97316A0076089D /* MealTotalTests.swift */; };
+		3BEF6AB52D9750330076089D /* meal-input-sim.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BEF6AB42D9750330076089D /* meal-input-sim.json */; };
+		3BEF6AB72D9750780076089D /* MealJsonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEF6AB62D9750710076089D /* MealJsonTests.swift */; };
 		3BF424C72DF4805A0017CFD9 /* AutosensError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF424C62DF480550017CFD9 /* AutosensError.swift */; };
 		3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */; };
 		3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */; };
@@ -668,6 +675,9 @@
 		DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */; };
 		DD940BAC2CA75889000830A5 /* DynamicGlucoseColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */; };
 		DD98ACC02D71013200C0778F /* StatChartUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98ACBF2D71013200C0778F /* StatChartUtils.swift */; };
+		DD9E6DA22D59A12700514CEC /* MealHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9E6DA12D59A12200514CEC /* MealHistory.swift */; };
+		DD9E6DA52D5A66BA00514CEC /* MealGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9E6DA42D5A66B500514CEC /* MealGenerator.swift */; };
+		DD9E6DA72D5A695500514CEC /* MealTotal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9E6DA62D5A694900514CEC /* MealTotal.swift */; };
 		DD9ECB682CA99F4500AA7C45 /* TrioRemoteControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */; };
 		DD9ECB6A2CA99F6C00AA7C45 /* PushMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB692CA99F6C00AA7C45 /* PushMessage.swift */; };
 		DD9ECB702CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6D2CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift */; };
@@ -1176,6 +1186,7 @@
 		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>"; };
 		3BBC22622DF5B93900169236 /* AutosensTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensTests.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>"; };
@@ -1188,10 +1199,16 @@
 		3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+rounding.swift"; 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>"; };
+		3BE2F1E72E030E2F009E2900 /* MealCobTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealCobTests.swift; sourceTree = "<group>"; };
+		3BE2F1E92E031951009E2900 /* MealCobBucketingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealCobBucketingTests.swift; sourceTree = "<group>"; };
 		3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlgorithmComparison.swift; sourceTree = "<group>"; };
 		3BEA3ADC2D58F79700A67A1D /* JSONCompare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCompare.swift; sourceTree = "<group>"; };
 		3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsSwiftOrefComparisonLogger.swift; sourceTree = "<group>"; };
 		3BEA3ADE2D58F79700A67A1D /* OrefFunction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrefFunction.swift; sourceTree = "<group>"; };
+		3BEF6AB02D9731530076089D /* MealHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealHistoryTests.swift; sourceTree = "<group>"; };
+		3BEF6AB22D97316A0076089D /* MealTotalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealTotalTests.swift; sourceTree = "<group>"; };
+		3BEF6AB42D9750330076089D /* meal-input-sim.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "meal-input-sim.json"; sourceTree = "<group>"; };
+		3BEF6AB62D9750710076089D /* MealJsonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealJsonTests.swift; sourceTree = "<group>"; };
 		3BF424C62DF480550017CFD9 /* AutosensError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensError.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
 		3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileJsNativeCompareTests.swift; sourceTree = "<group>"; };
@@ -1554,6 +1571,9 @@
 		DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseColorScheme.swift; sourceTree = "<group>"; };
 		DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicGlucoseColor.swift; sourceTree = "<group>"; };
 		DD98ACBF2D71013200C0778F /* StatChartUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatChartUtils.swift; sourceTree = "<group>"; };
+		DD9E6DA12D59A12200514CEC /* MealHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealHistory.swift; sourceTree = "<group>"; };
+		DD9E6DA42D5A66B500514CEC /* MealGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealGenerator.swift; sourceTree = "<group>"; };
+		DD9E6DA62D5A694900514CEC /* MealTotal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealTotal.swift; sourceTree = "<group>"; };
 		DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControl.swift; sourceTree = "<group>"; };
 		DD9ECB692CA99F6C00AA7C45 /* PushMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessage.swift; sourceTree = "<group>"; };
 		DD9ECB6D2CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigStateModel.swift; sourceTree = "<group>"; };
@@ -2748,6 +2768,7 @@
 			children = (
 				3B8B5D2C2DF5234C00365ED3 /* autosens */,
 				3BF92F392D86F1AA006B545A /* iob-error-log.json */,
+				3BEF6AB42D9750330076089D /* meal-input-sim.json */,
 			);
 			path = json;
 			sourceTree = "<group>";
@@ -2770,6 +2791,7 @@
 				3B5CD2A42D4AEA5D00CE213C /* Extensions */,
 				3B1C5C282D68E1E3004E9273 /* Iob */,
 				3BEA3ADF2D58F79700A67A1D /* Logging */,
+				DD9E6DA02D59A11200514CEC /* Meal */,
 				3B5CD2B22D4AEA6600CE213C /* Models */,
 				3B5CD2972D4AEA3C00CE213C /* Profile */,
 				3B5CD2A02D4AEA5100CE213C /* Utils */,
@@ -2841,6 +2863,11 @@
 				3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */,
 				3B1C5C382D68E269004E9273 /* IobTotalTests.swift */,
 				3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */,
+				3BE2F1E92E031951009E2900 /* MealCobBucketingTests.swift */,
+				3BE2F1E72E030E2F009E2900 /* MealCobTests.swift */,
+				3BEF6AB02D9731530076089D /* MealHistoryTests.swift */,
+				3BEF6AB62D9750710076089D /* MealJsonTests.swift */,
+				3BEF6AB22D97316A0076089D /* MealTotalTests.swift */,
 				3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */,
 				3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */,
 				3B5CD2C32D4AECD500CE213C /* ProfileCarbsTests.swift */,
@@ -3709,6 +3736,17 @@
 			path = StartupGuide;
 			sourceTree = "<group>";
 		};
+		DD9E6DA02D59A11200514CEC /* Meal */ = {
+			isa = PBXGroup;
+			children = (
+				3BBB76A92E01C7070040977D /* MealCob.swift */,
+				DD9E6DA42D5A66B500514CEC /* MealGenerator.swift */,
+				DD9E6DA12D59A12200514CEC /* MealHistory.swift */,
+				DD9E6DA62D5A694900514CEC /* MealTotal.swift */,
+			);
+			path = Meal;
+			sourceTree = "<group>";
+		};
 		DD9ECB662CA99EFE00AA7C45 /* RemoteControl */ = {
 			isa = PBXGroup;
 			children = (
@@ -4284,6 +4322,7 @@
 				3BF92F312D86DEE9006B545A /* meal.js in Resources */,
 				3BF92F322D86DEE9006B545A /* glucose-get-last.js in Resources */,
 				3BF92F332D86DEE9006B545A /* iob.js in Resources */,
+				3BEF6AB52D9750330076089D /* meal-input-sim.json in Resources */,
 				3BF92F352D86DEE9006B545A /* basal-set-temp.js in Resources */,
 				3BF92F362D86DEE9006B545A /* autotune-core.js in Resources */,
 				3BC0AA3B2DA74C87000DF7B7 /* iob-total.js in Resources */,
@@ -4472,6 +4511,7 @@
 				CEE9A6552BBB418300EB5194 /* CalibrationsProvider.swift in Sources */,
 				19F95FF529F10FCF00314DDC /* StatProvider.swift in Sources */,
 				38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */,
+				DD9E6DA72D5A695500514CEC /* MealTotal.swift in Sources */,
 				5A2325542BFCBF66003518CA /* NightscoutFetchView.swift in Sources */,
 				19B0EF2128F6D66200069496 /* Statistics.swift in Sources */,
 				DDF847E82C5DABA30049BB3B /* WatchConfigAppleWatchView.swift in Sources */,
@@ -4761,6 +4801,7 @@
 				CEF1ED6B2D58FB5800FAF41E /* CGMOptions.swift in Sources */,
 				E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */,
 				BD8E6B212D9036CA00ABF8FA /* OnboardingProvider.swift in Sources */,
+				DD9E6DA52D5A66BA00514CEC /* MealGenerator.swift in Sources */,
 				DD1745502C55CA5500211FAC /* UnitsLimitsSettingsProvider.swift in Sources */,
 				581AC4392BE22ED10038760C /* JSONConverter.swift in Sources */,
 				BD4064D12C4ED26900582F43 /* CoreDataObserver.swift in Sources */,
@@ -4959,6 +5000,8 @@
 				71D44AAB2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift in Sources */,
 				DDF691032DA2CA1E008BF16C /* AppDiagnosticsProvider.swift in Sources */,
 				320D030F724170A637F06D50 /* (null) in Sources */,
+				CE94598729E9E4110047C9C6 /* WatchConfigRootView.swift in Sources */,
+				DD9E6DA22D59A12700514CEC /* MealHistory.swift in Sources */,
 				19E1F7E829D082D0005C8D20 /* IconConfigDataFlow.swift in Sources */,
 				5A2325522BFCBF55003518CA /* NightscoutUploadView.swift in Sources */,
 				E3A08AAE59538BC8A8ABE477 /* GlucoseNotificationSettingsDataFlow.swift in Sources */,
@@ -4980,6 +5023,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 */,
@@ -5031,9 +5075,12 @@
 				3BFA5BF92D989F510072B082 /* MockTDDStorage.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */,
+				3BEF6AB32D97316F0076089D /* MealTotalTests.swift in Sources */,
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
+				3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */,
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
+				3BE2F1E82E030E2F009E2900 /* MealCobTests.swift in Sources */,
 				3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
@@ -5050,9 +5097,11 @@
 				3BF92F382D86E10B006B545A /* OpenAPSFixed.swift in Sources */,
 				3B1C5C442D68E269004E9273 /* IobJsonTypes.swift in Sources */,
 				3B1C5C452D68E269004E9273 /* IobTotalTests.swift in Sources */,
+				3BE2F1EA2E031951009E2900 /* MealCobBucketingTests.swift in Sources */,
 				3B1C5C472D68E269004E9273 /* IobCalculateTests.swift in Sources */,
 				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				3BC4053B2D931620006A03E9 /* IobJsonTests.swift in Sources */,
+				3BEF6AB72D9750780076089D /* MealJsonTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,

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

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

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

@@ -0,0 +1,241 @@
+import Foundation
+
+struct MealCob {
+    /// Internal structure to keep track of bucketed glucose values
+    struct BucketedGlucose: Codable {
+        let glucose: Decimal
+        let date: Date
+        let samplesInBucket: Int
+
+        func average(adding glucose: BucketedGlucose) -> BucketedGlucose {
+            let total = Decimal(samplesInBucket) * self.glucose + glucose.glucose
+            let numSamples = samplesInBucket + 1
+            let newGlucoseAverage = total / Decimal(numSamples)
+            return BucketedGlucose(glucose: newGlucoseAverage, date: date, samplesInBucket: numSamples)
+        }
+    }
+
+    /// Result structure for carb absorption detection
+    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,
+        carbImpactDate: 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,
+            carbImpactDate: carbImpactDate
+        )
+
+        return try calculateCarbAbsorption(
+            bucketedData: bucketedData,
+            treatments: treatments,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealDate,
+            carbImpactDate: carbImpactDate
+        )
+    }
+
+    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
+    static func bucketGlucoseForCob(
+        glucose: [BloodGlucose],
+        profile: Profile,
+        mealDate: Date,
+        carbImpactDate: Date?
+    ) throws -> [BucketedGlucose] {
+        var glucoseData = glucose.compactMap({ (bg: BloodGlucose) -> BucketedGlucose? in
+            guard let glucose = bg.glucose ?? bg.sgv else { return nil }
+            return BucketedGlucose(glucose: Decimal(glucose), date: bg.dateString, samplesInBucket: 1)
+        })
+
+        var bucketedData: [BucketedGlucose] = []
+
+        // 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 }
+        }
+
+        for glucose in glucoseData {
+            guard let lastBucket = bucketedData.last else {
+                bucketedData.append(glucose)
+                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))
+            } else {
+                // average
+                bucketedData = Array(bucketedData.dropLast())
+                bucketedData.append(lastBucket.average(adding: glucose))
+            }
+        }
+
+        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,
+        carbImpactDate: 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)
+        // 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
+
+            // Skip invalid glucose readings
+            guard glucose >= 39, bucketedData[bucketCount + 3].glucose >= 39 else {
+                continue
+            }
+
+            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 avgDelta = (glucose - bucketedData[bucketCount + 3].glucose) / 3
+            let delta = glucose - bucketedData[bucketCount + 1].glucose
+
+            var simulationProfile = profile
+            simulationProfile.currentBasal = try Basal.basalLookup(basalProfile, now: glucoseTime)
+
+            let iob = try IobCalculation.iobTotal(treatments: treatments, profile: simulationProfile, time: glucoseTime)
+
+            // Copying Javascript rounding
+            // JS oref calls this "big" = "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 {
+                    allDeviations.append(currentDeviation.rounded())
+                }
+            } else if let carbImpactDate = carbImpactDate, carbImpactDate > glucoseTime {
+                let avgDeviation = ((avgDelta - glucoseImpact) * 1000).rounded() / 1000
+                let deviationSlope = (avgDeviation - currentDeviation) / Decimal(glucoseTime.timeIntervalSince(carbImpactDate)) *
+                    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 glucoseTime is more recent than mealTime
+            if glucoseTime > 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 couldNotDetermineLastglucoseTime
+}

+ 25 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealGenerator.swift

@@ -0,0 +1,25 @@
+import Foundation
+
+enum MealGeneratorError {
+    static func generate(
+        pumpHistory: [PumpHistoryEvent],
+        profile: Profile,
+        basalProfile: [BasalProfileEntry],
+        clock: Date,
+        carbHistory: [CarbsEntry],
+        glucoseHistory: [BloodGlucose]
+    ) throws -> ComputedCarbs? {
+        let 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 try MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: pumpHistory,
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseHistory,
+            time: clock
+        )
+    }
+}

+ 81 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealHistory.swift

@@ -0,0 +1,81 @@
+import Foundation
+
+/// Represents the "temp" object built in JS meal/history.js
+struct MealInput {
+    let timestamp: Date
+    var carbs: Decimal? /// `current.carbs`
+    var bolus: Decimal? /// from `current.amount` in Bolus events
+    /// omitting nsCarbs, bwCarbs, journalCarbs
+
+    enum InputType: String {
+        case carbs
+        case bolus
+    }
+}
+
+private struct MealInputKey: Hashable {
+    let timestamp: Date
+    let type: MealInput.InputType
+}
+
+enum MealHistory {
+    /// Converts carb and bolus records into a single, chronological list of MealInput,
+    /// removing any duplicate entries of the same type whose timestamps are within ±2 seconds.
+    /// - Parameters:
+    ///   - pumpHistory: Array of PumpHistoryEvent (bolus events)
+    ///   - carbHistory: Array of CarbsEntry (carb treatments)
+    /// - Returns: A deduplicated array of MealInput, preserving original order but collapsing
+    ///            any carb or bolus events that occur within 2 seconds of an earlier one.
+    static func findMealInputs(
+        pumpHistory: [PumpHistoryEvent],
+        carbHistory: [CarbsEntry]
+    ) -> [MealInput] {
+        let carbInputs = carbHistory.compactMap { entry -> MealInput? in
+            guard entry.carbs > 0 else { return nil }
+            return MealInput(
+                timestamp: entry.createdAt,
+                carbs: entry.carbs,
+                bolus: nil
+            )
+        }
+
+        let bolusInputs = pumpHistory.compactMap { ev -> MealInput? in
+            guard ev.type == .bolus, let amt = ev.amount else { return nil }
+            return MealInput(
+                timestamp: ev.timestamp,
+                carbs: nil,
+                bolus: amt
+            )
+        }
+
+        let combinedIputs = carbInputs + bolusInputs
+        var seenBuckets: [MealInput.InputType: Set<Int>] = [
+            .carbs: Set(),
+            .bolus: Set()
+        ]
+
+        var dedupedInputs: [MealInput] = []
+        dedupedInputs.reserveCapacity(combinedIputs.count)
+
+        for input in combinedIputs {
+            let type: MealInput.InputType = input.carbs != nil ? .carbs : .bolus
+            let tSec = Int(input.timestamp.timeIntervalSince1970)
+
+            // check if any second in [tSec-2 ... tSec+2] is already in our bucket
+            let bucket = seenBuckets[type]!
+            let isDuplicate = (tSec - 2 ... tSec + 2).contains { bucket.contains($0) }
+
+            if !isDuplicate {
+                dedupedInputs.append(input)
+
+                /// copies out bucket, mutates it, writes it back
+                /// ensuring every entry exists at least once, but is properly deduped
+                var newBucket = bucket
+                newBucket.insert(tSec)
+                seenBuckets[type] = newBucket
+            }
+        }
+
+        return dedupedInputs
+    }
+}

+ 171 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealTotal.swift

@@ -0,0 +1,171 @@
+import Foundation
+
+struct ComputedCarbs: Codable {
+    var carbs: Decimal
+    var mealCOB: Decimal
+    var currentDeviation: Decimal
+    var maxDeviation: Decimal
+    var minDeviation: Decimal
+    var slopeFromMaxDeviation: Decimal
+    var slopeFromMinDeviation: Decimal
+    var allDeviations: [Decimal]
+    var lastCarbTime: TimeInterval
+}
+
+struct IOBInput {
+    let profile: Profile
+    let history: [PumpHistoryEvent]
+}
+
+struct COBInputs {
+    let glucoseData: [BloodGlucose]
+    let iobInputs: IOBInput
+    let basalProfile: [BasalProfileEntry]
+    var mealDate: Date
+    var carbImpactDate: Date?
+}
+
+enum MealTotal {
+    /// Calculates the effective carbohydrates on board (COB) and glucose deviations
+    /// resulting from recent meal entries within the user’s absorption window.
+    ///
+    /// This function:
+    /// 1. Filters `treatments` to only those within `profile.maxMealAbsorptionTime` hours before `time`.
+    /// 2. Iterates each carb entry (≥1 g), calling `MealCob.detectCarbAbsorption` in CI mode
+    ///    (last ~45 min) to estimate how much of the accumulated carbs remain unabsorbed,
+    ///    tracking the peak “COB driver” and removing any extra carbs that never drove COB.
+    /// 3. Subtracts out those “extra” carbs to yield the true total carbs counted.
+    /// 4. Performs a final COB + deviation pass anchored at the earliest valid carb timestamp,
+    ///    again in CI mode, to compute:
+    ///    – `currentDeviation`, `maxDeviation`, `minDeviation`,
+    ///    – `slopeFromMaxDeviation`, `slopeFromMinDeviation`,
+    ///    – the full series `allDeviations`,
+    ///    – and applies profile caps (`maxCOB`) and “zombie‐carb” safety (zero COB if no dev).
+    ///
+    /// - Parameters:
+    ///   - treatments:   list of past carb-and-bolus `MealInput` events
+    ///   - pumpHistory:  insulin pump history for IOB calculations
+    ///   - profile:      user profile (carb ratio, ISF, maxMealAbsorptionTime, maxCOB, etc.)
+    ///   - basalProfile: basal insulin schedule
+    ///   - glucose:      BG readings used to bucket and compute deviations
+    ///   - time:         the “now” timestamp at which to evaluate COB & deviations
+    ///
+    /// - Returns:
+    ///   A `ComputedCarbs` struct containing:
+    ///     • `carbs` – total carbs counted
+    ///     • `mealCOB` – carbs on board at peak
+    ///     • `currentDeviation`, `maxDeviation`, `minDeviation`
+    ///     • `slopeFromMaxDeviation`, `slopeFromMinDeviation`
+    ///     • `allDeviations` – the deviation history
+    ///     • `lastCarbTime` – timestamp of the most recent carb used
+    ///
+    /// - Throws: any errors from `MealCob.detectCarbAbsorption`.
+    static func recentCarbs(
+        treatments: [MealInput],
+        pumpHistory: [PumpHistoryEvent],
+        profile: Profile,
+        basalProfile: [BasalProfileEntry],
+        glucose: [BloodGlucose],
+        time: Date
+    ) throws -> ComputedCarbs? {
+        guard treatments.isNotEmpty else { return nil }
+
+        // Re-assign to a var, so it can be sorted
+        var _treatments = treatments
+
+        // Define defaults
+        var carbs = Decimal(0)
+        let mealCarbTime: TimeInterval = time.timeIntervalSince1970
+        var lastCarbTime: TimeInterval = 0
+
+        let iobInputs = IOBInput(profile: profile, history: pumpHistory)
+        var cobInputs = COBInputs(
+            glucoseData: glucose,
+            iobInputs: iobInputs,
+            basalProfile: basalProfile,
+            mealDate: Date(timeIntervalSince1970: mealCarbTime)
+        )
+        var mealCOB = Decimal(0)
+
+        _treatments.sort(by: {
+            $0.timestamp > $1.timestamp
+        })
+
+        var carbsToRemove = Decimal(0)
+
+        for treatment in _treatments {
+            let now = time.timeIntervalSince1970
+
+            // Use new maxMealAbsorptionTime setting here instead of default 6 hrs
+            let carbWindow = now - TimeInterval(hours: Double(truncating: profile.maxMealAbsorptionTime as NSNumber))
+
+            let treatmentDate = treatment.timestamp
+            let treatmentTime = treatmentDate.timeIntervalSince1970
+
+            if treatmentTime > carbWindow, treatmentTime <= now {
+                if let _carbs = treatment.carbs, _carbs >= 1 {
+                    carbs += _carbs
+
+                    cobInputs.mealDate = treatmentDate
+                    lastCarbTime = max(lastCarbTime, treatmentTime)
+
+                    let myCarbsAbsorbed = try MealCob.detectCarbAbsorption(
+                        glucose: cobInputs.glucoseData,
+                        pumpHistory: cobInputs.iobInputs.history,
+                        basalProfile: cobInputs.basalProfile,
+                        profile: cobInputs.iobInputs.profile,
+                        mealDate: cobInputs.mealDate,
+                        carbImpactDate: cobInputs.carbImpactDate
+                    ).carbsAbsorbed
+
+                    // TODO: add logging?
+                    let myMealCOB = max(0, carbs - myCarbsAbsorbed)
+                    mealCOB = max(mealCOB, myMealCOB)
+
+                    if myMealCOB < mealCOB {
+                        carbsToRemove += treatment.carbs ?? 0
+                    } else {
+                        carbsToRemove = 0
+                    }
+                }
+            }
+        }
+
+        // only include carbs actually used in calculating COB
+        carbs -= carbsToRemove
+
+        // calculate the current deviation and steepest deviation downslope over the last hour
+        cobInputs.carbImpactDate = 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,
+            carbImpactDate: cobInputs.carbImpactDate
+        )
+
+        // if currentDeviation is null or maxDeviation is 0, set mealCOB to 0 for zombie-carb safety
+        if finalCobResult.maxDeviation == 0 || finalCobResult.allDeviations.isEmpty {
+            mealCOB = 0
+        }
+
+        return ComputedCarbs(
+            carbs: carbs,
+            mealCOB: mealCOB,
+            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
+        )
+    }
+}

+ 0 - 5
Trio/Sources/Models/PumpHistoryEvent.swift

@@ -57,10 +57,6 @@ enum EventType: String, JSON {
     case bolus = "Bolus"
     case smb = "SMB"
     case isExternal = "External Insulin"
-    case mealBolus = "Meal Bolus"
-    case correctionBolus = "Correction Bolus"
-    case snackBolus = "Snack Bolus"
-    case bolusWizard = "BolusWizard"
     case tempBasal = "TempBasal"
     case tempBasalDuration = "TempBasalDuration"
     case pumpSuspend = "PumpSuspend"
@@ -69,7 +65,6 @@ enum EventType: String, JSON {
     case pumpBattery = "PumpBattery"
     case rewind = "Rewind"
     case prime = "Prime"
-    case journalCarbs = "JournalEntryMealMarker"
 
     case nsTempBasal = "Temp Basal"
     case nsCarbCorrection = "Carb Correction"

+ 224 - 0
TrioTests/OpenAPSSwiftTests/MealCobBucketingTests.swift

@@ -0,0 +1,224 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Meal glucose bucketing tests") struct MealCobBucketingTests {
+    // Default test profile - matches JS exactly
+    func createDefaultProfile() -> Profile {
+        var profile = Profile()
+        profile.dia = 4
+        profile.maxMealAbsorptionTime = 6
+        profile.min5mCarbImpact = 3
+        profile.carbRatio = 10
+        return profile
+    }
+
+    // Helper to create glucose entry - matches JS structure
+    func createGlucoseEntry(glucose: Int, timeMs: Double) -> BloodGlucose {
+        let date = Date(timeIntervalSince1970: timeMs / 1000)
+        return BloodGlucose(
+            sgv: glucose,
+            date: Decimal(timeMs),
+            dateString: date,
+            glucose: glucose
+        )
+    }
+
+    // Note: glucose_data is expected in reverse chronological order (newest first)
+    // The bucketGlucoseData function maintains this order in its output
+
+    @Test(
+        "should handle normal 5-minute interval data without modification"
+    ) func shouldHandleNormal5MinuteIntervalDataWithoutModification() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create regular 5-minute interval data (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 105, timeMs: mealTimeMs + 5 * 60 * 1000),
+            createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 10 * 60 * 1000),
+            createGlucoseEntry(glucose: 115, timeMs: mealTimeMs + 15 * 60 * 1000)
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        // Should return same number of entries
+        #expect(result.count == 4)
+        // Values should be unchanged (in reverse chronological order)
+        #expect(result[0].glucose == 115)
+        #expect(result[1].glucose == 110)
+        #expect(result[2].glucose == 105)
+        #expect(result[3].glucose == 100)
+    }
+
+    @Test("should interpolate missing data when gap > 8 minutes") func shouldInterpolateMissingDataWhenGapGreaterThan8Minutes(
+    ) async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data with a 21-minute gap (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 99, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 120, timeMs: mealTimeMs + 21 * 60 * 1000) // 21 min gap
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        // Should have interpolated 4 additional points (5, 10, 15, 20 minutes)
+        #expect(result.count == 5)
+
+        // 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
+
+        // Check that dates are properly set
+        #expect(result[1].date == mealTime.addingTimeInterval(16 * 60))
+        #expect(result[2].date == mealTime.addingTimeInterval(11 * 60))
+        #expect(result[3].date == mealTime.addingTimeInterval(6 * 60))
+        #expect(result[4].date == mealTime.addingTimeInterval(1 * 60))
+    }
+
+    @Test("should stop processing after maxMealAbsorptionTime") func shouldStopProcessingAfterMaxMealAbsorptionTime() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data spanning 8 hours (chronological order)
+        var glucose_data: [BloodGlucose] = []
+        for i in 0 ... 96 { // 96 * 5 min = 8 hours
+            glucose_data.append(createGlucoseEntry(
+                glucose: 100 + i,
+                timeMs: mealTimeMs + Double(i) * 5 * 60 * 1000
+            ))
+        }
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        // Set maxMealAbsorptionTime to 2 hours
+        var profile = createDefaultProfile()
+        profile.maxMealAbsorptionTime = 2
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: profile,
+            mealDate: mealTime,
+            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)
+
+        #expect(result[0].glucose == 124)
+        #expect(result[12].glucose == 112)
+        #expect(result[24].glucose == 100)
+    }
+
+    @Test("should only process data within 45 minutes in CI mode") func shouldOnlyProcessDataWithin45MinutesInCIMode() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let ciTime = Date.from(isoString: "2024-01-01T14:00:00-05:00") // 2 hours after meal
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data spanning 3 hours (chronological order)
+        var glucose_data: [BloodGlucose] = []
+        for i in 0 ... 36 { // 36 * 5 min = 3 hours
+            glucose_data.append(createGlucoseEntry(
+                glucose: 100 + i,
+                timeMs: mealTimeMs + Double(i) * 5 * 60 * 1000
+            ))
+        }
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            carbImpactDate: ciTime
+        )
+
+        // Should only include data within 45 minutes of ciTime
+        // but it keeps the first bucket value and interpolates
+        for entry in result {
+            let minutesFromCI = abs(ciTime.timeIntervalSince(entry.date)) / 60
+            #expect(minutesFromCI <= 45)
+        }
+
+        #expect(result.count == 10)
+    }
+
+    @Test("should stop processing when pre-meal BG is found") func shouldStopProcessingWhenPreMealBGIsFound() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // 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: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 105, timeMs: mealTimeMs + 5 * 60 * 1000),
+            createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 10 * 60 * 1000),
+            createGlucoseEntry(glucose: 115, timeMs: mealTimeMs + 15 * 60 * 1000) // 15 min after
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            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)
+        // 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)
+    }
+
+    @Test(
+        "should average glucose values when readings are very close (≤ 2 minutes)"
+    ) func shouldAverageGlucoseValuesWhenReadingsAreVeryClose() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data with readings 1 minute apart (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 102, timeMs: mealTimeMs + 1 * 60 * 1000), // 1 min later
+            createGlucoseEntry(glucose: 104, timeMs: mealTimeMs + 2 * 60 * 1000), // 2 min later
+            createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 5 * 60 * 1000) // 5 min later
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        // Close readings should be averaged (in reverse chronological order)
+        #expect(result.count == 2)
+        #expect(result[0].glucose == 110)
+        #expect(result[1].glucose == 102)
+    }
+}

+ 226 - 0
TrioTests/OpenAPSSwiftTests/MealCobTests.swift

@@ -0,0 +1,226 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("MealCob Tests") struct MealCobTests {
+    // Helper function to create basic profile for testing
+    func createBasicProfile() -> Profile {
+        var profile = Profile()
+        profile.dia = 4
+        profile.maxMealAbsorptionTime = 6
+        profile.min5mCarbImpact = 3
+        profile.carbRatio = 10
+        profile.currentBasal = 1.0
+        profile.isfProfile = ComputedInsulinSensitivities(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            sensitivities: [ComputedInsulinSensitivityEntry(sensitivity: 40, offset: 0, start: "00:00:00")]
+        )
+        return profile
+    }
+
+    // Helper function to create basal profile
+    func createBasalProfile() -> [BasalProfileEntry] {
+        [BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 1.0)]
+    }
+
+    // Helper function to create glucose data from values and timestamps
+    func createGlucoseData(startTime: Date, values: [Int], intervalMinutes: Int = 5) -> [BloodGlucose] {
+        values.enumerated().map { i, glucose in
+            let timestamp = startTime.addingTimeInterval(TimeInterval(i * intervalMinutes * 60))
+            return BloodGlucose(
+                sgv: glucose,
+                date: Decimal(timestamp.timeIntervalSince1970 * 1000), // JS uses ms
+                dateString: timestamp
+            )
+        }.reversed()
+    }
+
+    @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")
+
+        // 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()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        // Test with carbImpactTime
+        var result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            carbImpactDate: carbImpactTime
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 9.75))
+
+        // Test without carbImpactTime
+        result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 14.75))
+    }
+
+    @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")
+
+        // Create stable glucose data
+        let glucoseValues = [100, 100, 100, 100, 100, 100]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            carbImpactDate: carbImpactTime
+        )
+
+        #expect(result.carbsAbsorbed == 0)
+    }
+
+    @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")
+
+        // Create falling glucose data: 150 -> 125
+        let glucoseValues = [150, 145, 140, 135, 130, 125]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            carbImpactDate: carbImpactTime
+        )
+
+        #expect(result.carbsAbsorbed == 0) // No carbs absorbed when glucose is falling
+    }
+
+    @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")
+
+        // Include glucose data from before meal time
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 150,
+                date: Decimal(mealTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000), // 1 hour after meal
+                dateString: mealTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 120,
+                date: Decimal(mealTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000), // 30 minutes after meal
+                dateString: mealTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(mealTime.addingTimeInterval(-30 * 60).timeIntervalSince1970 * 1000),
+                // 30 minutes before meal (pre-meal)
+                dateString: mealTime.addingTimeInterval(-30 * 60)
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            carbImpactDate: carbImpactTime
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 3.75))
+    }
+
+    @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")
+
+        // Create glucose data spanning longer than maxMealAbsorptionTime
+        var glucoseValues: [Int] = []
+        for i in 0 ..< 100 { // 100 * 5 minutes = ~8 hours
+            let value = Int(100 + sin(Double(i) * 0.1) * 20) // Sinusoidal pattern
+            glucoseValues.append(value)
+        }
+
+        let glucoseData = createGlucoseData(
+            startTime: mealTime.addingTimeInterval(-2 * 60 * 60), // Start 2 hours before meal
+            values: glucoseValues
+        )
+
+        var profile = createBasicProfile()
+        profile.maxMealAbsorptionTime = 2 // Only 2 hours
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            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))
+    }
+
+    @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
+
+        // Create glucose data with slight rise to trigger carb absorption
+        let glucoseValues = [100, 101, 102, 103, 104, 105]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        var profile = createBasicProfile()
+        profile.min5mCarbImpact = 5 // Higher minimum impact
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: profile,
+            mealDate: mealTime,
+            carbImpactDate: carbImpactTime
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 3.75))
+    }
+}

+ 149 - 0
TrioTests/OpenAPSSwiftTests/MealHistoryTests.swift

@@ -0,0 +1,149 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("MealHistory Tests") struct MealHistoryTests {
+    @Test("should process carbs from carbHistory") func processCarbsFromCarbHistory() async {
+        let carbHistory = [
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                carbs: 20
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: [],
+            carbHistory: carbHistory
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].carbs == 20)
+        #expect(output[0].timestamp == Date.from(isoString: "2016-06-19T12:00:00-04:00"))
+    }
+
+    @Test("should process bolus events from pumpHistory") func processBolusEventsFromPumpHistory() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: []
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].bolus == 2.5)
+        #expect(output[0].timestamp == Date.from(isoString: "2016-06-19T12:00:00-04:00"))
+    }
+
+    @Test("should handle both carbs and bolus entries") func handleBothCarbsAndBolusEntries() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            )
+        ]
+
+        let carbHistory = [
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:30:00-04:00"),
+                carbs: 20
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: carbHistory
+        )
+
+        #expect(output.count == 2)
+
+        // Find the carb entry
+        let carbEntry = output.first { $0.carbs != nil }
+        #expect(carbEntry != nil)
+        #expect(carbEntry?.carbs == 20)
+
+        // Find the bolus entry
+        let bolusEntry = output.first { $0.bolus != nil }
+        #expect(bolusEntry != nil)
+        #expect(bolusEntry?.bolus == 2.5)
+    }
+
+    @Test("should dedupe carb entries with same timestamp") func dedupeCarbs() async {
+        let carbHistory = [
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                carbs: 20
+            ),
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                carbs: 30
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: [],
+            carbHistory: carbHistory
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].carbs == 20)
+    }
+
+    @Test("should dedupe bolus entries with same timestamp") func dedupeBolusEntries() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            ),
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 3.0
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: []
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].bolus == 2.5)
+    }
+
+    @Test("should consider timestamps within 2 seconds as duplicates") func timestampNearlyDuplicates() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            ),
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:01-04:00"),
+                amount: 3.0
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: []
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].bolus == 2.5)
+    }
+}

+ 47 - 0
TrioTests/OpenAPSSwiftTests/MealJsonTests.swift

@@ -0,0 +1,47 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@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")!
+        let data = try Data(contentsOf: URL(fileURLWithPath: path))
+
+        // this file stores an object with JSON encoded strings (so double encoded)
+        let jsonInputs = try JSONSerialization.jsonObject(with: data) as! [String: Any]
+
+        let pumpHistory = try JSONBridge.pumpHistory(from: jsonInputs["pumpHistory"] as! String)
+        let profile = try JSONBridge.profile(from: jsonInputs["profile"] as! String)
+        let basalProfile = try JSONBridge.basalProfile(from: jsonInputs["basalProfile"] as! String)
+        let clock = try JSONBridge.clock(from: jsonInputs["clock"] as! String)
+
+        let decoder = JSONCoding.decoder
+        var jsonData = (jsonInputs["carbs"] as! String).data(using: .utf8)!
+        let carbHistory: [CarbsEntry] = try decoder.decode([CarbsEntry].self, from: jsonData)
+
+        jsonData = (jsonInputs["glucose"] as! String).data(using: .utf8)!
+        let glucoseHistory: [BloodGlucose] = try decoder.decode([BloodGlucose].self, from: jsonData)
+
+        jsonData = (jsonInputs["meal"] as! String).data(using: .utf8)!
+        let mealResultFromJs = try decoder.decode(ComputedCarbs.self, from: jsonData)
+
+        let mealResult = try MealGeneratorError.generate(
+            pumpHistory: pumpHistory,
+            profile: profile,
+            basalProfile: basalProfile,
+            clock: clock,
+            carbHistory: carbHistory,
+            glucoseHistory: glucoseHistory
+        )
+
+        #expect(mealResult?.mealCOB == mealResultFromJs.mealCOB)
+        #expect(mealResult?.carbs == mealResultFromJs.carbs)
+        #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)
+    }
+}

+ 334 - 0
TrioTests/OpenAPSSwiftTests/MealTotalTests.swift

@@ -0,0 +1,334 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("MealTotal Tests") struct MealTotalTests {
+    // Helper methods for testing
+    func createBasicProfile() -> Profile {
+        var profile = Profile()
+        profile.dia = 4
+        profile.maxMealAbsorptionTime = 6
+        profile.maxCOB = 120
+        // profile.carbsAbsorptionRate = 30
+        profile.min5mCarbImpact = 3
+        profile.carbRatio = 10
+        profile.currentBasal = 1.0
+        // Note: In Swift we need to set sensitivities differently than in JS
+        profile
+            .isfProfile = ComputedInsulinSensitivities(
+                units: .mgdL,
+                userPreferredUnits: .mgdL,
+                sensitivities: [ComputedInsulinSensitivityEntry(sensitivity: 40, offset: 0, start: "00:00:00")]
+            )
+        return profile
+    }
+
+    func createBasicBasalProfile() -> [BasalProfileEntry] {
+        [BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 1.0)]
+    }
+
+    func createGlucoseData(baseTime: Date, pattern: [Int]) -> [BloodGlucose] {
+        var result: [BloodGlucose] = []
+
+        for (i, bg) in pattern.enumerated() {
+            let timestamp = baseTime.addingTimeInterval(TimeInterval(i * 5 * 60))
+
+            result.append(BloodGlucose(
+                sgv: bg,
+                date: Decimal(timestamp.timeIntervalSince1970 * 1000), // JS uses ms
+                dateString: timestamp
+            ))
+        }
+
+        return result.reversed()
+    }
+
+    @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
+
+        // Create glucose data showing rise after carbs
+        var bgValues = Array(repeating: 100, count: 13)
+        for i in 3 ..< 8 {
+            bgValues[i] = 100 + ((i - 2) * 10) // 100, 110, 120, 130, 140
+        }
+        for i in 8 ..< 13 {
+            bgValues[i] = 150 // plateau
+        }
+
+        let glucoseData = createGlucoseData(baseTime: baseTime, pattern: bgValues)
+
+        // Create insulin data - bolus at same time as carbs
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: mealTime,
+                amount: 3.0
+            )
+        ]
+
+        // Carb treatment
+        let treatments = [
+            MealInput(
+                timestamp: mealTime,
+                carbs: 30,
+                bolus: nil
+            ),
+            MealInput(
+                timestamp: mealTime,
+                carbs: nil,
+                bolus: 3
+            )
+        ]
+
+        let profile = createBasicProfile()
+
+        let basalProfile = createBasicBasalProfile()
+
+        let result = try MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: pumpHistory,
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: testTime
+        )
+
+        // 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)")
+        #expect(
+            result!.currentDeviation.isWithin(3 * 0.25, of: 0) == true,
+            "currentDeviation: \(result!.currentDeviation.description)"
+        )
+    }
+
+    @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(
+                sgv: 100,
+                date: Decimal(time.timeIntervalSince1970 * 1000),
+                dateString: time
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasicBasalProfile()
+
+        let result = try MealTotal.recentCarbs(
+            treatments: [],
+            pumpHistory: [],
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: time
+        )
+
+        #expect(result == nil)
+    }
+
+    @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")
+
+        let treatments = [
+            MealInput(
+                timestamp: baseTime,
+                carbs: 20,
+                bolus: nil
+            )
+        ]
+
+        // Create glucose pattern with slight rise
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 110,
+                date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 105,
+                date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(baseTime.timeIntervalSince1970 * 1000),
+                dateString: baseTime
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasicBasalProfile()
+
+        let result = try MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: [],
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: testTime
+        )
+
+        #expect(result != nil)
+        #expect(result!.carbs == 20)
+        #expect(
+            result!.currentDeviation.isWithin(0.67 * 0.25, of: 0.67) == true,
+            "currentDeviation: \(result!.currentDeviation.description)"
+        )
+        #expect(result!.mealCOB.isWithin(14 * 0.25, of: 14) == true, "mealCOB: \(result!.mealCOB.description)")
+    }
+
+    @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")
+
+        let treatments = [
+            MealInput(
+                timestamp: treatmentTime,
+                carbs: 20,
+                bolus: nil
+            )
+        ]
+
+        // Create glucose pattern with slight rise
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 110,
+                date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 105,
+                date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(baseTime.timeIntervalSince1970 * 1000),
+                dateString: baseTime
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasicBasalProfile()
+
+        let result = try MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: [],
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: testTime
+        )
+
+        #expect(result != nil)
+        #expect(result?.carbs == 0)
+        #expect(result?.mealCOB == 0)
+        #expect(
+            result?.currentDeviation.isWithin(0.67 * 0.25, of: 0.67) == true,
+            "currentDeviation: \(result!.currentDeviation.description)"
+        )
+    }
+
+    @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")
+
+        let treatments = [
+            MealInput(
+                timestamp: treatmentTime,
+                carbs: 20,
+                bolus: nil
+            )
+        ]
+
+        // Create glucose pattern with slight rise
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 110,
+                date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 105,
+                date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(baseTime.timeIntervalSince1970 * 1000),
+                dateString: baseTime
+            )
+        ]
+
+        var profile = createBasicProfile()
+        profile.maxMealAbsorptionTime = 2 // 2 hour window
+        let basalProfile = createBasicBasalProfile()
+
+        let result = try MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: [],
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: testTime
+        )
+
+        #expect(result != nil)
+        #expect(result?.carbs == 0)
+        #expect(result?.mealCOB == 0)
+    }
+
+    @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")
+
+        let treatments = [
+            MealInput(
+                timestamp: baseTime,
+                carbs: 200,
+                bolus: nil
+            )
+        ]
+
+        // Create glucose pattern with slight rise
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 110,
+                date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 105,
+                date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
+                dateString: baseTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(baseTime.timeIntervalSince1970 * 1000),
+                dateString: baseTime
+            )
+        ]
+
+        let profile = createBasicProfile()
+        let basalProfile = createBasicBasalProfile()
+
+        let result = try MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: [],
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseData,
+            time: testTime
+        )
+
+        #expect(result != nil)
+        #expect(result!.mealCOB <= 120)
+    }
+}

Разница между файлами не показана из-за своего большого размера
+ 16 - 0
TrioTests/OpenAPSSwiftTests/json/meal-input-sim.json


+ 38 - 0
TrioTests/OpenAPSSwiftTests/utils/Extensions.swift

@@ -1,6 +1,44 @@
 import Foundation
 @testable import Trio
 
+// Helper extension for Date from ISO string
+extension Date {
+    static func from(isoString: String) -> Date {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime, .withTimeZone]
+        return formatter.date(from: isoString)!
+    }
+
+    var iso8601String: String {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime, .withTimeZone]
+        return formatter.string(from: self)
+    }
+}
+
+extension CarbsEntry {
+    static func forTest(createdAt: Date, carbs: Decimal) -> CarbsEntry {
+        CarbsEntry(
+            id: nil,
+            createdAt: createdAt,
+            actualDate: nil,
+            carbs: carbs,
+            fat: nil,
+            protein: nil,
+            note: nil,
+            enteredBy: nil,
+            isFPU: nil,
+            fpuID: nil
+        )
+    }
+}
+
+extension TimeInterval {
+    static func hours(_ hours: Double) -> TimeInterval {
+        hours * 60 * 60
+    }
+}
+
 extension [ComputedPumpHistoryEvent] {
     func netInsulin() -> Decimal { compactMap(\.insulin).reduce(0, +) }
 }