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

Port the oref IoB function to Swift

Sam King 1 год назад
Родитель
Сommit
665e7da3c5

+ 92 - 0
Trio.xcodeproj/project.pbxproj

@@ -206,6 +206,23 @@
 		38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FB2737E53800574A46 /* MainStateModel.swift */; };
 		38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FB2737E53800574A46 /* MainStateModel.swift */; };
 		38FEF3FE2738083E00574A46 /* CGMSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FD2738083E00574A46 /* CGMSettingsProvider.swift */; };
 		38FEF3FE2738083E00574A46 /* CGMSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FD2738083E00574A46 /* CGMSettingsProvider.swift */; };
 		38FEF413273B317A00574A46 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF412273B317A00574A46 /* HKUnit.swift */; };
 		38FEF413273B317A00574A46 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF412273B317A00574A46 /* HKUnit.swift */; };
+		3B1C5C292D68E1E3004E9273 /* IobCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C242D68E1E3004E9273 /* IobCalculation.swift */; };
+		3B1C5C2A2D68E1E3004E9273 /* IobGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C262D68E1E3004E9273 /* IobGenerator.swift */; };
+		3B1C5C2B2D68E1E3004E9273 /* IobHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C272D68E1E3004E9273 /* IobHistory.swift */; };
+		3B1C5C2C2D68E1E3004E9273 /* IobError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C252D68E1E3004E9273 /* IobError.swift */; };
+		3B1C5C2F2D68E220004E9273 /* IobResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C2E2D68E220004E9273 /* IobResult.swift */; };
+		3B1C5C302D68E220004E9273 /* ComputedPumpHistoryEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C2D2D68E220004E9273 /* ComputedPumpHistoryEvent.swift */; };
+		3B1C5C332D68E233004E9273 /* PumpHistory+copy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C312D68E233004E9273 /* PumpHistory+copy.swift */; };
+		3B1C5C342D68E233004E9273 /* TimeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C322D68E233004E9273 /* TimeExtensions.swift */; };
+		3B1C5C402D68E269004E9273 /* iob_result.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B1C5C3A2D68E269004E9273 /* iob_result.json */; };
+		3B1C5C412D68E269004E9273 /* iob_history.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B1C5C392D68E269004E9273 /* iob_history.json */; };
+		3B1C5C422D68E269004E9273 /* pump_history.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B1C5C3B2D68E269004E9273 /* pump_history.json */; };
+		3B1C5C432D68E269004E9273 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C3D2D68E269004E9273 /* Extensions.swift */; };
+		3B1C5C442D68E269004E9273 /* IobJsonTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C3E2D68E269004E9273 /* IobJsonTypes.swift */; };
+		3B1C5C452D68E269004E9273 /* IobTotalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C382D68E269004E9273 /* IobTotalTests.swift */; };
+		3B1C5C462D68E269004E9273 /* IobJsonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C372D68E269004E9273 /* IobJsonTests.swift */; };
+		3B1C5C472D68E269004E9273 /* IobCalculateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C352D68E269004E9273 /* IobCalculateTests.swift */; };
+		3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C362D68E269004E9273 /* IobHistoryTests.swift */; };
 		3B5CD1EC2D4912A600CE213C /* OpenAPSSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD1E92D4912A600CE213C /* OpenAPSSwift.swift */; };
 		3B5CD1EC2D4912A600CE213C /* OpenAPSSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD1E92D4912A600CE213C /* OpenAPSSwift.swift */; };
 		3B5CD1ED2D4912A600CE213C /* JSONBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD1EA2D4912A600CE213C /* JSONBridge.swift */; };
 		3B5CD1ED2D4912A600CE213C /* JSONBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD1EA2D4912A600CE213C /* JSONBridge.swift */; };
 		3B5CD2982D4AEA3C00CE213C /* Carbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2922D4AEA3C00CE213C /* Carbs.swift */; };
 		3B5CD2982D4AEA3C00CE213C /* Carbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2922D4AEA3C00CE213C /* Carbs.swift */; };
@@ -959,6 +976,23 @@
 		38FEF3FB2737E53800574A46 /* MainStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStateModel.swift; sourceTree = "<group>"; };
 		38FEF3FB2737E53800574A46 /* MainStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStateModel.swift; sourceTree = "<group>"; };
 		38FEF3FD2738083E00574A46 /* CGMSettingsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMSettingsProvider.swift; sourceTree = "<group>"; };
 		38FEF3FD2738083E00574A46 /* CGMSettingsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMSettingsProvider.swift; sourceTree = "<group>"; };
 		38FEF412273B317A00574A46 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = "<group>"; };
 		38FEF412273B317A00574A46 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = "<group>"; };
+		3B1C5C242D68E1E3004E9273 /* IobCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobCalculation.swift; sourceTree = "<group>"; };
+		3B1C5C252D68E1E3004E9273 /* IobError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobError.swift; sourceTree = "<group>"; };
+		3B1C5C262D68E1E3004E9273 /* IobGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobGenerator.swift; sourceTree = "<group>"; };
+		3B1C5C272D68E1E3004E9273 /* IobHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobHistory.swift; sourceTree = "<group>"; };
+		3B1C5C2D2D68E220004E9273 /* ComputedPumpHistoryEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComputedPumpHistoryEvent.swift; sourceTree = "<group>"; };
+		3B1C5C2E2D68E220004E9273 /* IobResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobResult.swift; sourceTree = "<group>"; };
+		3B1C5C312D68E233004E9273 /* PumpHistory+copy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PumpHistory+copy.swift"; sourceTree = "<group>"; };
+		3B1C5C322D68E233004E9273 /* TimeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeExtensions.swift; sourceTree = "<group>"; };
+		3B1C5C352D68E269004E9273 /* IobCalculateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobCalculateTests.swift; sourceTree = "<group>"; };
+		3B1C5C362D68E269004E9273 /* IobHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobHistoryTests.swift; sourceTree = "<group>"; };
+		3B1C5C372D68E269004E9273 /* IobJsonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobJsonTests.swift; sourceTree = "<group>"; };
+		3B1C5C382D68E269004E9273 /* IobTotalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobTotalTests.swift; sourceTree = "<group>"; };
+		3B1C5C392D68E269004E9273 /* iob_history.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = iob_history.json; sourceTree = "<group>"; };
+		3B1C5C3A2D68E269004E9273 /* iob_result.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = iob_result.json; sourceTree = "<group>"; };
+		3B1C5C3B2D68E269004E9273 /* pump_history.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = pump_history.json; sourceTree = "<group>"; };
+		3B1C5C3D2D68E269004E9273 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
+		3B1C5C3E2D68E269004E9273 /* IobJsonTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobJsonTypes.swift; sourceTree = "<group>"; };
 		3B5CD1E92D4912A600CE213C /* OpenAPSSwift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAPSSwift.swift; sourceTree = "<group>"; };
 		3B5CD1E92D4912A600CE213C /* OpenAPSSwift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAPSSwift.swift; sourceTree = "<group>"; };
 		3B5CD1EA2D4912A600CE213C /* JSONBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONBridge.swift; sourceTree = "<group>"; };
 		3B5CD1EA2D4912A600CE213C /* JSONBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONBridge.swift; sourceTree = "<group>"; };
 		3B5CD2912D4AEA3C00CE213C /* Basal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Basal.swift; sourceTree = "<group>"; };
 		3B5CD2912D4AEA3C00CE213C /* Basal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Basal.swift; sourceTree = "<group>"; };
@@ -2358,10 +2392,41 @@
 			path = TrioTests;
 			path = TrioTests;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
 		};
 		};
+		3B1C5C282D68E1E3004E9273 /* Iob */ = {
+			isa = PBXGroup;
+			children = (
+				3B1C5C242D68E1E3004E9273 /* IobCalculation.swift */,
+				3B1C5C252D68E1E3004E9273 /* IobError.swift */,
+				3B1C5C262D68E1E3004E9273 /* IobGenerator.swift */,
+				3B1C5C272D68E1E3004E9273 /* IobHistory.swift */,
+			);
+			path = Iob;
+			sourceTree = "<group>";
+		};
+		3B1C5C3C2D68E269004E9273 /* json */ = {
+			isa = PBXGroup;
+			children = (
+				3B1C5C392D68E269004E9273 /* iob_history.json */,
+				3B1C5C3A2D68E269004E9273 /* iob_result.json */,
+				3B1C5C3B2D68E269004E9273 /* pump_history.json */,
+			);
+			path = json;
+			sourceTree = "<group>";
+		};
+		3B1C5C3F2D68E269004E9273 /* utils */ = {
+			isa = PBXGroup;
+			children = (
+				3B1C5C3D2D68E269004E9273 /* Extensions.swift */,
+				3B1C5C3E2D68E269004E9273 /* IobJsonTypes.swift */,
+			);
+			path = utils;
+			sourceTree = "<group>";
+		};
 		3B5CD1EB2D4912A600CE213C /* OpenAPSSwift */ = {
 		3B5CD1EB2D4912A600CE213C /* OpenAPSSwift */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
 				3B5CD2A42D4AEA5D00CE213C /* Extensions */,
 				3B5CD2A42D4AEA5D00CE213C /* Extensions */,
+				3B1C5C282D68E1E3004E9273 /* Iob */,
 				3BEA3ADF2D58F79700A67A1D /* Logging */,
 				3BEA3ADF2D58F79700A67A1D /* Logging */,
 				3B5CD2B22D4AEA6600CE213C /* Models */,
 				3B5CD2B22D4AEA6600CE213C /* Models */,
 				3B5CD2972D4AEA3C00CE213C /* Profile */,
 				3B5CD2972D4AEA3C00CE213C /* Profile */,
@@ -2399,6 +2464,8 @@
 				3B5CD2A32D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift */,
 				3B5CD2A32D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift */,
 				3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */,
 				3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */,
 				3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */,
 				3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */,
+				3B1C5C312D68E233004E9273 /* PumpHistory+copy.swift */,
+				3B1C5C322D68E233004E9273 /* TimeExtensions.swift */,
 			);
 			);
 			path = Extensions;
 			path = Extensions;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
@@ -2408,6 +2475,8 @@
 			children = (
 			children = (
 				3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */,
 				3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */,
 				3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */,
 				3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */,
+				3B1C5C2D2D68E220004E9273 /* ComputedPumpHistoryEvent.swift */,
+				3B1C5C2E2D68E220004E9273 /* IobResult.swift */,
 				3B5CD2AF2D4AEA6600CE213C /* Profile.swift */,
 				3B5CD2AF2D4AEA6600CE213C /* Profile.swift */,
 			);
 			);
 			path = Models;
 			path = Models;
@@ -2416,6 +2485,12 @@
 		3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */ = {
 		3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
+				3B1C5C3C2D68E269004E9273 /* json */,
+				3B1C5C3F2D68E269004E9273 /* utils */,
+				3B1C5C352D68E269004E9273 /* IobCalculateTests.swift */,
+				3B1C5C362D68E269004E9273 /* IobHistoryTests.swift */,
+				3B1C5C372D68E269004E9273 /* IobJsonTests.swift */,
+				3B1C5C382D68E269004E9273 /* IobTotalTests.swift */,
 				3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */,
 				3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */,
 				3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */,
 				3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */,
 				3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */,
 				3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */,
@@ -3563,6 +3638,9 @@
 			isa = PBXResourcesBuildPhase;
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			buildActionMask = 2147483647;
 			files = (
 			files = (
+				3B1C5C402D68E269004E9273 /* iob_result.json in Resources */,
+				3B1C5C412D68E269004E9273 /* iob_history.json in Resources */,
+				3B1C5C422D68E269004E9273 /* pump_history.json in Resources */,
 			);
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			runOnlyForDeploymentPostprocessing = 0;
 		};
 		};
@@ -3728,6 +3806,10 @@
 				DD1745262C55526F00211FAC /* SMBSettingsRootView.swift in Sources */,
 				DD1745262C55526F00211FAC /* SMBSettingsRootView.swift in Sources */,
 				3811DE3025C9D49500A708ED /* HomeStateModel.swift in Sources */,
 				3811DE3025C9D49500A708ED /* HomeStateModel.swift in Sources */,
 				38BF021725E7CBBC00579895 /* PumpManagerExtensions.swift in Sources */,
 				38BF021725E7CBBC00579895 /* PumpManagerExtensions.swift in Sources */,
+				3B1C5C292D68E1E3004E9273 /* IobCalculation.swift in Sources */,
+				3B1C5C2A2D68E1E3004E9273 /* IobGenerator.swift in Sources */,
+				3B1C5C2B2D68E1E3004E9273 /* IobHistory.swift in Sources */,
+				3B1C5C2C2D68E1E3004E9273 /* IobError.swift in Sources */,
 				118DF76E2C5ECBC60067FEB7 /* OverridePresetsIntentRequest.swift in Sources */,
 				118DF76E2C5ECBC60067FEB7 /* OverridePresetsIntentRequest.swift in Sources */,
 				CEE9A6552BBB418300EB5194 /* CalibrationsProvider.swift in Sources */,
 				CEE9A6552BBB418300EB5194 /* CalibrationsProvider.swift in Sources */,
 				19F95FF529F10FCF00314DDC /* StatProvider.swift in Sources */,
 				19F95FF529F10FCF00314DDC /* StatProvider.swift in Sources */,
@@ -4033,6 +4115,8 @@
 				DBA5254DBB2586C98F61220C /* ISFEditorProvider.swift in Sources */,
 				DBA5254DBB2586C98F61220C /* ISFEditorProvider.swift in Sources */,
 				BDF34EBE2C0A31D100D51995 /* CustomNotification.swift in Sources */,
 				BDF34EBE2C0A31D100D51995 /* CustomNotification.swift in Sources */,
 				BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */,
 				BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */,
+				3B1C5C332D68E233004E9273 /* PumpHistory+copy.swift in Sources */,
+				3B1C5C342D68E233004E9273 /* TimeExtensions.swift in Sources */,
 				1BBB001DAD60F3B8CEA4B1C7 /* ISFEditorStateModel.swift in Sources */,
 				1BBB001DAD60F3B8CEA4B1C7 /* ISFEditorStateModel.swift in Sources */,
 				DDB37CC72D05127500D99BF4 /* FontExtensions.swift in Sources */,
 				DDB37CC72D05127500D99BF4 /* FontExtensions.swift in Sources */,
 				582DF9772C8CDBE7001F516D /* InsulinView.swift in Sources */,
 				582DF9772C8CDBE7001F516D /* InsulinView.swift in Sources */,
@@ -4188,6 +4272,8 @@
 				DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */,
 				DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */,
 				DDE179682C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift in Sources */,
 				DDE179682C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift in Sources */,
 				DDE179692C910127003CDDB7 /* TempBasalStored+CoreDataProperties.swift in Sources */,
 				DDE179692C910127003CDDB7 /* TempBasalStored+CoreDataProperties.swift in Sources */,
+				3B1C5C2F2D68E220004E9273 /* IobResult.swift in Sources */,
+				3B1C5C302D68E220004E9273 /* ComputedPumpHistoryEvent.swift in Sources */,
 				DDE1796C2C910127003CDDB7 /* OverrideRunStored+CoreDataClass.swift in Sources */,
 				DDE1796C2C910127003CDDB7 /* OverrideRunStored+CoreDataClass.swift in Sources */,
 				DDE1796D2C910127003CDDB7 /* OverrideRunStored+CoreDataProperties.swift in Sources */,
 				DDE1796D2C910127003CDDB7 /* OverrideRunStored+CoreDataProperties.swift in Sources */,
 				DDE1796E2C910127003CDDB7 /* OrefDetermination+CoreDataClass.swift in Sources */,
 				DDE1796E2C910127003CDDB7 /* OrefDetermination+CoreDataClass.swift in Sources */,
@@ -4220,6 +4306,12 @@
 				3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */,
 				3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */,
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,
+				3B1C5C432D68E269004E9273 /* Extensions.swift in Sources */,
+				3B1C5C442D68E269004E9273 /* IobJsonTypes.swift in Sources */,
+				3B1C5C452D68E269004E9273 /* IobTotalTests.swift in Sources */,
+				3B1C5C462D68E269004E9273 /* IobJsonTests.swift in Sources */,
+				3B1C5C472D68E269004E9273 /* IobCalculateTests.swift in Sources */,
+				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 			);
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			runOnlyForDeploymentPostprocessing = 0;

+ 23 - 1
Trio/Sources/APS/OpenAPSSwift/Extensions/Date+MinutesFromMidnight.swift

@@ -13,7 +13,7 @@ enum MinutesFromMidnightError: LocalizedError, Equatable {
 
 
 extension Date {
 extension Date {
     /// Returns the total minutes elapsed since midnight for the current date
     /// Returns the total minutes elapsed since midnight for the current date
-    private var minutesSinceMidnight: Int? {
+    var minutesSinceMidnight: Int? {
         let calendar = Calendar.current
         let calendar = Calendar.current
         let components = calendar.dateComponents([.hour, .minute], from: self)
         let components = calendar.dateComponents([.hour, .minute], from: self)
         guard let hour = components.hour, let minute = components.minute else {
         guard let hour = components.hour, let minute = components.minute else {
@@ -22,6 +22,28 @@ extension Date {
         return hour * 60 + minute
         return hour * 60 + minute
     }
     }
 
 
+    var minutesSinceMidnightWithPrecision: Decimal? {
+        let calendar = Calendar.current
+        let components = calendar.dateComponents([.hour, .minute, .second, .nanosecond], from: self)
+
+        guard let hour = components.hour,
+              let minute = components.minute,
+              let second = components.second,
+              let nanosecond = components.nanosecond
+        else {
+            return nil
+        }
+
+        // Convert nanoseconds to milliseconds and round
+        let milliseconds = (Decimal(nanosecond) / 1_000_000).rounded()
+
+        let baseMinutes = Decimal(hour * 60 + minute)
+        let secondsAsMinutes = Decimal(second) / Decimal(60)
+        let millisecondsAsMinutes = milliseconds / Decimal(60000)
+
+        return baseMinutes + secondsAsMinutes + millisecondsAsMinutes
+    }
+
     /// Checks if the current time falls within the specified range of minutes
     /// Checks if the current time falls within the specified range of minutes
     /// - Parameters:
     /// - Parameters:
     ///   - lowerBound: The lower bound in minutes since midnight (inclusive)
     ///   - lowerBound: The lower bound in minutes since midnight (inclusive)

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

@@ -16,4 +16,14 @@ extension Decimal {
     func rounded() -> Decimal {
     func rounded() -> Decimal {
         rounded(scale: 0)
         rounded(scale: 0)
     }
     }
+
+    func clamp(lowerBound: Decimal, upperBound: Decimal) -> Decimal {
+        if self < lowerBound {
+            return lowerBound
+        } else if self > upperBound {
+            return upperBound
+        } else {
+            return self
+        }
+    }
 }
 }

+ 202 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/PumpHistory+copy.swift

@@ -0,0 +1,202 @@
+import Foundation
+
+extension PumpHistoryEvent {
+    func computedEvent() -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent(
+            id: id,
+            type: type,
+            timestamp: timestamp,
+            amount: amount,
+            duration: duration.map { Decimal($0) },
+            durationMin: durationMin,
+            rate: rate,
+            temp: temp,
+            carbInput: carbInput,
+            fatInput: fatInput,
+            proteinInput: proteinInput,
+            note: note,
+            isSMB: isSMB,
+            isExternal: isExternal,
+            insulin: nil
+        )
+    }
+}
+
+extension ComputedPumpHistoryEvent {
+    func copyWith(duration: Decimal?) -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent(
+            id: id,
+            type: type,
+            timestamp: timestamp,
+            amount: amount,
+            duration: duration,
+            durationMin: durationMin,
+            rate: rate,
+            temp: temp,
+            carbInput: carbInput,
+            fatInput: fatInput,
+            proteinInput: proteinInput,
+            note: note,
+            isSMB: isSMB,
+            isExternal: isExternal,
+            insulin: insulin
+        )
+    }
+
+    func copyWith(duration: Decimal, timestamp: Date) -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent(
+            id: id,
+            type: type,
+            timestamp: timestamp,
+            amount: amount,
+            duration: duration,
+            durationMin: durationMin,
+            rate: rate,
+            temp: temp,
+            carbInput: carbInput,
+            fatInput: fatInput,
+            proteinInput: proteinInput,
+            note: note,
+            isSMB: isSMB,
+            isExternal: isExternal,
+            insulin: insulin
+        )
+    }
+
+    func copyWith(insulin: Decimal?) -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent(
+            id: id,
+            type: type,
+            timestamp: timestamp,
+            amount: amount,
+            duration: duration,
+            durationMin: durationMin,
+            rate: rate,
+            temp: temp,
+            carbInput: carbInput,
+            fatInput: fatInput,
+            proteinInput: proteinInput,
+            note: note,
+            isSMB: isSMB,
+            isExternal: isExternal,
+            insulin: insulin
+        )
+    }
+
+    func copyWith(rate: Decimal?) -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent(
+            id: id,
+            type: type,
+            timestamp: timestamp,
+            amount: amount,
+            duration: duration,
+            durationMin: durationMin,
+            rate: rate,
+            temp: temp,
+            carbInput: carbInput,
+            fatInput: fatInput,
+            proteinInput: proteinInput,
+            note: note,
+            isSMB: isSMB,
+            isExternal: isExternal,
+            insulin: insulin
+        )
+    }
+
+    // Warning: we're using .tempBasal here since there isn't a 'SuspendBasal' case
+    // but the JS code says it's just for debugging
+    static func suspendBasal(timestamp: Date, duration: Decimal?) -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent(
+            id: UUID().uuidString,
+            type: .tempBasal,
+            timestamp: timestamp,
+            amount: nil,
+            duration: duration,
+            durationMin: nil,
+            rate: 0,
+            temp: .absolute,
+            carbInput: nil,
+            fatInput: nil,
+            proteinInput: nil,
+            note: nil,
+            isSMB: nil,
+            isExternal: nil,
+            insulin: nil
+        )
+    }
+
+    static func zeroTempBasal(timestamp: Date, duration: Decimal) -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent(
+            id: UUID().uuidString,
+            type: .tempBasal,
+            timestamp: timestamp,
+            amount: nil,
+            duration: duration,
+            durationMin: nil,
+            rate: 0,
+            temp: nil,
+            carbInput: nil,
+            fatInput: nil,
+            proteinInput: nil,
+            note: nil,
+            isSMB: nil,
+            isExternal: nil,
+            insulin: nil
+        )
+    }
+
+    static func tempBolus(timestamp: Date, insulin: Decimal) -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent(
+            id: UUID().uuidString,
+            type: .bolus,
+            timestamp: timestamp,
+            amount: nil,
+            duration: nil,
+            durationMin: nil,
+            rate: nil,
+            temp: nil,
+            carbInput: nil,
+            fatInput: nil,
+            proteinInput: nil,
+            note: nil,
+            isSMB: nil,
+            isExternal: nil,
+            insulin: insulin
+        )
+    }
+
+    static func forTest(
+        type: EventType,
+        timestamp: Date,
+        amount: Decimal? = nil,
+        duration: Decimal? = nil,
+        durationMin: Int? = nil,
+        rate: Decimal? = nil,
+        temp: TempType? = nil,
+        carbInput: Int? = nil,
+        fatInput: Int? = nil,
+        proteinInput: Int? = nil,
+        note: String? = nil,
+        isSMB: Bool? = nil,
+        isExternal: Bool? = nil,
+        insulin: Decimal? = nil
+    ) -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent(
+            id: UUID().uuidString,
+            type: type,
+            timestamp: timestamp,
+            amount: amount,
+            duration: duration,
+            durationMin: durationMin,
+            rate: rate,
+            temp: temp,
+            carbInput: carbInput,
+            fatInput: fatInput,
+            proteinInput: proteinInput,
+            note: note,
+            isSMB: isSMB,
+            isExternal: isExternal,
+            insulin: insulin
+        )
+    }
+}

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

@@ -0,0 +1,23 @@
+import Foundation
+
+extension Int {
+    var minutesToSeconds: TimeInterval {
+        Double(self * 60)
+    }
+
+    var hoursToSeconds: TimeInterval {
+        Double(minutesToSeconds * 60)
+    }
+}
+
+extension Decimal {
+    var minutesToSeconds: TimeInterval {
+        Double(self * 60)
+    }
+}
+
+extension TimeInterval {
+    var secondsToMinutes: Decimal {
+        Decimal(self / 60)
+    }
+}

+ 117 - 0
Trio/Sources/APS/OpenAPSSwift/Iob/IobCalculation.swift

@@ -0,0 +1,117 @@
+import Foundation
+
+struct IobTotal: Codable {
+    let iob: Decimal
+    let activity: Decimal
+    let basaliob: Decimal
+    let bolusiob: Decimal
+    let netbasalinsulin: Decimal
+    let bolusinsulin: Decimal
+    let time: Date
+}
+
+enum IobCalculation {
+    struct IobCalculationResult {
+        let activityContrib: Decimal
+        let iobContrib: Decimal
+    }
+
+    private static func lookupPeak(from profile: Profile) throws -> Decimal {
+        switch (profile.curve, profile.useCustomPeakTime, profile.insulinPeakTime) {
+        case (.rapidActing, true, let insulinPeakTime):
+            return insulinPeakTime.clamp(lowerBound: 50, upperBound: 120)
+        case (.rapidActing, false, _):
+            return 75
+        case (.ultraRapid, true, let insulinPeakTime):
+            return insulinPeakTime.clamp(lowerBound: 35, upperBound: 100)
+        case (.ultraRapid, false, _):
+            return 55
+        case (.bilinear, _, _):
+            throw IobError.bilinearCurveNotSupported
+        }
+    }
+
+    private static func exp(_ x: Decimal) -> Decimal {
+        // Convert Decimal to Double, calculate exp, convert back to Decimal
+        let doubleX = NSDecimalNumber(decimal: x).doubleValue
+        return Decimal(Darwin.exp(doubleX))
+    }
+
+    static func iobCalc(
+        treatment: ComputedPumpHistoryEvent,
+        time: Date,
+        dia: Decimal,
+        profile: Profile
+    ) throws -> IobCalculationResult? {
+        guard let insulin = treatment.insulin else {
+            return nil
+        }
+
+        let bolusTime = treatment.timestamp
+        let minsAgo = time.timeIntervalSince(bolusTime).secondsToMinutes.rounded()
+        let peak = try lookupPeak(from: profile)
+        let end = dia * Decimal(60)
+
+        guard minsAgo < end else {
+            return IobCalculationResult(activityContrib: 0, iobContrib: 0)
+        }
+
+        // Calculate the constants exactly as in JavaScript
+        let tau = peak * (1 - peak / end) / (1 - 2 * peak / end)
+        let a = 2 * tau / end
+        let S = 1 / (1 - a + (1 + a) * exp(-end / tau))
+
+        let activityContrib = insulin * (S / pow(tau, 2)) * minsAgo * (1 - minsAgo / end) * exp(-minsAgo / tau)
+        let iobContrib = insulin *
+            (1 - S * (1 - a) * ((pow(minsAgo, 2) / (tau * end * (1 - a)) - minsAgo / tau - 1) * exp(-minsAgo / tau) + 1))
+
+        return IobCalculationResult(activityContrib: activityContrib, iobContrib: iobContrib)
+    }
+
+    static func iobTotal(treatments: [ComputedPumpHistoryEvent], profile: Profile, time now: Date) throws -> IobTotal {
+        guard var dia = profile.dia else {
+            throw IobError.diaNotSet
+        }
+
+        var iob = Decimal(0)
+        var basaliob = Decimal(0)
+        var bolusiob = Decimal(0)
+        var netbasalinsulin = Decimal(0)
+        var bolusinsulin = Decimal(0)
+        var activity = Decimal(0)
+
+        if dia < 5 {
+            dia = 5
+        }
+
+        let diaAgo = now - Double(dia * 60 * 60) // convert to seconds
+        let treatments = treatments.filter({ $0.timestamp <= now && $0.timestamp > diaAgo })
+        for treatment in treatments {
+            guard let tIOB = try iobCalc(treatment: treatment, time: now, dia: dia, profile: profile),
+                  let insulin = treatment.insulin
+            else {
+                continue
+            }
+            iob += tIOB.iobContrib
+            activity += tIOB.activityContrib
+            if insulin < 0.1 {
+                // bolus to represent temp basal, which can only be 0.05 or -0.05
+                basaliob += tIOB.iobContrib
+                netbasalinsulin += insulin
+            } else {
+                bolusiob += tIOB.iobContrib
+                bolusinsulin += insulin
+            }
+        }
+
+        return IobTotal(
+            iob: iob.rounded(scale: 3),
+            activity: activity.rounded(scale: 4),
+            basaliob: basaliob.rounded(scale: 3),
+            bolusiob: bolusiob.rounded(scale: 3),
+            netbasalinsulin: netbasalinsulin.rounded(scale: 3),
+            bolusinsulin: bolusinsulin.rounded(scale: 3),
+            time: now
+        )
+    }
+}

+ 33 - 0
Trio/Sources/APS/OpenAPSSwift/Iob/IobError.swift

@@ -0,0 +1,33 @@
+import Foundation
+
+enum IobError: LocalizedError, Equatable {
+    case tempBasalDurationMismatch
+    case tempBasalMissingDuration(timestamp: Date)
+    case tempBasalDurationMissingDuration(timestamp: Date)
+    case pumpSuspendResumeMismatch
+    case basalRateNotSet
+    case rateNotSetOnTempBasal(timestamp: Date)
+    case bilinearCurveNotSupported
+    case diaNotSet
+
+    var errorDescription: String? {
+        switch self {
+        case .tempBasalDurationMismatch:
+            return "Incomplete temp basal / duration pair"
+        case let .tempBasalMissingDuration(timestamp):
+            return "Temp basal is missing duration @ \(timestamp)"
+        case let .tempBasalDurationMissingDuration(timestamp):
+            return "Temp basal duration @ \(timestamp) pump history entry without a duration set"
+        case .pumpSuspendResumeMismatch:
+            return "Had two consecutive pump suspend or resume events"
+        case .basalRateNotSet:
+            return "Unable to derive the current basal rate from the profile data"
+        case let .rateNotSetOnTempBasal(timestamp):
+            return "Temp basal @ \(timestamp) without a rate set"
+        case .bilinearCurveNotSupported:
+            return "Bilinear curve not supported in Trio"
+        case .diaNotSet:
+            return "DIA not set on Profile"
+        }
+    }
+}

+ 40 - 0
Trio/Sources/APS/OpenAPSSwift/Iob/IobGenerator.swift

@@ -0,0 +1,40 @@
+import Foundation
+
+struct IobGenerator {
+    static func generate(history: [PumpHistoryEvent], profile: Profile, clock: Date, autosens: Autosens?) throws -> [IobResult] {
+        let pumpHistory = history.map { $0.computedEvent() }
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: clock,
+            autosens: autosens,
+            zeroTempDuration: nil
+        )
+        let treatmentsWithZeroTemp = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: clock,
+            autosens: autosens,
+            zeroTempDuration: 240
+        )
+
+        let lastBolusTime = treatments.filter({ $0.insulin != nil }).map(\.timestamp).max() ?? Date(timeIntervalSince1970: 0)
+        let lastTemp = treatments.filter({ $0.rate != nil && ($0.duration ?? 0) > 0 }).sorted(by: { $0.timestamp < $1.timestamp })
+            .last
+
+        let iStop = 4 * 60 // look 4h into the future
+        var iobArray = try stride(from: 0, to: iStop, by: 5).map { minutes in
+            let time = clock + minutes.minutesToSeconds
+            let iob = try IobCalculation.iobTotal(treatments: treatments, profile: profile, time: time)
+            let iobWithZeroTemp = try IobCalculation.iobTotal(treatments: treatmentsWithZeroTemp, profile: profile, time: time)
+            return IobResult.from(iob: iob, iobWithZeroTemp: iobWithZeroTemp)
+        }
+
+        if !iobArray.isEmpty {
+            iobArray[0].lastTemp = lastTemp?.toLastTemp() ?? IobResult.LastTemp()
+            iobArray[0].lastBolusTime = UInt64(lastBolusTime.timeIntervalSince1970 * 1000)
+        }
+
+        return iobArray
+    }
+}

+ 305 - 0
Trio/Sources/APS/OpenAPSSwift/Iob/IobHistory.swift

@@ -0,0 +1,305 @@
+import Foundation
+
+/// The Javascript implementation was too complex to port directly, so this is a clean implementation
+/// of the original logic. There are a few differences:
+///  - We are more strict in error checking
+///  - We ignore event types that Trio won't send us
+///  - We exclude some redundant events (shouldn't impact the IoB calculation)
+///
+///  There are two areas where we changed the implementation that could impact IoB calculations
+///  - We don't split temp basals that cross suspends -- after a suspend resumes we assume that
+///     it goes back to the profile basal rate
+///  - We don't split temp basals into 30 minute durations. This doesn't impact the total insulin accounting
+///    but could give the temp basal bolus entries slightly different timing
+///
+///  From looking at the implementat, the `suspendZerosIob` should just be on by default to
+///  handle pump suspensions correctly
+
+struct IobHistory {
+    struct PumpSuspended {
+        let timestamp: Date
+        let durationInMinutes: Decimal
+
+        var end: Date {
+            timestamp + durationInMinutes.minutesToSeconds
+        }
+
+        func doesOverlap(with event: ComputedPumpHistoryEvent) -> Bool {
+            guard let eventDuration = event.duration else {
+                return event.timestamp >= timestamp && event.timestamp < end
+            }
+            let eventEnd = event.timestamp + eventDuration.minutesToSeconds
+
+            return event.timestamp < end && timestamp < eventEnd
+        }
+    }
+
+    /// Processes and extract temp basals from a pumpHistory.
+    ///
+    /// The core algorithm here is to combine `TempBasal` and `TempBasalDuration`
+    /// events into a single TempBasal event with a duration. It also adds a zeroTempBasal at the end
+    /// and makes sure that none of the temp basals overlap.
+    private static func getTempBasals(
+        pumpHistory: [ComputedPumpHistoryEvent],
+        clock: Date,
+        zeroTempDuration: Decimal?
+    ) throws -> [ComputedPumpHistoryEvent] {
+        let tempBasals = pumpHistory.filter { $0.type == .tempBasal }
+        let durations = pumpHistory.filter { $0.type == .tempBasalDuration }
+
+        guard tempBasals.count == durations.count else {
+            throw IobError.tempBasalDurationMismatch
+        }
+
+        // this stops the most recent temp basal, the 1m comes from Javascript
+        let zeroTempBasal = ComputedPumpHistoryEvent.zeroTempBasal(
+            timestamp: clock + 1.minutesToSeconds,
+            duration: zeroTempDuration ?? 0
+        )
+
+        // match temp basal entries to their duration entry
+        let unifiedTempBasals = try zip(tempBasals, durations).map { tempBasal, duration in
+            guard tempBasal.timestamp == duration.timestamp else {
+                throw IobError.tempBasalDurationMismatch
+            }
+
+            guard let duration = duration.durationMin else {
+                throw IobError.tempBasalDurationMissingDuration(timestamp: duration.timestamp)
+            }
+
+            return tempBasal.copyWith(duration: Decimal(duration))
+        } + [zeroTempBasal]
+
+        // if any of our temp basals overlap, truncate
+        let alignedTempBasals = zip(unifiedTempBasals, unifiedTempBasals.dropFirst()).map { curr, next in
+
+            let currEnd = curr.timestamp + (curr.duration?.minutesToSeconds ?? 0)
+            if currEnd > next.timestamp {
+                let newDuration = next.timestamp.timeIntervalSince(curr.timestamp).secondsToMinutes
+                return curr.copyWith(duration: newDuration)
+            } else {
+                return curr
+            }
+        }
+
+        return alignedTempBasals + (unifiedTempBasals.last.map { [$0] } ?? [])
+    }
+
+    /// Calculates periods of pump suspension using `PumpSuspend` and `PumpResume` events.
+    ///
+    /// The algorithm just looks at time intervals from suspend events to resume events to calculate
+    /// periods of suspension.
+    private static func getSuspends(pumpHistory: [ComputedPumpHistoryEvent], clock: Date) throws -> [PumpSuspended] {
+        let pumpSuspendResume = pumpHistory.filter { $0.type == .pumpSuspend || $0.type == .pumpResume }
+
+        for (curr, next) in zip(pumpSuspendResume, pumpSuspendResume.dropFirst()) {
+            guard curr.type != next.type, curr.timestamp != next.timestamp else {
+                throw IobError.pumpSuspendResumeMismatch
+            }
+        }
+
+        var suspends = zip(pumpSuspendResume, pumpSuspendResume.dropFirst()).compactMap { curr, next -> PumpSuspended? in
+            if curr.type == .pumpResume {
+                return nil
+            } else {
+                let duration = next.timestamp.timeIntervalSince(curr.timestamp).secondsToMinutes
+                return PumpSuspended(timestamp: curr.timestamp, durationInMinutes: duration)
+            }
+        }
+
+        if let last = pumpSuspendResume.last, last.type == .pumpSuspend {
+            let duration = (clock + 1.minutesToSeconds).timeIntervalSince(last.timestamp).secondsToMinutes
+            suspends.append(PumpSuspended(timestamp: last.timestamp, durationInMinutes: duration))
+        }
+
+        return suspends
+    }
+
+    /// Modifies or removes tempBasals that overlap with suspension periods
+    ///
+    /// Truncate, move, or remove temp basal commands that overlap with suspension periods.
+    ///
+    /// **Difference from Javascript**
+    /// One important note is that once a suspend happens, the pump doesn't go back to the temp basal's rate
+    /// (at least the omnipod doesn't). When you resume, it resumes at the scheduled basal rate and stays
+    /// there until you issue a new TempBasal command. Thus, we don't split `TempBasal` entries when
+    /// a suspend starts in the middle, we truncate them, which is different from the Javascript implementation.
+    ///
+    /// Dealing with `TempBasal` records that start while the pump is suspended is a bit more nuanced becase
+    /// theoretically this sholdn't be possible. For this case, we follow the Javascript implementation and
+    /// move the `TempBasal` to start after the resume happens.
+    ///
+    /// Finally it adds zero temp basal events for the suspend periods for the IoB calculation
+    private static func modifyTempBasalDuringSuspend(
+        tempBasal: ComputedPumpHistoryEvent,
+        suspends: [PumpSuspended]
+    ) -> ComputedPumpHistoryEvent? {
+        for suspend in suspends {
+            if suspend.doesOverlap(with: tempBasal) {
+                let tempBasalEnd = tempBasal.timestamp + (tempBasal.duration ?? 0).minutesToSeconds
+                if tempBasal.timestamp <= suspend.timestamp {
+                    // truncate if the suspend starts during the temp basal
+                    let duration = suspend.timestamp.timeIntervalSince(tempBasal.timestamp).secondsToMinutes
+                    return tempBasal.copyWith(duration: duration)
+                } else if tempBasalEnd <= suspend.end {
+                    // tempBasal is completely within the suspend
+                    return nil
+                } else {
+                    // adjust start and duration to start after suspend ends
+                    let duration = tempBasalEnd.timeIntervalSince(suspend.end).secondsToMinutes
+                    return tempBasal.copyWith(duration: duration, timestamp: suspend.end)
+                }
+            }
+        }
+
+        return tempBasal
+    }
+
+    private static func splitAroundSuspends(
+        tempBasals: [ComputedPumpHistoryEvent],
+        suspends: [PumpSuspended]
+    ) -> [ComputedPumpHistoryEvent] {
+        let tempBasals = tempBasals.compactMap { modifyTempBasalDuringSuspend(tempBasal: $0, suspends: suspends) }
+        let zeroTempBasals = suspends
+            .map { ComputedPumpHistoryEvent.zeroTempBasal(timestamp: $0.timestamp, duration: $0.durationInMinutes) }
+
+        let tempHistory = (tempBasals + zeroTempBasals).sorted { $0.timestamp < $1.timestamp
+        }
+
+        let adjustedTempHistory = zip(tempHistory, tempHistory.dropFirst()).map { curr, next in
+            let end = curr.timestamp + (curr.duration ?? 0).minutesToSeconds
+            if end > next.timestamp {
+                let newDuration = next.timestamp.timeIntervalSince(end).secondsToMinutes
+                return curr.copyWith(duration: newDuration)
+            } else {
+                return curr
+            }
+        }
+
+        return adjustedTempHistory + (tempHistory.last.map { [$0] } ?? [])
+    }
+
+    /// Returns the relative offset of a profile break in the middle of the event, if one exists
+    private static func findIntersectionOffset(tempBasal: ComputedPumpHistoryEvent, profileBreaks: [Decimal]) -> TimeInterval? {
+        let minutesPerDay = Decimal(24 * 60)
+        if let minutes = tempBasal.timestamp.minutesSinceMidnightWithPrecision {
+            let endMinutes = minutes + (tempBasal.duration ?? 0)
+            for profileBreak in profileBreaks {
+                let breakPlusOneDay = profileBreak + minutesPerDay
+                if profileBreak > minutes, profileBreak < endMinutes {
+                    return (profileBreak - minutes).minutesToSeconds
+                } else if breakPlusOneDay > minutes, breakPlusOneDay < endMinutes {
+                    return (breakPlusOneDay - minutes).minutesToSeconds
+                }
+            }
+        }
+
+        return nil
+    }
+
+    /// Splits any temp basal commands that cross profile break points to simplify the IoB calculation
+    private static func splitTempBasal(
+        tempBasal: ComputedPumpHistoryEvent,
+        profileBreaks: [Decimal],
+        accumulator: [ComputedPumpHistoryEvent] = []
+    ) -> [ComputedPumpHistoryEvent] {
+        guard let offset = findIntersectionOffset(tempBasal: tempBasal, profileBreaks: profileBreaks),
+              let duration = tempBasal.duration
+        else {
+            return accumulator + [tempBasal]
+        }
+
+        let firstEvent = tempBasal.copyWith(duration: offset.secondsToMinutes)
+        let secondEvent = tempBasal.copyWith(
+            duration: duration - offset.secondsToMinutes,
+            timestamp: tempBasal.timestamp + offset
+        )
+
+        // use tail recursion to give the compiler a chance to optimize
+        return splitTempBasal(tempBasal: secondEvent, profileBreaks: profileBreaks, accumulator: accumulator + [firstEvent])
+    }
+
+    /// Converts tempBasal commands to bolus commands with roughly equal insulin delivered
+    private static func extractTempBoluses(
+        from tempBasal: ComputedPumpHistoryEvent,
+        profile: Profile,
+        autosens: Autosens?
+    ) throws -> [ComputedPumpHistoryEvent] {
+        guard let duration = tempBasal.duration, duration > 0 else {
+            return []
+        }
+
+        guard let tempBasalRate = tempBasal.rate else {
+            throw IobError.rateNotSetOnTempBasal(timestamp: tempBasal.timestamp)
+        }
+
+        guard let profileCurrentRate = try Basal.basalLookup(profile.basalprofile ?? [], now: tempBasal.timestamp) ?? profile
+            .currentBasal
+        else {
+            throw IobError.basalRateNotSet
+        }
+
+        let currentRate = autosens.map { $0.ratio * profileCurrentRate } ?? profileCurrentRate
+
+        let netBasalRate = tempBasalRate - currentRate
+        let tempBolusSize: Decimal = netBasalRate < 0 ? -0.05 : 0.05
+
+        let netBasalAmountTmp = (netBasalRate * duration * 10 / 6).rounded()
+        let netBasalAmount = netBasalAmountTmp / Decimal(100)
+        // FIXME: I think the count should be floor not rounded due to pump implementation artifacts
+        let tempBolusCount = Int((netBasalAmount / tempBolusSize).rounded())
+
+        let tempBolusSpacing = Decimal(duration.minutesToSeconds) / Decimal(tempBolusCount)
+
+        return (0 ..< tempBolusCount).map { j in
+            let timestamp = tempBasal.timestamp + Double(j) * Double(tempBolusSpacing)
+            return ComputedPumpHistoryEvent.tempBolus(timestamp: timestamp, insulin: tempBolusSize)
+        }
+    }
+
+    /// Converts tempBasal commands into a series of relative bolus amounts.
+    ///
+    /// Operates on net insulin delivery relative to the current basal rate. Can result in
+    /// negative bolus amounts.
+    private static func convertTempBasalToBolus(
+        tempHistory: [ComputedPumpHistoryEvent],
+        profile: Profile,
+        autosens: Autosens?
+    ) throws -> [ComputedPumpHistoryEvent] {
+        let profileBreaksMinutesSinceMidnight = profile.basalprofile?.map({ Decimal($0.minutes) }) ?? []
+        let splitTempBasals = tempHistory
+            .flatMap { splitTempBasal(tempBasal: $0, profileBreaks: profileBreaksMinutesSinceMidnight) }
+
+        return try splitTempBasals
+            .flatMap { try extractTempBoluses(from: $0, profile: profile, autosens: autosens) }
+    }
+
+    static func calcTempTreatments(
+        history: [ComputedPumpHistoryEvent],
+        profile: Profile,
+        clock: Date,
+        autosens: Autosens?,
+        zeroTempDuration: Decimal?
+    ) throws -> [ComputedPumpHistoryEvent] {
+        // ignore any records in the future and sort them
+        let pumpHistory = history.filter({ $0.timestamp <= clock }).sorted { $0.timestamp < $1.timestamp }
+        let tempBasals = try getTempBasals(pumpHistory: pumpHistory, clock: clock, zeroTempDuration: zeroTempDuration)
+        let suspends = try getSuspends(pumpHistory: pumpHistory, clock: clock)
+        let boluses = pumpHistory.filter({ $0.type == .bolus }).map { $0.copyWith(insulin: $0.amount) }
+
+        let tempHistory: [ComputedPumpHistoryEvent]
+        if profile.suspendZerosIob {
+            tempHistory = splitAroundSuspends(tempBasals: tempBasals, suspends: suspends)
+        } else {
+            tempHistory = tempBasals
+        }
+
+        let tempBoluses = try convertTempBasalToBolus(
+            tempHistory: tempHistory,
+            profile: profile,
+            autosens: autosens
+        )
+
+        return (boluses + tempBoluses + tempHistory).sorted { $0.timestamp < $1.timestamp }
+    }
+}

+ 83 - 0
Trio/Sources/APS/OpenAPSSwift/Models/ComputedPumpHistoryEvent.swift

@@ -0,0 +1,83 @@
+import Foundation
+
+struct ComputedPumpHistoryEvent: Codable, Equatable, Identifiable {
+    let id: String
+    let type: EventType
+    let timestamp: Date
+    let amount: Decimal?
+    var duration: Decimal?
+    let durationMin: Int?
+    let rate: Decimal?
+    let temp: TempType?
+    let carbInput: Int?
+    let fatInput: Int?
+    let proteinInput: Int?
+    let note: String?
+    let isSMB: Bool?
+    let isExternal: Bool?
+    let insulin: Decimal?
+
+    // Make these non-computed properties to ensure they're always set
+    let started_at: Date
+    let date: UInt64
+
+    init(
+        id: String,
+        type: EventType,
+        timestamp: Date,
+        amount: Decimal?,
+        duration: Decimal?,
+        durationMin: Int?,
+        rate: Decimal?,
+        temp: TempType?,
+        carbInput: Int?,
+        fatInput: Int?,
+        proteinInput: Int?,
+        note: String?,
+        isSMB: Bool?,
+        isExternal: Bool?,
+        insulin: Decimal?
+    ) {
+        self.id = id
+        self.type = type
+        self.timestamp = timestamp
+        self.amount = amount
+        self.duration = duration
+        self.durationMin = durationMin
+        self.rate = rate
+        self.temp = temp
+        self.carbInput = carbInput
+        self.fatInput = fatInput
+        self.proteinInput = proteinInput
+        self.note = note
+        self.isSMB = isSMB
+        self.isExternal = isExternal
+        self.insulin = insulin
+
+        // Explicitly set started_at and date as required by history.js
+        started_at = timestamp // This matches behavior of new Date(tz(timestamp))
+        date = UInt64(timestamp.timeIntervalSince1970 * 1000) // This matches behavior of started_at.getTime()
+    }
+}
+
+extension ComputedPumpHistoryEvent {
+    private enum CodingKeys: String, CodingKey {
+        case id
+        case type = "_type"
+        case timestamp
+        case amount
+        case duration
+        case durationMin = "duration (min)"
+        case rate
+        case temp
+        case carbInput = "carb_input"
+        case fatInput
+        case proteinInput
+        case note
+        case isSMB
+        case isExternal
+        case started_at
+        case date
+        case insulin
+    }
+}

+ 92 - 0
Trio/Sources/APS/OpenAPSSwift/Models/IobResult.swift

@@ -0,0 +1,92 @@
+import Foundation
+
+struct IobResult: Codable {
+    static func from(iob: IobTotal, iobWithZeroTemp: IobTotal) -> IobResult {
+        IobResult(
+            iob: iob.iob,
+            activity: iob.activity,
+            basaliob: iob.basaliob,
+            bolusiob: iob.bolusiob,
+            netbasalinsulin: iob.netbasalinsulin,
+            bolusinsulin: iob.bolusinsulin,
+            time: iob.time,
+            iobWithZeroTemp: IobWithZeroTemp(
+                iob: iobWithZeroTemp.iob,
+                activity: iobWithZeroTemp.activity,
+                basaliob: iobWithZeroTemp.basaliob,
+                bolusiob: iobWithZeroTemp.bolusiob,
+                netbasalinsulin: iobWithZeroTemp.netbasalinsulin,
+                bolusinsulin: iobWithZeroTemp.bolusinsulin,
+                time: iobWithZeroTemp.time
+            ),
+            lastBolusTime: nil,
+            lastTemp: nil
+        )
+    }
+
+    let iob: Decimal
+    let activity: Decimal
+    let basaliob: Decimal
+    let bolusiob: Decimal
+    let netbasalinsulin: Decimal
+    let bolusinsulin: Decimal
+    let time: Date
+    let iobWithZeroTemp: IobWithZeroTemp
+    var lastBolusTime: UInt64?
+    var lastTemp: LastTemp?
+
+    struct IobWithZeroTemp: Codable {
+        let iob: Decimal
+        let activity: Decimal
+        let basaliob: Decimal
+        let bolusiob: Decimal
+        let netbasalinsulin: Decimal
+        let bolusinsulin: Decimal
+        let time: Date
+    }
+
+    struct LastTemp: Codable {
+        let rate: Decimal?
+        let timestamp: Date?
+        let started_at: Date?
+        let date: UInt64
+        let duration: Decimal?
+
+        init(rate: Decimal, timestamp: Date, started_at: Date, date: UInt64, duration: Decimal) {
+            self.rate = rate
+            self.timestamp = timestamp
+            self.started_at = started_at
+            self.date = date
+            self.duration = duration
+        }
+
+        // this constructor helps handle the JSON output for the case when there
+        // aren't any temp basals to match the output from Javascript
+        init() {
+            rate = nil
+            timestamp = nil
+            started_at = nil
+            date = 0
+            duration = nil
+        }
+    }
+}
+
+extension ComputedPumpHistoryEvent {
+    func toLastTemp() -> IobResult.LastTemp? {
+        // Only convert if we have the required fields and it's a temp event
+        guard let rate = self.rate,
+              let duration = self.duration
+        else {
+            return nil
+        }
+
+        return IobResult.LastTemp(
+            rate: rate,
+            timestamp: timestamp,
+            started_at: started_at,
+            date: date,
+            duration: duration
+        )
+    }
+}

+ 188 - 0
TrioTests/OpenAPSSwiftTests/IobCalculateTests.swift

@@ -0,0 +1,188 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Calculate IOB Tests") struct CalculateIobTests {
+    // Helper function to create a basic treatment
+    func createTreatment(insulin: Decimal, timestamp: Date) -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent.forTest(
+            type: .bolus,
+            timestamp: timestamp,
+            insulin: insulin
+        )
+    }
+
+    // Helper function to create a basic profile
+    func createProfile(
+        curve: InsulinCurve = .rapidActing,
+        useCustomPeakTime: Bool = false,
+        insulinPeakTime: Decimal = 0
+    ) -> Profile {
+        var profile = Profile()
+        profile.curve = curve
+        profile.useCustomPeakTime = useCustomPeakTime
+        profile.insulinPeakTime = insulinPeakTime
+        profile.dia = 3
+        return profile
+    }
+
+    @Test("should return nil when treatment has no insulin") func returnNilForNoInsulin() async throws {
+        let treatment = ComputedPumpHistoryEvent.forTest(
+            type: .bolus,
+            timestamp: Date()
+        )
+
+        let result = try IobCalculation.iobCalc(
+            treatment: treatment,
+            time: Date(),
+            dia: 3,
+            profile: createProfile()
+        )
+
+        #expect(result == nil)
+    }
+
+    @Test("should calculate IOB with default rapid-acting settings") func calculateDefaultRapidActing() async throws {
+        let now = Date()
+        let thirtyMinsAgo = now - 30.minutesToSeconds
+        let treatment = createTreatment(insulin: 2, timestamp: thirtyMinsAgo)
+
+        let result = try IobCalculation.iobCalc(
+            treatment: treatment,
+            time: now,
+            dia: 3,
+            profile: createProfile()
+        )
+
+        #expect(result != nil)
+        #expect(result!.activityContrib.isWithin(0.0001, of: 0.0115))
+        #expect(result!.iobContrib.isWithin(0.0001, of: 1.8085))
+    }
+
+    @Test("should calculate IOB with custom peak time for rapid-acting insulin") func calculateCustomPeakRapidActing() async throws {
+        let now = Date()
+        let thirtyMinsAgo = now - 30.minutesToSeconds
+        let treatment = createTreatment(insulin: 2, timestamp: thirtyMinsAgo)
+
+        let profile = createProfile(
+            curve: .rapidActing,
+            useCustomPeakTime: true,
+            insulinPeakTime: 100
+        )
+
+        let result = try IobCalculation.iobCalc(
+            treatment: treatment,
+            time: now,
+            dia: 3,
+            profile: profile
+        )
+
+        #expect(result != nil)
+        #expect(result!.activityContrib.isWithin(0.0001, of: 0.0079))
+        #expect(result!.iobContrib.isWithin(0.0001, of: 1.8763))
+    }
+
+    @Test("should handle peak time limits for rapid-acting insulin") func handlePeakTimeLimitsRapidActing() async throws {
+        let now = Date()
+        let thirtyMinsAgo = now - 30.minutesToSeconds
+        let treatment = createTreatment(insulin: 2, timestamp: thirtyMinsAgo)
+
+        // Test upper limit (120)
+        let profileHigh = createProfile(
+            curve: .rapidActing,
+            useCustomPeakTime: true,
+            insulinPeakTime: 150
+        )
+        let resultHigh = try IobCalculation.iobCalc(
+            treatment: treatment,
+            time: now,
+            dia: 3,
+            profile: profileHigh
+        )
+        #expect(resultHigh != nil)
+
+        // Test lower limit (50)
+        let profileLow = createProfile(
+            curve: .rapidActing,
+            useCustomPeakTime: true,
+            insulinPeakTime: 30
+        )
+        let resultLow = try IobCalculation.iobCalc(
+            treatment: treatment,
+            time: now,
+            dia: 3,
+            profile: profileLow
+        )
+        #expect(resultLow != nil)
+    }
+
+    @Test("should calculate IOB with ultra-rapid insulin") func calculateUltraRapid() async throws {
+        let now = Date()
+        let thirtyMinsAgo = now - 30.minutesToSeconds
+        let treatment = createTreatment(insulin: 2, timestamp: thirtyMinsAgo)
+
+        let profile = createProfile(curve: .ultraRapid)
+
+        let result = try IobCalculation.iobCalc(
+            treatment: treatment,
+            time: now,
+            dia: 3,
+            profile: profile
+        )
+
+        #expect(result != nil)
+        #expect(result!.activityContrib.isWithin(0.0001, of: 0.01569))
+        #expect(result!.iobContrib.isWithin(0.0001, of: 1.7202))
+    }
+
+    @Test("should handle peak time limits for ultra-rapid insulin") func handlePeakTimeLimitsUltraRapid() async throws {
+        let now = Date()
+        let thirtyMinsAgo = now - 30.minutesToSeconds
+        let treatment = createTreatment(insulin: 2, timestamp: thirtyMinsAgo)
+
+        // Test upper limit (100)
+        let profileHigh = createProfile(
+            curve: .ultraRapid,
+            useCustomPeakTime: true,
+            insulinPeakTime: 120
+        )
+        let resultHigh = try IobCalculation.iobCalc(
+            treatment: treatment,
+            time: now,
+            dia: 3,
+            profile: profileHigh
+        )
+        #expect(resultHigh != nil)
+
+        // Test lower limit (35)
+        let profileLow = createProfile(
+            curve: .ultraRapid,
+            useCustomPeakTime: true,
+            insulinPeakTime: 30
+        )
+        let resultLow = try IobCalculation.iobCalc(
+            treatment: treatment,
+            time: now,
+            dia: 3,
+            profile: profileLow
+        )
+        #expect(resultLow != nil)
+    }
+
+    @Test("should handle insulin activity after DIA") func handleActivityAfterDIA() async throws {
+        let now = Date()
+        let fourHoursAgo = now - (4 * 60 * 60)
+        let treatment = createTreatment(insulin: 2, timestamp: fourHoursAgo)
+
+        let result = try IobCalculation.iobCalc(
+            treatment: treatment,
+            time: now,
+            dia: 3,
+            profile: createProfile()
+        )
+
+        #expect(result != nil)
+        #expect(result?.activityContrib == 0)
+        #expect(result?.iobContrib == 0)
+    }
+}

+ 459 - 0
TrioTests/OpenAPSSwiftTests/IobHistoryTests.swift

@@ -0,0 +1,459 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Calculate Temp Treatments Tests") struct CalculateTempTreatmentsTests {
+    // Helper function to create a basic basal profile
+    func createBasicBasalProfile() -> [BasalProfileEntry] {
+        [
+            BasalProfileEntry(
+                start: "00:00:00",
+                minutes: 0,
+                rate: 1
+            )
+        ]
+    }
+
+    @Test("should calculate temp basals with defaults") func calculateTempBasalsWithDefaults() async throws {
+        let basalprofile = createBasicBasalProfile()
+
+        let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
+        let timestamp30mAgo = now - 30.minutesToSeconds
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: timestamp30mAgo,
+                duration: nil,
+                rate: 2,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: timestamp30mAgo,
+                durationMin: 30
+            )
+        ]
+
+        var profile = Profile()
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = false
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Filter temp basals (excluding zero temps)
+        let tempBasals = treatments.filter { $0.rate != nil }
+
+        // Test expected number of temp basals
+        #expect(tempBasals.count == 2) // Original temp plus split zero temps
+
+        // First entry should be actual temp basal
+        #expect(tempBasals[0].rate == 2)
+        #expect(tempBasals[0].duration == 30)
+
+        // Following entries should be zero temps
+        #expect(tempBasals[1].rate == 0)
+        #expect(tempBasals[1].duration == 0)
+
+        // 30m at 2 U/h - 1U/h -> 0.5U
+        #expect(treatments.netInsulin().isWithin(0.01, of: 0.5))
+    }
+
+    @Test("should handle overlapping temp basals") func handleOverlappingTempBasals() async throws {
+        let basalprofile = createBasicBasalProfile()
+
+        let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
+        let timestamp30mAgo = now - 30.minutesToSeconds
+        let timestamp15mAgo = now - 15.minutesToSeconds
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: timestamp30mAgo,
+                duration: nil,
+                rate: 2,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: timestamp30mAgo,
+                durationMin: 30
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: timestamp15mAgo,
+                durationMin: nil,
+                rate: 3,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: timestamp15mAgo,
+                durationMin: 30
+            )
+        ]
+
+        var profile = Profile()
+        profile.dia = 3
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = false
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Get only non-zero temp basals
+        let tempBasals = treatments.filter { ($0.rate ?? 0) > 0 && ($0.duration ?? 0) > 0 }
+        #expect(tempBasals.count == 2)
+        #expect(tempBasals[0].rate == 2)
+        #expect(tempBasals[0].duration == 15)
+        #expect(tempBasals[1].rate == 3)
+        #expect(tempBasals[1].duration == 16)
+
+        // in this case, the JS returns an incorrect adjusted tempBasal set
+        // so we rely on counting the basals only
+        // net 1 U/h for 15m and 2 U/h for 15m -> 0.75 U
+        // but there is buggy rounding behavior so the answer will
+        // be 0.8
+        #expect(treatments.netInsulin().isWithin(0.01, of: 0.8))
+    }
+
+    @Test("should handle pump suspends and resumes") func handlePumpSuspendsAndResumes() async throws {
+        let basalprofile = createBasicBasalProfile()
+
+        let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
+        let timestamp30mAgo = now - 30.minutesToSeconds
+        let timestamp15mAgo = now - 15.minutesToSeconds
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: timestamp30mAgo,
+                duration: nil,
+                rate: 2,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: timestamp30mAgo,
+                durationMin: 30
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: timestamp15mAgo
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: now
+            )
+        ]
+
+        var profile = Profile()
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.suspendZerosIob = true
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Original temp should exist but be shortened
+        let origTemp = treatments.first { $0.rate == 2 }
+        #expect(origTemp != nil)
+        #expect(origTemp?.duration == 15)
+
+        // 15m at 2U/h - 1U/h -> 0.25U
+        // 15m at 0U/h - 1U/h -> -0.25U
+        // Total: 0
+        #expect(treatments.netInsulin().isWithin(0.01, of: 0))
+    }
+
+    @Test("should handle basal profile changes") func handleBasalProfileChanges() async throws {
+        let basalprofile = [
+            BasalProfileEntry(
+                start: "00:00:00",
+                minutes: 0,
+                rate: 1
+            ),
+            BasalProfileEntry(
+                start: "00:30:00",
+                minutes: 30,
+                rate: 2
+            )
+        ]
+
+        let startingPoint = Calendar.current.startOfDay(for: Date())
+        let endingPoint = startingPoint + 45.minutesToSeconds
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: startingPoint,
+                duration: nil,
+                rate: 3,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: startingPoint,
+                durationMin: 60
+            )
+        ]
+        var profile = Profile()
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 2
+        profile.suspendZerosIob = false
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: endingPoint,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        let tempBasals = treatments.filter { ($0.rate ?? 0) != 0 && ($0.duration ?? 0) > 0 }
+        #expect(!tempBasals.isEmpty)
+
+        // Should split temp basal at profile change
+        // Note: This is a little different from JS since we use the split output
+        // and we divide up one tempbasal into two, but it should end up with the
+        // same result for IoB
+        #expect(tempBasals[0].rate == 3)
+
+        // 30m at 3 U/h - 1 U/h -> 1U
+        // 15m at 3 U/h - 2 U/h - 0.25U
+        // 1.25U total
+        print(treatments.prettyPrintedJSON!)
+        #expect(treatments.netInsulin().isWithin(0.01, of: 1.25))
+    }
+
+    @Test("should properly record boluses") func properlyRecordBoluses() async throws {
+        let basalprofile = createBasicBasalProfile()
+        let now = Calendar.current.startOfDay(for: Date())
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .bolus,
+                timestamp: now,
+                amount: 2
+            )
+        ]
+
+        var profile = Profile()
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.suspendZerosIob = false
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        let boluses = treatments.filter { $0.insulin != nil }
+        #expect(boluses.count == 1)
+        #expect(boluses[0].insulin == 2)
+    }
+
+    @Test("should add zero temp with specified duration") func addZeroTempWithSpecifiedDuration() async throws {
+        let basalprofile = createBasicBasalProfile()
+
+        let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
+        let timestamp30mAgo = now - 30.minutesToSeconds
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: timestamp30mAgo,
+                duration: nil,
+                rate: 2,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: timestamp30mAgo,
+                durationMin: 30
+            )
+        ]
+
+        var profile = Profile()
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.suspendZerosIob = false
+
+        // Test with 120 min zero temp duration
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: 120
+        )
+
+        // Get only the zero temps
+        let zeroTemps = treatments.filter { ($0.rate ?? 0) == 0 && ($0.duration ?? 0) > 0 }
+        #expect(!zeroTemps.isEmpty)
+        #expect(!zeroTemps.isEmpty)
+
+        // Verify zero temp has correct duration
+        let duration = zeroTemps.map({ $0.duration! }).reduce(0, +)
+        #expect(duration == 120)
+
+        // Verify zero temp starts 1 min in future
+        let expectedStart = now + 60 // 1 minute in future
+        #expect(zeroTemps[0].timestamp == expectedStart)
+
+        // 30m at 2U/h - 1U/h -> 0.5
+        // 120m at 0U/h - 1U/h -> -2.0
+        // Total -> -1.5U
+        #expect(treatments.netInsulin().isWithin(0.01, of: -1.5))
+    }
+
+    @Test("should handle zero temp with basal profile changes") func handleZeroTempWithBasalProfileChanges() async throws {
+        let basalprofile = [
+            BasalProfileEntry(
+                start: "00:00:00",
+                minutes: 0,
+                rate: 1
+            ),
+            BasalProfileEntry(
+                start: "00:30:00",
+                minutes: 30,
+                rate: 2
+            )
+        ]
+
+        let startingPoint = Calendar.current.startOfDay(for: Date())
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: startingPoint,
+                duration: nil,
+                rate: 3,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: startingPoint,
+                durationMin: 60
+            )
+        ]
+
+        var profile = Profile()
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 2
+        profile.suspendZerosIob = false
+
+        // Test with 90 min zero temp duration
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: startingPoint + 60.minutesToSeconds,
+            autosens: nil,
+            zeroTempDuration: 90
+        )
+
+        // Get zero temps
+        let zeroTemps = treatments.filter { ($0.rate ?? 0) == 0 && ($0.duration ?? 0) > 0 }
+        #expect(!zeroTemps.isEmpty)
+
+        // Verify zero temp duration
+        let duration = zeroTemps.map({ $0.duration! }).reduce(0, +)
+        #expect(duration == 90)
+        let expectedStart = startingPoint + 61.minutesToSeconds // 1 minute in future
+        #expect(zeroTemps[0].timestamp == expectedStart)
+
+        // 30m at 3U/h - 1U/h -> 1U
+        // 30m at 3U/h - 2U/h -> 0.5U
+        // 90m at 0U/h - 2U/h -> -3U
+        // Total: -1.5U
+        #expect(treatments.netInsulin().isWithin(0.01, of: -1.5))
+    }
+
+    @Test("should add zero temp when suspended") func addZeroTempWhenSuspended() async throws {
+        let basalprofile = createBasicBasalProfile()
+
+        let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
+        let timestamp30mAgo = now - 30.minutesToSeconds
+        let timestamp15mAgo = now - 15.minutesToSeconds
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: timestamp30mAgo,
+                duration: nil,
+                rate: 2,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: timestamp30mAgo,
+                durationMin: 30
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: timestamp15mAgo
+            )
+        ]
+
+        var profile = Profile()
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.suspendZerosIob = true
+
+        // Test with 60 min zero temp duration
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: 60
+        )
+
+        let tempBasals = treatments.filter { $0.type == .tempBasal }
+        #expect(tempBasals[0].duration == 15)
+        #expect(tempBasals[0].timestamp == timestamp30mAgo)
+        #expect(tempBasals[0].rate == 2)
+
+        // 15m at 2U/h - 1U/h -> 0.25U
+        // 15m at 0U/h - 1U/h -> -0.25U
+        // 60m at 0U/h - 1U/h -> -1
+        // Total: -1U
+        #expect(treatments.netInsulin().isWithin(0.01, of: -1))
+    }
+}

+ 108 - 0
TrioTests/OpenAPSSwiftTests/IobJsonTests.swift

@@ -0,0 +1,108 @@
+import Foundation
+import Testing
+@testable import Trio
+
+class BundleReference {}
+
+@Suite("IoB using real pump history JSON") struct IobJsonTests {
+    @Test("should produce the same JSON IobResult as Javascript") func createIobResultFromJson() async throws {
+        let testBundle = Bundle(for: BundleReference.self)
+        guard let path = testBundle.path(forResource: "pump_history", ofType: "json"),
+              let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
+              let pumpHistory: [PumpHistoryEvent] = try! JSONBridge.from(string: String(data: data, encoding: .utf8)!),
+              let path2 = testBundle.path(forResource: "iob_result", ofType: "json"),
+              let data2 = try? Data(contentsOf: URL(fileURLWithPath: path2)),
+              let iobResultsJson: [IobResult] = try! JSONBridge.from(string: String(data: data2, encoding: .utf8)!)
+        else {
+            #expect(Bool(false))
+            return
+        }
+
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 0.5)
+        ]
+
+        var profile = Profile()
+        profile.dia = 10
+        profile.basalprofile = basalProfile
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.curve = .ultraRapid
+
+        let clock = Date("2025-02-18T23:23:31.036Z")!
+
+        let iobResult = try IobGenerator.generate(history: pumpHistory, profile: profile, clock: clock, autosens: nil)
+
+        #expect(iobResult.count == iobResultsJson.count)
+        for (swift, javascript) in zip(iobResult, iobResultsJson) {
+            #expect(swift.approximatelyEquals(javascript))
+        }
+    }
+
+    @Test("should produce the same JSON history as Javascript") func createIobHistoryFromJson() async throws {
+        let testBundle = Bundle(for: BundleReference.self)
+        guard let path = testBundle.path(forResource: "pump_history", ofType: "json"),
+              let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
+              let path2 = testBundle.path(forResource: "iob_history", ofType: "json"),
+              let data2 = try? Data(contentsOf: URL(fileURLWithPath: path2)),
+              let pumpHistory: [PumpHistoryEvent] = try! JSONBridge.from(string: String(data: data, encoding: .utf8)!),
+              let iobHistoryJson: [HistoryRecord] = try! JSONBridge.from(string: String(data: data2, encoding: .utf8)!)
+        else {
+            #expect(Bool(false))
+            return
+        }
+
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 0.5)
+        ]
+
+        var profile = Profile()
+        profile.dia = 10
+        profile.basalprofile = basalProfile
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.curve = .ultraRapid
+
+        let clock = Date("2025-02-18T23:23:31.036Z")!
+
+        let computedHistory = pumpHistory.map { $0.computedEvent() }
+
+        let history = try IobHistory.calcTempTreatments(
+            history: computedHistory,
+            profile: profile,
+            clock: clock,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        #expect(history.count == iobHistoryJson.count)
+        let historyBolusCount = history.filter({ $0.insulin != nil }).count
+        let jsonBolusCount = iobHistoryJson.filter({ record in
+            switch record {
+            case .insulin: return true
+            case .basal: return false
+            }
+        }).count
+        #expect(historyBolusCount == jsonBolusCount)
+
+        let historyBasalCount = history.filter({ $0.rate != nil }).count
+        let jsonBasalCount = iobHistoryJson.filter({ record in
+            switch record {
+            case .insulin: return false
+            case .basal: return true
+            }
+        }).count
+        #expect(historyBasalCount == jsonBasalCount)
+
+        let historyInsulin = history.compactMap(\.insulin).reduce(0, +)
+        let jsonInsulin = iobHistoryJson.compactMap({ record in
+            switch record {
+            case let .insulin(r):
+                return r.insulin
+            case .basal:
+                return nil
+            }
+        }).reduce(0, +)
+        #expect(historyInsulin == jsonInsulin)
+    }
+}

+ 200 - 0
TrioTests/OpenAPSSwiftTests/IobTotalTests.swift

@@ -0,0 +1,200 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Calculate Total IOB Tests") struct CalculateIobTotalTests {
+    // Helper function to create a basic treatment
+    func createTreatment(insulin: Decimal, timestamp: Date) -> ComputedPumpHistoryEvent {
+        ComputedPumpHistoryEvent.forTest(
+            type: .bolus,
+            timestamp: timestamp,
+            insulin: insulin
+        )
+    }
+
+    // Helper function to create a basic profile
+    func createProfile(
+        dia: Decimal = 5,
+        curve: InsulinCurve = .rapidActing,
+        useCustomPeakTime: Bool = false,
+        insulinPeakTime: Decimal = 0
+    ) -> Profile {
+        var profile = Profile()
+        profile.curve = curve
+        profile.useCustomPeakTime = useCustomPeakTime
+        profile.insulinPeakTime = insulinPeakTime
+        profile.dia = dia
+        return profile
+    }
+
+    @Test("should return zero values when no treatments provided") func returnZeroForNoTreatments() async throws {
+        let now = Date()
+        let result = try IobCalculation.iobTotal(
+            treatments: [],
+            profile: createProfile(),
+            time: now
+        )
+
+        #expect(result.iob == 0)
+        #expect(result.activity == 0)
+        #expect(result.basaliob == 0)
+        #expect(result.bolusiob == 0)
+    }
+
+    @Test("should calculate total IOB with rapid-acting insulin bolus") func calculateTotalRapidActing() async throws {
+        let now = Date()
+        let thirtyMinsAgo = now - 30.minutesToSeconds
+        let treatments = [createTreatment(insulin: 2, timestamp: thirtyMinsAgo)]
+
+        let result = try IobCalculation.iobTotal(
+            treatments: treatments,
+            profile: createProfile(dia: 5, curve: .rapidActing),
+            time: now
+        )
+
+        #expect(result.iob.isWithin(0.1, of: 1.8))
+        #expect(result.bolusiob.isWithin(0.1, of: 1.8))
+        #expect(result.basaliob == 0)
+    }
+
+    @Test("should calculate total IOB with ultra-rapid insulin bolus") func calculateTotalUltraRapid() async throws {
+        let now = Date()
+        let thirtyMinsAgo = now - 30.minutesToSeconds
+        let treatments = [createTreatment(insulin: 2, timestamp: thirtyMinsAgo)]
+
+        let result = try IobCalculation.iobTotal(
+            treatments: treatments,
+            profile: createProfile(dia: 5, curve: .ultraRapid),
+            time: now
+        )
+
+        #expect(result.iob.isWithin(0.001, of: 1.769))
+        #expect(result.bolusiob.isWithin(0.001, of: 1.769))
+        #expect(result.basaliob == 0)
+    }
+
+    @Test("should calculate total IOB with basal insulin") func calculateTotalBasal() async throws {
+        let now = Date()
+        let thirtyMinsAgo = now - 30.minutesToSeconds
+        let treatments = [createTreatment(insulin: -0.05, timestamp: thirtyMinsAgo)]
+
+        let result = try IobCalculation.iobTotal(
+            treatments: treatments,
+            profile: createProfile(dia: 5, curve: .rapidActing),
+            time: now
+        )
+
+        #expect(result.basaliob.isWithin(0.001, of: -0.046))
+        #expect(result.bolusiob == 0)
+    }
+
+    @Test("should handle multiple treatments of different types") func handleMultipleTreatments() async throws {
+        let now = Date()
+        let treatments = [
+            createTreatment(insulin: 2.0, timestamp: now - 30.minutesToSeconds),
+            createTreatment(insulin: 0.05, timestamp: now - 20.minutesToSeconds),
+            createTreatment(insulin: 1.0, timestamp: now - 10.minutesToSeconds)
+        ]
+
+        let result = try IobCalculation.iobTotal(
+            treatments: treatments,
+            profile: createProfile(dia: 5, curve: .rapidActing),
+            time: now
+        )
+
+        #expect(result.basaliob.isWithin(0.001, of: 0.048))
+        #expect(result.bolusinsulin == 3.0)
+        #expect(result.netbasalinsulin == 0.05)
+    }
+
+    @Test("should handle custom peak times for rapid-acting insulin") func handleCustomPeakRapidActing() async throws {
+        let now = Date()
+        let thirtyMinsAgo = now - 30.minutesToSeconds
+        let treatments = [createTreatment(insulin: 2.0, timestamp: thirtyMinsAgo)]
+
+        let result = try IobCalculation.iobTotal(
+            treatments: treatments,
+            profile: createProfile(
+                dia: 5,
+                curve: .rapidActing,
+                useCustomPeakTime: true,
+                insulinPeakTime: 100
+            ),
+            time: now
+        )
+
+        #expect(result.iob.isWithin(0.001, of: 1.898))
+    }
+
+    @Test("should handle custom peak times for ultra-rapid insulin") func handleCustomPeakUltraRapid() async throws {
+        let now = Date()
+        let thirtyMinsAgo = now - 30.minutesToSeconds
+        let treatments = [createTreatment(insulin: 2.0, timestamp: thirtyMinsAgo)]
+
+        let result = try IobCalculation.iobTotal(
+            treatments: treatments,
+            profile: createProfile(
+                dia: 5,
+                curve: .ultraRapid,
+                useCustomPeakTime: true,
+                insulinPeakTime: 80
+            ),
+            time: now
+        )
+
+        #expect(result.iob.isWithin(0.001, of: 1.863))
+    }
+
+    @Test("should ignore future treatments") func ignoreFutureTreatments() async throws {
+        let now = Date()
+        let treatments = [
+            createTreatment(insulin: 2.0, timestamp: now + 30.minutesToSeconds),
+            createTreatment(insulin: 1.0, timestamp: now - 10.minutesToSeconds)
+        ]
+
+        let result = try IobCalculation.iobTotal(
+            treatments: treatments,
+            profile: createProfile(dia: 5, curve: .rapidActing),
+            time: now
+        )
+
+        #expect(result.bolusinsulin == 1.0)
+    }
+
+    @Test("should ignore treatments older than DIA") func ignoreOldTreatments() async throws {
+        let now = Date()
+        let sixHoursAgo = now - 6.hoursToSeconds
+        let treatments = [createTreatment(insulin: 2.0, timestamp: sixHoursAgo)]
+
+        let result = try IobCalculation.iobTotal(
+            treatments: treatments,
+            profile: createProfile(dia: 5, curve: .rapidActing),
+            time: now
+        )
+
+        #expect(result.iob == 0)
+        #expect(result.activity == 0)
+    }
+
+    @Test("should enforce minimum DIA of 5 hours for both insulin types") func enforceMinimumDIA() async throws {
+        let now = Date()
+        let fourHoursAgo = now - 4.hoursToSeconds
+        let treatments = [createTreatment(insulin: 2.0, timestamp: fourHoursAgo)]
+
+        // Test rapid-acting
+        let rapidResult = try IobCalculation.iobTotal(
+            treatments: treatments,
+            profile: createProfile(dia: 4, curve: .rapidActing),
+            time: now
+        )
+        #expect(rapidResult.iob > 0)
+
+        // Test ultra-rapid
+        let ultraResult = try IobCalculation.iobTotal(
+            treatments: treatments,
+            profile: createProfile(dia: 4, curve: .ultraRapid),
+            time: now
+        )
+        #expect(ultraResult.iob > 0)
+    }
+}

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


+ 874 - 0
TrioTests/OpenAPSSwiftTests/json/iob_result.json

@@ -0,0 +1,874 @@
+[
+  {
+    "iob": 0.539,
+    "activity": 0.0046,
+    "basaliob": 0.149,
+    "bolusiob": 0.39,
+    "netbasalinsulin": -1.75,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-18T23:23:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": 0.539,
+      "activity": 0.0046,
+      "basaliob": 0.149,
+      "bolusiob": 0.39,
+      "netbasalinsulin": -1.75,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-18T23:23:31.036Z"
+    },
+    "lastBolusTime": 1739920771036,
+    "lastTemp": {
+      "rate": 0,
+      "timestamp": "2025-02-18T23:12:39.306Z",
+      "started_at": "2025-02-18T23:12:39.306Z",
+      "date": 1739920359306,
+      "duration": 11.86
+    }
+  },
+  {
+    "iob": 0.516,
+    "activity": 0.0047,
+    "basaliob": 0.142,
+    "bolusiob": 0.374,
+    "netbasalinsulin": -1.7,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-18T23:28:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": 0.466,
+      "activity": 0.0046,
+      "basaliob": 0.092,
+      "bolusiob": 0.374,
+      "netbasalinsulin": -1.75,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-18T23:28:31.036Z"
+    }
+  },
+  {
+    "iob": 0.492,
+    "activity": 0.0047,
+    "basaliob": 0.135,
+    "bolusiob": 0.358,
+    "netbasalinsulin": -1.65,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-18T23:33:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": 0.393,
+      "activity": 0.0045,
+      "basaliob": 0.036,
+      "bolusiob": 0.358,
+      "netbasalinsulin": -1.75,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-18T23:33:31.036Z"
+    }
+  },
+  {
+    "iob": 0.469,
+    "activity": 0.0047,
+    "basaliob": 0.128,
+    "bolusiob": 0.341,
+    "netbasalinsulin": -1.65,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-18T23:38:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": 0.321,
+      "activity": 0.0043,
+      "basaliob": -0.02,
+      "bolusiob": 0.341,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-18T23:38:31.036Z"
+    }
+  },
+  {
+    "iob": 0.446,
+    "activity": 0.0046,
+    "basaliob": 0.121,
+    "bolusiob": 0.325,
+    "netbasalinsulin": -1.6,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-18T23:43:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": 0.25,
+      "activity": 0.0041,
+      "basaliob": -0.075,
+      "bolusiob": 0.325,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-18T23:43:31.036Z"
+    }
+  },
+  {
+    "iob": 0.423,
+    "activity": 0.0045,
+    "basaliob": 0.115,
+    "bolusiob": 0.308,
+    "netbasalinsulin": -1.55,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-18T23:48:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": 0.18,
+      "activity": 0.0038,
+      "basaliob": -0.128,
+      "bolusiob": 0.308,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-18T23:48:31.036Z"
+    }
+  },
+  {
+    "iob": 0.401,
+    "activity": 0.0044,
+    "basaliob": 0.108,
+    "bolusiob": 0.292,
+    "netbasalinsulin": -1.55,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-18T23:53:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": 0.162,
+      "activity": 0.0034,
+      "basaliob": -0.13,
+      "bolusiob": 0.292,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-18T23:53:31.036Z"
+    }
+  },
+  {
+    "iob": 0.379,
+    "activity": 0.0043,
+    "basaliob": 0.102,
+    "bolusiob": 0.277,
+    "netbasalinsulin": -1.5,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-18T23:58:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": 0.096,
+      "activity": 0.003,
+      "basaliob": -0.181,
+      "bolusiob": 0.277,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-18T23:58:31.036Z"
+    }
+  },
+  {
+    "iob": 0.358,
+    "activity": 0.0041,
+    "basaliob": 0.096,
+    "bolusiob": 0.262,
+    "netbasalinsulin": -1.45,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:03:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": 0.032,
+      "activity": 0.0026,
+      "basaliob": -0.23,
+      "bolusiob": 0.262,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:03:31.036Z"
+    }
+  },
+  {
+    "iob": 0.338,
+    "activity": 0.004,
+    "basaliob": 0.091,
+    "bolusiob": 0.247,
+    "netbasalinsulin": -1.4,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:08:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.03,
+      "activity": 0.0022,
+      "basaliob": -0.277,
+      "bolusiob": 0.247,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:08:31.036Z"
+    }
+  },
+  {
+    "iob": 0.318,
+    "activity": 0.0038,
+    "basaliob": 0.085,
+    "bolusiob": 0.233,
+    "netbasalinsulin": -1.35,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:13:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.09,
+      "activity": 0.0018,
+      "basaliob": -0.323,
+      "bolusiob": 0.233,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:13:31.036Z"
+    }
+  },
+  {
+    "iob": 0.299,
+    "activity": 0.0037,
+    "basaliob": 0.08,
+    "bolusiob": 0.219,
+    "netbasalinsulin": -1.3,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:18:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.148,
+      "activity": 0.0013,
+      "basaliob": -0.367,
+      "bolusiob": 0.219,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:18:31.036Z"
+    }
+  },
+  {
+    "iob": 0.281,
+    "activity": 0.0035,
+    "basaliob": 0.075,
+    "bolusiob": 0.206,
+    "netbasalinsulin": -1.3,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:23:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.153,
+      "activity": 0.0009,
+      "basaliob": -0.359,
+      "bolusiob": 0.206,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:23:31.036Z"
+    }
+  },
+  {
+    "iob": 0.264,
+    "activity": 0.0034,
+    "basaliob": 0.071,
+    "bolusiob": 0.194,
+    "netbasalinsulin": -1.25,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:28:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.206,
+      "activity": 0.0004,
+      "basaliob": -0.4,
+      "bolusiob": 0.194,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:28:31.036Z"
+    }
+  },
+  {
+    "iob": 0.248,
+    "activity": 0.0032,
+    "basaliob": 0.066,
+    "bolusiob": 0.182,
+    "netbasalinsulin": -1.2,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:33:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.258,
+      "activity": 0,
+      "basaliob": -0.439,
+      "bolusiob": 0.182,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:33:31.036Z"
+    }
+  },
+  {
+    "iob": 0.232,
+    "activity": 0.0031,
+    "basaliob": 0.062,
+    "bolusiob": 0.17,
+    "netbasalinsulin": -1.15,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:38:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.307,
+      "activity": -0.0004,
+      "basaliob": -0.477,
+      "bolusiob": 0.17,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:38:31.036Z"
+    }
+  },
+  {
+    "iob": 0.217,
+    "activity": 0.0029,
+    "basaliob": 0.058,
+    "bolusiob": 0.159,
+    "netbasalinsulin": -1.1,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:43:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.353,
+      "activity": -0.0008,
+      "basaliob": -0.513,
+      "bolusiob": 0.159,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:43:31.036Z"
+    }
+  },
+  {
+    "iob": 0.203,
+    "activity": 0.0027,
+    "basaliob": 0.054,
+    "bolusiob": 0.149,
+    "netbasalinsulin": -1.1,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:48:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.398,
+      "activity": -0.0012,
+      "basaliob": -0.547,
+      "bolusiob": 0.149,
+      "netbasalinsulin": -1.85,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:48:31.036Z"
+    }
+  },
+  {
+    "iob": 0.19,
+    "activity": 0.0026,
+    "basaliob": 0.05,
+    "bolusiob": 0.139,
+    "netbasalinsulin": -1.05,
+    "bolusinsulin": 7.25,
+    "time": "2025-02-19T00:53:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.391,
+      "activity": -0.0016,
+      "basaliob": -0.53,
+      "bolusiob": 0.139,
+      "netbasalinsulin": -1.8,
+      "bolusinsulin": 7.25,
+      "time": "2025-02-19T00:53:31.036Z"
+    }
+  },
+  {
+    "iob": 0.177,
+    "activity": 0.0025,
+    "basaliob": 0.047,
+    "bolusiob": 0.13,
+    "netbasalinsulin": -1.1,
+    "bolusinsulin": 6.65,
+    "time": "2025-02-19T00:58:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.432,
+      "activity": -0.002,
+      "basaliob": -0.562,
+      "bolusiob": 0.13,
+      "netbasalinsulin": -1.9,
+      "bolusinsulin": 6.65,
+      "time": "2025-02-19T00:58:31.036Z"
+    }
+  },
+  {
+    "iob": 0.165,
+    "activity": 0.0023,
+    "basaliob": 0.044,
+    "bolusiob": 0.122,
+    "netbasalinsulin": -1.25,
+    "bolusinsulin": 6.25,
+    "time": "2025-02-19T01:03:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.471,
+      "activity": -0.0024,
+      "basaliob": -0.592,
+      "bolusiob": 0.122,
+      "netbasalinsulin": -2.1,
+      "bolusinsulin": 6.25,
+      "time": "2025-02-19T01:03:31.036Z"
+    }
+  },
+  {
+    "iob": 0.154,
+    "activity": 0.0022,
+    "basaliob": 0.041,
+    "bolusiob": 0.113,
+    "netbasalinsulin": -1.25,
+    "bolusinsulin": 6.25,
+    "time": "2025-02-19T01:08:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.508,
+      "activity": -0.0027,
+      "basaliob": -0.621,
+      "bolusiob": 0.113,
+      "netbasalinsulin": -2.15,
+      "bolusinsulin": 6.25,
+      "time": "2025-02-19T01:08:31.036Z"
+    }
+  },
+  {
+    "iob": 0.144,
+    "activity": 0.0021,
+    "basaliob": 0.038,
+    "bolusiob": 0.106,
+    "netbasalinsulin": -1.3,
+    "bolusinsulin": 6.25,
+    "time": "2025-02-19T01:13:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.544,
+      "activity": -0.0031,
+      "basaliob": -0.649,
+      "bolusiob": 0.106,
+      "netbasalinsulin": -2.25,
+      "bolusinsulin": 6.25,
+      "time": "2025-02-19T01:13:31.036Z"
+    }
+  },
+  {
+    "iob": 0.134,
+    "activity": 0.0019,
+    "basaliob": 0.035,
+    "bolusiob": 0.098,
+    "netbasalinsulin": -1.35,
+    "bolusinsulin": 6.25,
+    "time": "2025-02-19T01:18:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.577,
+      "activity": -0.0034,
+      "basaliob": -0.676,
+      "bolusiob": 0.098,
+      "netbasalinsulin": -2.35,
+      "bolusinsulin": 6.25,
+      "time": "2025-02-19T01:18:31.036Z"
+    }
+  },
+  {
+    "iob": 0.124,
+    "activity": 0.0018,
+    "basaliob": 0.033,
+    "bolusiob": 0.091,
+    "netbasalinsulin": -1.35,
+    "bolusinsulin": 5.65,
+    "time": "2025-02-19T01:23:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.56,
+      "activity": -0.0037,
+      "basaliob": -0.651,
+      "bolusiob": 0.091,
+      "netbasalinsulin": -2.35,
+      "bolusinsulin": 5.65,
+      "time": "2025-02-19T01:23:31.036Z"
+    }
+  },
+  {
+    "iob": 0.115,
+    "activity": 0.0017,
+    "basaliob": 0.03,
+    "bolusiob": 0.085,
+    "netbasalinsulin": -1.3,
+    "bolusinsulin": 5.25,
+    "time": "2025-02-19T01:28:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.59,
+      "activity": -0.004,
+      "basaliob": -0.675,
+      "bolusiob": 0.085,
+      "netbasalinsulin": -2.35,
+      "bolusinsulin": 5.25,
+      "time": "2025-02-19T01:28:31.036Z"
+    }
+  },
+  {
+    "iob": 0.107,
+    "activity": 0.0016,
+    "basaliob": 0.028,
+    "bolusiob": 0.079,
+    "netbasalinsulin": -1.25,
+    "bolusinsulin": 4.75,
+    "time": "2025-02-19T01:33:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.62,
+      "activity": -0.0043,
+      "basaliob": -0.699,
+      "bolusiob": 0.079,
+      "netbasalinsulin": -2.35,
+      "bolusinsulin": 4.75,
+      "time": "2025-02-19T01:33:31.036Z"
+    }
+  },
+  {
+    "iob": 0.1,
+    "activity": 0.0015,
+    "basaliob": 0.026,
+    "bolusiob": 0.073,
+    "netbasalinsulin": -1.2,
+    "bolusinsulin": 4.3,
+    "time": "2025-02-19T01:38:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.648,
+      "activity": -0.0045,
+      "basaliob": -0.721,
+      "bolusiob": 0.073,
+      "netbasalinsulin": -2.35,
+      "bolusinsulin": 4.3,
+      "time": "2025-02-19T01:38:31.036Z"
+    }
+  },
+  {
+    "iob": 0.092,
+    "activity": 0.0014,
+    "basaliob": 0.024,
+    "bolusiob": 0.068,
+    "netbasalinsulin": -1.15,
+    "bolusinsulin": 3.85,
+    "time": "2025-02-19T01:43:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.674,
+      "activity": -0.0048,
+      "basaliob": -0.742,
+      "bolusiob": 0.068,
+      "netbasalinsulin": -2.35,
+      "bolusinsulin": 3.85,
+      "time": "2025-02-19T01:43:31.036Z"
+    }
+  },
+  {
+    "iob": 0.086,
+    "activity": 0.0013,
+    "basaliob": 0.023,
+    "bolusiob": 0.063,
+    "netbasalinsulin": -1.1,
+    "bolusinsulin": 3.25,
+    "time": "2025-02-19T01:48:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.7,
+      "activity": -0.005,
+      "basaliob": -0.763,
+      "bolusiob": 0.063,
+      "netbasalinsulin": -2.35,
+      "bolusinsulin": 3.25,
+      "time": "2025-02-19T01:48:31.036Z"
+    }
+  },
+  {
+    "iob": 0.079,
+    "activity": 0.0012,
+    "basaliob": 0.021,
+    "bolusiob": 0.058,
+    "netbasalinsulin": -1.05,
+    "bolusinsulin": 2.75,
+    "time": "2025-02-19T01:53:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.674,
+      "activity": -0.0052,
+      "basaliob": -0.732,
+      "bolusiob": 0.058,
+      "netbasalinsulin": -2.3,
+      "bolusinsulin": 2.75,
+      "time": "2025-02-19T01:53:31.036Z"
+    }
+  },
+  {
+    "iob": 0.073,
+    "activity": 0.0011,
+    "basaliob": 0.019,
+    "bolusiob": 0.054,
+    "netbasalinsulin": -1,
+    "bolusinsulin": 2.5,
+    "time": "2025-02-19T01:58:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.697,
+      "activity": -0.0055,
+      "basaliob": -0.751,
+      "bolusiob": 0.054,
+      "netbasalinsulin": -2.3,
+      "bolusinsulin": 2.5,
+      "time": "2025-02-19T01:58:31.036Z"
+    }
+  },
+  {
+    "iob": 0.068,
+    "activity": 0.0011,
+    "basaliob": 0.018,
+    "bolusiob": 0.05,
+    "netbasalinsulin": -0.95,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:03:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.72,
+      "activity": -0.0057,
+      "basaliob": -0.77,
+      "bolusiob": 0.05,
+      "netbasalinsulin": -2.3,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:03:31.036Z"
+    }
+  },
+  {
+    "iob": 0.063,
+    "activity": 0.001,
+    "basaliob": 0.017,
+    "bolusiob": 0.046,
+    "netbasalinsulin": -0.9,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:08:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.741,
+      "activity": -0.0058,
+      "basaliob": -0.787,
+      "bolusiob": 0.046,
+      "netbasalinsulin": -2.3,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:08:31.036Z"
+    }
+  },
+  {
+    "iob": 0.058,
+    "activity": 0.0009,
+    "basaliob": 0.015,
+    "bolusiob": 0.043,
+    "netbasalinsulin": -0.85,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:13:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.761,
+      "activity": -0.006,
+      "basaliob": -0.804,
+      "bolusiob": 0.043,
+      "netbasalinsulin": -2.3,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:13:31.036Z"
+    }
+  },
+  {
+    "iob": 0.054,
+    "activity": 0.0009,
+    "basaliob": 0.014,
+    "bolusiob": 0.04,
+    "netbasalinsulin": -0.8,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:18:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.781,
+      "activity": -0.0062,
+      "basaliob": -0.82,
+      "bolusiob": 0.04,
+      "netbasalinsulin": -2.3,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:18:31.036Z"
+    }
+  },
+  {
+    "iob": 0.049,
+    "activity": 0.0008,
+    "basaliob": 0.013,
+    "bolusiob": 0.036,
+    "netbasalinsulin": -0.75,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:23:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.749,
+      "activity": -0.0063,
+      "basaliob": -0.786,
+      "bolusiob": 0.036,
+      "netbasalinsulin": -2.25,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:23:31.036Z"
+    }
+  },
+  {
+    "iob": 0.046,
+    "activity": 0.0007,
+    "basaliob": 0.012,
+    "bolusiob": 0.034,
+    "netbasalinsulin": -0.7,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:28:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.767,
+      "activity": -0.0065,
+      "basaliob": -0.801,
+      "bolusiob": 0.034,
+      "netbasalinsulin": -2.25,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:28:31.036Z"
+    }
+  },
+  {
+    "iob": 0.042,
+    "activity": 0.0007,
+    "basaliob": 0.011,
+    "bolusiob": 0.031,
+    "netbasalinsulin": -0.65,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:33:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.785,
+      "activity": -0.0066,
+      "basaliob": -0.816,
+      "bolusiob": 0.031,
+      "netbasalinsulin": -2.25,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:33:31.036Z"
+    }
+  },
+  {
+    "iob": 0.039,
+    "activity": 0.0006,
+    "basaliob": 0.01,
+    "bolusiob": 0.029,
+    "netbasalinsulin": -0.6,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:38:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.801,
+      "activity": -0.0067,
+      "basaliob": -0.83,
+      "bolusiob": 0.029,
+      "netbasalinsulin": -2.25,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:38:31.036Z"
+    }
+  },
+  {
+    "iob": 0.036,
+    "activity": 0.0006,
+    "basaliob": 0.009,
+    "bolusiob": 0.026,
+    "netbasalinsulin": -0.55,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:43:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.817,
+      "activity": -0.0069,
+      "basaliob": -0.844,
+      "bolusiob": 0.026,
+      "netbasalinsulin": -2.25,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:43:31.036Z"
+    }
+  },
+  {
+    "iob": 0.033,
+    "activity": 0.0005,
+    "basaliob": 0.009,
+    "bolusiob": 0.024,
+    "netbasalinsulin": -0.5,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:48:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.833,
+      "activity": -0.007,
+      "basaliob": -0.857,
+      "bolusiob": 0.024,
+      "netbasalinsulin": -2.25,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:48:31.036Z"
+    }
+  },
+  {
+    "iob": 0.03,
+    "activity": 0.0005,
+    "basaliob": 0.008,
+    "bolusiob": 0.022,
+    "netbasalinsulin": -0.45,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:53:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.798,
+      "activity": -0.0071,
+      "basaliob": -0.82,
+      "bolusiob": 0.022,
+      "netbasalinsulin": -2.2,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:53:31.036Z"
+    }
+  },
+  {
+    "iob": 0.028,
+    "activity": 0.0005,
+    "basaliob": 0.007,
+    "bolusiob": 0.021,
+    "netbasalinsulin": -0.4,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T02:58:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.812,
+      "activity": -0.0072,
+      "basaliob": -0.833,
+      "bolusiob": 0.021,
+      "netbasalinsulin": -2.2,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T02:58:31.036Z"
+    }
+  },
+  {
+    "iob": 0.026,
+    "activity": 0.0004,
+    "basaliob": 0.007,
+    "bolusiob": 0.019,
+    "netbasalinsulin": -0.35,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T03:03:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.826,
+      "activity": -0.0073,
+      "basaliob": -0.845,
+      "bolusiob": 0.019,
+      "netbasalinsulin": -2.2,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T03:03:31.036Z"
+    }
+  },
+  {
+    "iob": 0.023,
+    "activity": 0.0004,
+    "basaliob": 0.006,
+    "bolusiob": 0.017,
+    "netbasalinsulin": -0.3,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T03:08:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.84,
+      "activity": -0.0073,
+      "basaliob": -0.857,
+      "bolusiob": 0.017,
+      "netbasalinsulin": -2.2,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T03:08:31.036Z"
+    }
+  },
+  {
+    "iob": 0.022,
+    "activity": 0.0004,
+    "basaliob": 0.006,
+    "bolusiob": 0.016,
+    "netbasalinsulin": -0.25,
+    "bolusinsulin": 2.3,
+    "time": "2025-02-19T03:13:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.853,
+      "activity": -0.0074,
+      "basaliob": -0.869,
+      "bolusiob": 0.016,
+      "netbasalinsulin": -2.2,
+      "bolusinsulin": 2.3,
+      "time": "2025-02-19T03:13:31.036Z"
+    }
+  },
+  {
+    "iob": 0.02,
+    "activity": 0.0003,
+    "basaliob": 0.005,
+    "bolusiob": 0.015,
+    "netbasalinsulin": -0.2,
+    "bolusinsulin": 2.05,
+    "time": "2025-02-19T03:18:31.036Z",
+    "iobWithZeroTemp": {
+      "iob": -0.866,
+      "activity": -0.0075,
+      "basaliob": -0.88,
+      "bolusiob": 0.015,
+      "netbasalinsulin": -2.2,
+      "bolusinsulin": 2.05,
+      "time": "2025-02-19T03:18:31.036Z"
+    }
+  }
+]

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


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

@@ -0,0 +1,73 @@
+import Foundation
+@testable import Trio
+
+extension [ComputedPumpHistoryEvent] {
+    func netInsulin() -> Decimal { compactMap(\.insulin).reduce(0, +) }
+}
+
+extension Decimal {
+    func isWithin(_ error: Decimal, of value: Decimal) -> Bool {
+        (self - value).magnitude <= error
+    }
+}
+
+extension Encodable {
+    var prettyPrintedJSON: String? {
+        let encoder = JSONCoding.encoder
+        encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]
+
+        do {
+            let data = try encoder.encode(self)
+            return String(data: data, encoding: .utf8)
+        } catch {
+            return nil
+        }
+    }
+}
+
+extension IobResult {
+    func approximatelyEquals(_ rhs: IobResult) -> Bool {
+        // Compare all properties
+        guard iob.isWithin(0.001, of: rhs.iob),
+              activity.isWithin(0.0001, of: rhs.activity),
+              basaliob.isWithin(0.001, of: rhs.basaliob),
+              bolusiob.isWithin(0.001, of: rhs.bolusiob),
+              netbasalinsulin.isWithin(0.05, of: rhs.netbasalinsulin),
+              bolusinsulin.isWithin(0.001, of: rhs.bolusinsulin),
+              time == rhs.time,
+              lastBolusTime == rhs.lastBolusTime
+        else {
+            return false
+        }
+
+        // Compare nested IobWithZeroTemp
+        guard iobWithZeroTemp.iob.isWithin(0.001, of: rhs.iobWithZeroTemp.iob),
+              iobWithZeroTemp.activity.isWithin(0.0001, of: rhs.iobWithZeroTemp.activity),
+              iobWithZeroTemp.basaliob.isWithin(0.001, of: rhs.iobWithZeroTemp.basaliob),
+              iobWithZeroTemp.bolusiob.isWithin(0.001, of: rhs.iobWithZeroTemp.bolusiob),
+              iobWithZeroTemp.netbasalinsulin.isWithin(0.05, of: rhs.iobWithZeroTemp.netbasalinsulin),
+              iobWithZeroTemp.bolusinsulin.isWithin(0.001, of: rhs.iobWithZeroTemp.bolusinsulin),
+              iobWithZeroTemp.time == rhs.iobWithZeroTemp.time
+        else {
+            return false
+        }
+
+        // Compare optional LastTemp
+        if let selfTemp = lastTemp, let rhsTemp = rhs.lastTemp {
+            guard let selfDuration = selfTemp.duration, let rhsDuration = rhsTemp.duration, selfDuration.isWithin(
+                0.01,
+                of: rhsDuration
+            ) else {
+                return false
+            }
+            // Both are non-nil, compare their properties
+            return selfTemp.rate == rhsTemp.rate &&
+                selfTemp.timestamp == rhsTemp.timestamp &&
+                selfTemp.started_at == rhsTemp.started_at &&
+                selfTemp.date == rhsTemp.date
+        } else {
+            // Both should be nil for equality
+            return lastTemp == nil && rhs.lastTemp == nil
+        }
+    }
+}

+ 162 - 0
TrioTests/OpenAPSSwiftTests/utils/IobJsonTypes.swift

@@ -0,0 +1,162 @@
+import Foundation
+@testable import Trio
+
+protocol BaseHistoryRecord {
+    var date: UInt64 { get }
+}
+
+// For insulin bolus records
+struct InsulinRecord: BaseHistoryRecord, Codable {
+    let insulin: Decimal
+    let date: UInt64
+    let created_at: Date?
+    let started_at: Date?
+    let timestamp: Date?
+
+    enum CodingKeys: String, CodingKey {
+        case insulin
+        case date
+        case created_at
+        case started_at
+        case timestamp
+    }
+
+    func mismatch(field: String, record: ComputedPumpHistoryEvent) -> Bool {
+        print("Insulin mismatch \(field) json date: \(date) swift: \(record.date)")
+        return false
+    }
+
+    func matches(record: ComputedPumpHistoryEvent) -> Bool {
+        if insulin != record.insulin {
+            return mismatch(field: "insulin", record: record)
+        }
+
+        if date != record.date, (date - 1) != record.date {
+            return mismatch(field: "date", record: record)
+        }
+
+        if let timestamp = timestamp, timestamp != record.timestamp {
+            return mismatch(field: "timestamp", record: record)
+        }
+
+        if let started_at = started_at, started_at != record.started_at {
+            return mismatch(field: "started_at", record: record)
+        }
+
+        return true
+    }
+}
+
+// For temporary basal rate records
+struct BasalRateRecord: BaseHistoryRecord, Codable {
+    let rate: Decimal
+    let timestamp: Date?
+    let started_at: Date?
+    let date: UInt64
+    let duration: Decimal
+
+    enum CodingKeys: String, CodingKey {
+        case rate
+        case timestamp
+        case started_at
+        case date
+        case duration
+    }
+
+    func mismatch(field: String, record: ComputedPumpHistoryEvent) -> Bool {
+        print("Basal mismatch \(field) json date: \(date) swift: \(record.date)")
+        return false
+    }
+
+    func matches(record: ComputedPumpHistoryEvent) -> Bool {
+        if rate != record.rate! {
+            return mismatch(field: "rate", record: record)
+        }
+
+        if date != record.date {
+            return mismatch(field: "date", record: record)
+        }
+
+        if !duration.isWithin(0.00001, of: record.duration!) {
+            return mismatch(field: "duration", record: record)
+        }
+
+        if let timestamp = timestamp, timestamp != record.timestamp {
+            return mismatch(field: "timestamp", record: record)
+        }
+
+        if let started_at = started_at, started_at != record.started_at {
+            return mismatch(field: "started_at", record: record)
+        }
+
+        return true
+    }
+}
+
+// Helper enum to handle either type of record
+enum HistoryRecord: Decodable {
+    case insulin(InsulinRecord)
+    case basal(BasalRateRecord)
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+
+        let date: UInt64
+        if let doubleDate = try? container.decode(Double.self, forKey: .date) {
+            date = UInt64(doubleDate)
+        } else {
+            date = try container.decode(UInt64.self, forKey: .date)
+        }
+
+        // If not basal, it must be insulin - check for insulin value
+        if let insulin = try? container.decode(Decimal.self, forKey: .insulin) {
+            // Handle both formats of insulin records
+            let created_at = try? container.decode(Date.self, forKey: .created_at)
+            let timestamp = try? container.decode(Date.self, forKey: .timestamp)
+            let started_at = try? container.decode(Date.self, forKey: .started_at)
+
+            self = .insulin(InsulinRecord(
+                insulin: insulin,
+                date: date,
+                created_at: created_at,
+                started_at: started_at,
+                timestamp: timestamp
+            ))
+            return
+        }
+
+        // Otherwise, try to decode as basal record
+        let rate = try container.decode(Decimal.self, forKey: .rate)
+        let timestamp = try? container.decode(Date.self, forKey: .timestamp)
+        let started_at = try? container.decode(Date.self, forKey: .started_at)
+        let duration = try container.decode(Decimal.self, forKey: .duration)
+
+        self = .basal(BasalRateRecord(
+            rate: rate,
+            timestamp: timestamp,
+            started_at: started_at,
+            date: date,
+            duration: duration
+        ))
+    }
+
+    func matches(_ event: ComputedPumpHistoryEvent) -> Bool {
+        switch self {
+        case let .insulin(record):
+            return record.matches(record: event)
+
+        case let .basal(record):
+            return record.matches(record: event)
+        }
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case insulin
+        case date
+        case created_at
+        case rate
+        case timestamp
+        case started_at
+        case duration
+    }
+}