Ver código fonte

Swift port of the autosens algorithm

In this current PR we port the Javascript autosens algorithm to
swift. The JS implementation is in a single monolithic function, so
for testing we captured JSON logs from a real device and use these in
swift to test. We added a few unit tests to cover the major cases not
included in our real logs.
Sam King 11 meses atrás
pai
commit
6819a17b9d

+ 81 - 20
Trio.xcodeproj/project.pbxproj

@@ -201,6 +201,7 @@
 		38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FB2737E53800574A46 /* MainStateModel.swift */; };
 		38FEF3FE2738083E00574A46 /* CGMSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FD2738083E00574A46 /* CGMSettingsProvider.swift */; };
 		38FEF413273B317A00574A46 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF412273B317A00574A46 /* HKUnit.swift */; };
+		3B139EF32DF06CE100D40797 /* AutosensGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B139EF22DF06CDC00D40797 /* AutosensGenerator.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 */; };
@@ -217,7 +218,6 @@
 		3B2F77862D7E52ED005ED9FA /* TDD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77852D7E52ED005ED9FA /* TDD.swift */; };
 		3B2F77882D7E5387005ED9FA /* CurrentTDDSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */; };
 		3B3B57C92DA07B3400849D16 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3B57C82DA07B3400849D16 /* GoogleService-Info.plist */; };
-		3B3B57C92DA07B3400849D16 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3B57C82DA07B3400849D16 /* GoogleService-Info.plist */; };
 		3B4196E02D8C4BC00091DFF7 /* HomeStateModel+CGM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4196DF2D8C4BBB0091DFF7 /* HomeStateModel+CGM.swift */; };
 		3B4550532D862C0000551B0D /* PumpHistoryEvent+Duplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4550522D862BF200551B0D /* PumpHistoryEvent+Duplicates.swift */; };
 		3B47C6102DA0A28F00B0E5EF /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 3B47C60F2DA0A28F00B0E5EF /* FirebaseCrashlytics */; };
@@ -278,21 +278,31 @@
 		3B5CD2CD2D4AECD500CE213C /* ProfileIsfTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C42D4AECD500CE213C /* ProfileIsfTests.swift */; };
 		3B5CD2CE2D4AECD500CE213C /* ProfileBasalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */; };
 		3B5F45B62D6A239500F70982 /* DoubleApproximateMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */; };
+		3B8B5D332DF5238000365ED3 /* as-profile.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D302DF5238000365ED3 /* as-profile.json */; };
+		3B8B5D342DF5238000365ED3 /* as-glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D2F2DF5238000365ED3 /* as-glucose.json */; };
+		3B8B5D352DF5238000365ED3 /* as-pump.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D312DF5238000365ED3 /* as-pump.json */; };
+		3B8B5D362DF5238000365ED3 /* as-carbs.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D2E2DF5238000365ED3 /* as-carbs.json */; };
+		3B8B5D372DF5238000365ED3 /* as-basal.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D2D2DF5238000365ED3 /* as-basal.json */; };
+		3B8B5D382DF5238000365ED3 /* as-temp-targets.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D322DF5238000365ED3 /* as-temp-targets.json */; };
+		3B8B5D3C2DF523C000365ED3 /* AutosensJsonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8B5D3B2DF523B800365ED3 /* AutosensJsonTests.swift */; };
+		3B8B5D3E2DF5240C00365ED3 /* TimeZoneForTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8B5D3D2DF5240600365ED3 /* TimeZoneForTests.swift */; };
+		3B8B5D402DF52D0E00365ED3 /* deviationsUnsorted.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B8B5D3F2DF52D0700365ED3 /* deviationsUnsorted.json */; };
 		3B997DCB2DC00849006B6BB2 /* JSONImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B997DCA2DC00849006B6BB2 /* JSONImporter.swift */; };
 		3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */; };
 		3B997DD32DC02AEF006B6BB2 /* glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B997DD12DC02AEF006B6BB2 /* glucose.json */; };
 		3BA8D1B32DDB87150006191F /* DecimalExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA8D1B22DDB870F0006191F /* DecimalExtensions.swift */; };
 		3BAD36B22D7CDC1A00CC298D /* MainLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */; };
 		3BAD36CC2D7D420E00CC298D /* CoreDataInitializationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */; };
+		3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BBC22622DF5B93900169236 /* AutosensTests.swift */; };
 		3BC0AA3B2DA74C87000DF7B7 /* iob-total.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BC0AA3A2DA74C87000DF7B7 /* iob-total.js */; };
 		3BC0AA3E2DA817EC000DF7B7 /* iob-calculate.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BC0AA3C2DA817EC000DF7B7 /* iob-calculate.js */; };
 		3BC0AA3F2DA817EC000DF7B7 /* iob-history.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BC0AA3D2DA817EC000DF7B7 /* iob-history.js */; };
 		3BC0AA412DA8B900000DF7B7 /* iob-history-prepare.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BC0AA402DA8B8F7000DF7B7 /* iob-history-prepare.js */; };
 		3BC26E552D7418830066ACD6 /* IobSuspendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */; };
 		3BC4053B2D931620006A03E9 /* IobJsonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC4053A2D931620006A03E9 /* IobJsonTests.swift */; };
+		3BCA5F7C2DC7B16400A7EAC7 /* pumphistory-with-external.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */; };
 		3BCE75B32D4B38AE009E9453 /* InsulinSensitivities+Convert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */; };
 		3BCE75B52D4B391F009E9453 /* Decimal+rounding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */; };
-		3BCA5F7C2DC7B16400A7EAC7 /* pumphistory-with-external.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */; };
 		3BD6CE262DC24CFD00FA0472 /* pumphistory-24h-zoned.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */; };
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
@@ -300,6 +310,7 @@
 		3BEA3AE12D58F79700A67A1D /* AlgorithmComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */; };
 		3BEA3AE22D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */; };
 		3BEA3AE32D58F79700A67A1D /* JSONCompare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADC2D58F79700A67A1D /* JSONCompare.swift */; };
+		3BF424C72DF4805A0017CFD9 /* AutosensError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF424C62DF480550017CFD9 /* AutosensError.swift */; };
 		3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */; };
 		3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */; };
 		3BF92F2D2D86DEE9006B545A /* autosens.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BF92F212D86DEE9006B545A /* autosens.js */; };
@@ -1095,6 +1106,7 @@
 		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>"; };
 		38FEF412273B317A00574A46 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = "<group>"; };
+		3B139EF22DF06CDC00D40797 /* AutosensGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensGenerator.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>"; };
@@ -1149,27 +1161,38 @@
 		3B5CD2C52D4AECD500CE213C /* ProfileJavascriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileJavascriptTests.swift; sourceTree = "<group>"; };
 		3B5CD2C62D4AECD500CE213C /* ProfileTargetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTargetsTests.swift; sourceTree = "<group>"; };
 		3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleApproximateMatching.swift; sourceTree = "<group>"; };
+		3B8B5D2D2DF5238000365ED3 /* as-basal.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-basal.json"; sourceTree = "<group>"; };
+		3B8B5D2E2DF5238000365ED3 /* as-carbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-carbs.json"; sourceTree = "<group>"; };
+		3B8B5D2F2DF5238000365ED3 /* as-glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-glucose.json"; sourceTree = "<group>"; };
+		3B8B5D302DF5238000365ED3 /* as-profile.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-profile.json"; sourceTree = "<group>"; };
+		3B8B5D312DF5238000365ED3 /* as-pump.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-pump.json"; sourceTree = "<group>"; };
+		3B8B5D322DF5238000365ED3 /* as-temp-targets.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "as-temp-targets.json"; sourceTree = "<group>"; };
+		3B8B5D3B2DF523B800365ED3 /* AutosensJsonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensJsonTests.swift; sourceTree = "<group>"; };
+		3B8B5D3D2DF5240600365ED3 /* TimeZoneForTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeZoneForTests.swift; sourceTree = "<group>"; };
+		3B8B5D3F2DF52D0700365ED3 /* deviationsUnsorted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = deviationsUnsorted.json; sourceTree = "<group>"; };
 		3B997DCA2DC00849006B6BB2 /* JSONImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONImporter.swift; sourceTree = "<group>"; };
 		3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONImporterTests.swift; sourceTree = "<group>"; };
 		3B997DD12DC02AEF006B6BB2 /* glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = glucose.json; sourceTree = "<group>"; };
 		3BA8D1B22DDB870F0006191F /* DecimalExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecimalExtensions.swift; sourceTree = "<group>"; };
 		3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainLoadingView.swift; sourceTree = "<group>"; };
 		3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataInitializationCoordinator.swift; sourceTree = "<group>"; };
+		3BBC22622DF5B93900169236 /* AutosensTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensTests.swift; sourceTree = "<group>"; };
 		3BC0AA3A2DA74C87000DF7B7 /* iob-total.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "iob-total.js"; sourceTree = "<group>"; };
 		3BC0AA3C2DA817EC000DF7B7 /* iob-calculate.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "iob-calculate.js"; sourceTree = "<group>"; };
 		3BC0AA3D2DA817EC000DF7B7 /* iob-history.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "iob-history.js"; sourceTree = "<group>"; };
 		3BC0AA402DA8B8F7000DF7B7 /* iob-history-prepare.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "iob-history-prepare.js"; sourceTree = "<group>"; };
 		3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobSuspendTests.swift; sourceTree = "<group>"; };
 		3BC4053A2D931620006A03E9 /* IobJsonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobJsonTests.swift; sourceTree = "<group>"; };
+		3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-with-external.json"; sourceTree = "<group>"; };
 		3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InsulinSensitivities+Convert.swift"; sourceTree = "<group>"; };
 		3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+rounding.swift"; sourceTree = "<group>"; };
-		3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-with-external.json"; sourceTree = "<group>"; };
 		3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-24h-zoned.json"; sourceTree = "<group>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlgorithmComparison.swift; sourceTree = "<group>"; };
 		3BEA3ADC2D58F79700A67A1D /* JSONCompare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCompare.swift; sourceTree = "<group>"; };
 		3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsSwiftOrefComparisonLogger.swift; sourceTree = "<group>"; };
 		3BEA3ADE2D58F79700A67A1D /* OrefFunction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrefFunction.swift; sourceTree = "<group>"; };
+		3BF424C62DF480550017CFD9 /* AutosensError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensError.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
 		3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileJsNativeCompareTests.swift; sourceTree = "<group>"; };
 		3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCompareTests.swift; sourceTree = "<group>"; };
@@ -2691,10 +2714,8 @@
 				BD8FC0552D66187700B95AED /* CoreDataTests */,
 				38FCF3F125E9028E0078B0D1 /* Info.plist */,
 				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
-				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */,
-				3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
 				3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
@@ -2702,6 +2723,15 @@
 			path = TrioTests;
 			sourceTree = "<group>";
 		};
+		3B139EF12DF06CD100D40797 /* Autosens */ = {
+			isa = PBXGroup;
+			children = (
+				3BF424C62DF480550017CFD9 /* AutosensError.swift */,
+				3B139EF22DF06CDC00D40797 /* AutosensGenerator.swift */,
+			);
+			path = Autosens;
+			sourceTree = "<group>";
+		};
 		3B1C5C282D68E1E3004E9273 /* Iob */ = {
 			isa = PBXGroup;
 			children = (
@@ -2716,6 +2746,7 @@
 		3B1C5C3C2D68E269004E9273 /* json */ = {
 			isa = PBXGroup;
 			children = (
+				3B8B5D2C2DF5234C00365ED3 /* autosens */,
 				3BF92F392D86F1AA006B545A /* iob-error-log.json */,
 			);
 			path = json;
@@ -2724,9 +2755,10 @@
 		3B1C5C3F2D68E269004E9273 /* utils */ = {
 			isa = PBXGroup;
 			children = (
-				3BF92F372D86E106006B545A /* OpenAPSFixed.swift */,
 				3B1C5C3D2D68E269004E9273 /* Extensions.swift */,
 				3B1C5C3E2D68E269004E9273 /* IobJsonTypes.swift */,
+				3BF92F372D86E106006B545A /* OpenAPSFixed.swift */,
+				3B8B5D3D2DF5240600365ED3 /* TimeZoneForTests.swift */,
 			);
 			path = utils;
 			sourceTree = "<group>";
@@ -2734,6 +2766,7 @@
 		3B5CD1EB2D4912A600CE213C /* OpenAPSSwift */ = {
 			isa = PBXGroup;
 			children = (
+				3B139EF12DF06CD100D40797 /* Autosens */,
 				3B5CD2A42D4AEA5D00CE213C /* Extensions */,
 				3B1C5C282D68E1E3004E9273 /* Iob */,
 				3BEA3ADF2D58F79700A67A1D /* Logging */,
@@ -2800,6 +2833,8 @@
 				3B1C5C3C2D68E269004E9273 /* json */,
 				3BFA5BF72D989F380072B082 /* mocks */,
 				3B1C5C3F2D68E269004E9273 /* utils */,
+				3B8B5D3B2DF523B800365ED3 /* AutosensJsonTests.swift */,
+				3BBC22622DF5B93900169236 /* AutosensTests.swift */,
 				3B1C5C352D68E269004E9273 /* IobCalculateTests.swift */,
 				3B1C5C362D68E269004E9273 /* IobHistoryTests.swift */,
 				3BC4053A2D931620006A03E9 /* IobJsonTests.swift */,
@@ -2816,6 +2851,34 @@
 			path = OpenAPSSwiftTests;
 			sourceTree = "<group>";
 		};
+		3B8B5D2C2DF5234C00365ED3 /* autosens */ = {
+			isa = PBXGroup;
+			children = (
+				3B8B5D3F2DF52D0700365ED3 /* deviationsUnsorted.json */,
+				3B8B5D2D2DF5238000365ED3 /* as-basal.json */,
+				3B8B5D2E2DF5238000365ED3 /* as-carbs.json */,
+				3B8B5D2F2DF5238000365ED3 /* as-glucose.json */,
+				3B8B5D302DF5238000365ED3 /* as-profile.json */,
+				3B8B5D312DF5238000365ED3 /* as-pump.json */,
+				3B8B5D322DF5238000365ED3 /* as-temp-targets.json */,
+			);
+			path = autosens;
+			sourceTree = "<group>";
+		};
+		3B997DD22DC02AEF006B6BB2 /* JSONImporterData */ = {
+			isa = PBXGroup;
+			children = (
+				3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */,
+				DD3C47B22DC5608A003DD20D /* newerSuggested.json */,
+				DDD78AD72DC421B500AC63F3 /* enacted.json */,
+				DDD78AD82DC421B500AC63F3 /* suggested.json */,
+				DDD78A902DC4064800AC63F3 /* carbhistory.json */,
+				3B997DD12DC02AEF006B6BB2 /* glucose.json */,
+				3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */,
+			);
+			path = JSONImporterData;
+			sourceTree = "<group>";
+		};
 		3BEA3ADF2D58F79700A67A1D /* Logging */ = {
 			isa = PBXGroup;
 			children = (
@@ -2863,20 +2926,6 @@
 			path = mocks;
 			sourceTree = "<group>";
 		};
-		3B997DD22DC02AEF006B6BB2 /* JSONImporterData */ = {
-			isa = PBXGroup;
-			children = (
-				3BCA5F7B2DC7B15400A7EAC7 /* pumphistory-with-external.json */,
-				DD3C47B22DC5608A003DD20D /* newerSuggested.json */,
-				DDD78AD72DC421B500AC63F3 /* enacted.json */,
-				DDD78AD82DC421B500AC63F3 /* suggested.json */,
-				DDD78A902DC4064800AC63F3 /* carbhistory.json */,
-				3B997DD12DC02AEF006B6BB2 /* glucose.json */,
-				3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */,
-			);
-			path = JSONImporterData;
-			sourceTree = "<group>";
-		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -4246,8 +4295,15 @@
 				DDD78A912DC4064800AC63F3 /* carbhistory.json in Resources */,
 				3B997DD32DC02AEF006B6BB2 /* glucose.json in Resources */,
 				DDD78AD92DC421B500AC63F3 /* enacted.json in Resources */,
+				3B8B5D402DF52D0E00365ED3 /* deviationsUnsorted.json in Resources */,
 				DD3C47B32DC5608A003DD20D /* newerSuggested.json in Resources */,
 				3BCA5F7C2DC7B16400A7EAC7 /* pumphistory-with-external.json in Resources */,
+				3B8B5D332DF5238000365ED3 /* as-profile.json in Resources */,
+				3B8B5D342DF5238000365ED3 /* as-glucose.json in Resources */,
+				3B8B5D352DF5238000365ED3 /* as-pump.json in Resources */,
+				3B8B5D362DF5238000365ED3 /* as-carbs.json in Resources */,
+				3B8B5D372DF5238000365ED3 /* as-basal.json in Resources */,
+				3B8B5D382DF5238000365ED3 /* as-temp-targets.json in Resources */,
 				DDD78ADA2DC421B500AC63F3 /* suggested.json in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -4754,6 +4810,7 @@
 				3B2F77882D7E5387005ED9FA /* CurrentTDDSetup.swift in Sources */,
 				DBA5254DBB2586C98F61220C /* ISFEditorProvider.swift in Sources */,
 				BDF34EBE2C0A31D100D51995 /* CustomNotification.swift in Sources */,
+				3B139EF32DF06CE100D40797 /* AutosensGenerator.swift in Sources */,
 				DDC38E102D9B377800ADCB46 /* OnboardingView+Util.swift in Sources */,
 				BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */,
 				3B1C5C332D68E233004E9273 /* PumpHistory+copy.swift in Sources */,
@@ -4808,6 +4865,7 @@
 				389ECE052601144100D86C4F /* ConcurrentMap.swift in Sources */,
 				110AEDEC2C51A0AE00615CC9 /* ShortcutsConfigDataFlow.swift in Sources */,
 				CE7CA3562A064973004BE681 /* StateIntentRequest.swift in Sources */,
+				3BF424C72DF4805A0017CFD9 /* AutosensError.swift in Sources */,
 				DD4C57A82D73ADEA001BFF2C /* RestartLiveActivityIntent.swift in Sources */,
 				19E1F7EA29D082ED005C8D20 /* IconConfigProvider.swift in Sources */,
 				DD09D4822C5986F6003FEA5D /* CalendarEventSettingsRootView.swift in Sources */,
@@ -4965,6 +5023,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				3B5CD2C92D4AECD500CE213C /* ProfileCarbsTests.swift in Sources */,
+				3B8B5D3E2DF5240C00365ED3 /* TimeZoneForTests.swift in Sources */,
 				3B5CD2CA2D4AECD500CE213C /* ProfileJavascriptTests.swift in Sources */,
 				3B5CD2CB2D4AECD500CE213C /* ProfileTargetsTests.swift in Sources */,
 				3B5CD2CD2D4AECD500CE213C /* ProfileIsfTests.swift in Sources */,
@@ -4975,12 +5034,14 @@
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
+				3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,
 				BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */,
 				DDC6CA6D2DD90A2A0060EE25 /* LocalizationTests.swift in Sources */,
 				3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */,
+				3B8B5D3C2DF523C000365ED3 /* AutosensJsonTests.swift in Sources */,
 				BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */,
 				BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,

+ 27 - 0
Trio/Sources/APS/OpenAPSSwift/Autosens/AutosensError.swift

@@ -0,0 +1,27 @@
+import Foundation
+
+enum AutosensError: LocalizedError, Equatable {
+    case missingIsfProfile
+    case missingCarbRatioInProfile
+    case missingCurrentBasalInProfile
+    case missingSensInProfile
+    case missingMaxDailyBasalInProfile
+    case isfLookupError
+
+    var errorDescription: String? {
+        switch self {
+        case .missingIsfProfile:
+            return "No ISF set on the profile"
+        case .missingCarbRatioInProfile:
+            return "Carb ratio is not set on the profile"
+        case .missingCurrentBasalInProfile:
+            return "Current basal is not set on the profile"
+        case .missingSensInProfile:
+            return "Sensitivity is not set on the profile"
+        case .missingMaxDailyBasalInProfile:
+            return "Max Daily Basal is not set on the profile"
+        case .isfLookupError:
+            return "Unable to lookup the ISF"
+        }
+    }
+}

+ 405 - 0
Trio/Sources/APS/OpenAPSSwift/Autosens/AutosensGenerator.swift

@@ -0,0 +1,405 @@
+import Foundation
+
+struct AutosensGenerator {
+    /// Internal structure to keep track of bucketed glucose values
+    struct BucketedGlucose {
+        let glucose: Decimal
+        let date: Date
+    }
+
+    /// Internal structure to keep track of the insulin effects simulation state
+    struct SimulationState {
+        enum StateType {
+            case initialState
+            case csf
+            case uam
+            case nonMeal
+        }
+
+        var meals: [CarbsEntry]
+        var absorbing = false
+        var uam = false
+        var mealCOB: Decimal = 0
+        var mealCarbs: Decimal = 0
+        var mealStartCounter: Int = 999
+        var type: StateType = .initialState
+    }
+
+    /// Generates autosens ratio by analyzing glucose deviations from expected insulin activity
+    ///
+    /// This is the main Autosens algorithm entry point
+    static func generate(
+        glucose: [BloodGlucose],
+        pumpHistory: [PumpHistoryEvent],
+        basalProfile: [BasalProfileEntry],
+        profile: Profile,
+        carbs: [CarbsEntry],
+        tempTargets: [TempTarget],
+        maxDeviations: Int,
+        clock: Date,
+        includeDeviationsForTesting: Bool = false
+    ) throws -> Autosens {
+        // from prepare/autosens.js
+        guard glucose.count >= 72 else {
+            return Autosens(ratio: 1, newisf: nil)
+        }
+
+        let lastSiteChange = determineLastSiteChange(pumpHistory: pumpHistory, profile: profile, clock: clock)
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory.map { $0.computedEvent() },
+            profile: profile,
+            clock: clock,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        let bucketedData = bucketGlucose(glucose: glucose, lastSiteChange: lastSiteChange)
+
+        let meals = findMeals(history: pumpHistory, carbs: carbs, profile: profile, bucketedGlucose: bucketedData)
+
+        // run through the simulation loop
+        var state = SimulationState(meals: meals)
+        var deviations: [Decimal] = []
+        // in JS the simulation loop starts at index 3
+        for (prevGlucose, currGlucose) in zip(bucketedData.dropFirst(2), bucketedData.dropFirst(3)) {
+            guard let isfProfile = profile.isfProfile?.toInsulinSensitivities() else {
+                throw AutosensError.missingIsfProfile
+            }
+            let (sensitivity, _) = try Isf.isfLookup(isfDataInput: isfProfile, timestamp: currGlucose.date)
+            // in JS the isfLookup function returns -1 on errors
+            guard sensitivity > 0 else {
+                throw AutosensError.isfLookupError
+            }
+            let deltaGlucose = currGlucose.glucose - prevGlucose.glucose
+            var simulationProfile = profile
+            simulationProfile.currentBasal = try Basal.basalLookup(basalProfile, now: currGlucose.date)
+            simulationProfile.temptargetSet = false
+            let iob = try IobCalculation.iobTotal(treatments: treatments, profile: simulationProfile, time: currGlucose.date)
+            let bgi = (-iob.activity * sensitivity * 5).rounded(scale: 2)
+
+            // BUG: the time span for deltaGlucose might be different
+            // then the time span for bgi if there was a missing CGM
+            // reading. We're porting the JS logic, but this is incorrect
+            var deviation = deltaGlucose - bgi
+
+            // set positive deviations to zero if BG is below 80
+            if currGlucose.glucose < 80, deviation > 0 {
+                deviation = 0
+            }
+
+            state = try advanceSimulationState(
+                state: state,
+                glucose: currGlucose,
+                profile: simulationProfile,
+                sensitivity: sensitivity,
+                iob: iob.iob,
+                deviation: deviation
+            )
+
+            if state.type == .nonMeal {
+                deviations.append(deviation)
+            }
+
+            if let tempTargetDeviation = tempTargetDeviation(tempTargets: tempTargets, profile: profile, time: currGlucose.date) {
+                deviations.append(tempTargetDeviation)
+            }
+
+            // BUG: You might get runs that are less than 5 minutes apart
+            // due to the bucketing logic, resulting in extra 0s if this
+            // happens right on an even hour
+            if everyOtherHourOnTheHour(glucoseDate: currGlucose.date) {
+                deviations.append(0)
+            }
+
+            // BUG: Should be in a loop since you can add more than
+            // one deviation each iteration
+            if deviations.count > maxDeviations {
+                deviations = deviations.dropFirst().map { $0 }
+            }
+        }
+
+        // Add padding zeros when we have insufficient data (less than 8 hours worth)
+        // This dampens sensitivity changes based on too little data
+        if deviations.count < 96 {
+            let dataCompleteness = Double(deviations.count) / 96.0 // 0.0 to 1.0
+            let paddingNeeded = Int(round((1.0 - dataCompleteness) * 18.0))
+
+            // Add zeros - more padding when we have less data
+            for _ in 0 ..< paddingNeeded {
+                deviations.append(0)
+            }
+        }
+
+        return try statisticsOnDeviations(
+            deviations: deviations,
+            profile: profile,
+            includeDeviationsForTesting: includeDeviationsForTesting
+        )
+    }
+
+    /// Calculates deviation adjustment for high temp targets to raise sensitivity
+    ///
+    /// This function is not private to enable testing, but it shouldn't be used outside of this module
+    static func tempTargetDeviation(tempTargets: [TempTarget], profile: Profile, time: Date) -> Decimal? {
+        // Trio doesn't support exercise mode, so we can ignore it
+        guard profile.highTemptargetRaisesSensitivity else {
+            return nil
+        }
+
+        guard let tempTarget = tempTargetRunning(tempTargets: tempTargets, time: time), tempTarget > 100 else {
+            return nil
+        }
+
+        return -(tempTarget - 100) / 20
+    }
+
+    /// Calculates autosens ratio and new ISF from glucose deviation statistics
+    private static func statisticsOnDeviations(
+        deviations: [Decimal],
+        profile: Profile,
+        includeDeviationsForTesting: Bool
+    ) throws -> Autosens {
+        guard let profileSensitivity = profile.sens else {
+            throw AutosensError.missingSensInProfile
+        }
+        guard let maxDailyBasal = profile.maxDailyBasal else {
+            throw AutosensError.missingMaxDailyBasalInProfile
+        }
+
+        let deviationsUnsorted = deviations
+        let deviations = deviations.sorted()
+
+        // Calculate 50th percentile to determine sensitivity vs resistance
+        let medianDeviation = percentile(deviations, 0.50)
+
+        // Calculate basal adjustment based on sensitivity/resistance
+        var basalOff: Decimal = 0
+
+        if medianDeviation < 0 {
+            // Insulin sensitivity detected
+            basalOff = medianDeviation * (60 / 5) / profileSensitivity
+        } else if medianDeviation > 0 {
+            // Insulin resistance detected
+            basalOff = medianDeviation * (60 / 5) / profileSensitivity
+        }
+        // If neither condition is met, sensitivity is normal (basalOff remains 0)
+
+        // Calculate the autosens ratio
+        var ratio = 1 + (basalOff / maxDailyBasal)
+
+        // Apply min/max limits (typically 0.7x to 1.2x)
+        ratio = ratio.clamp(lowerBound: profile.autosensMin, upperBound: profile.autosensMax)
+
+        // Round ratio to 2 decimal places
+        ratio = ratio.rounded(scale: 2)
+
+        // Calculate new ISF
+        let newISF = (profileSensitivity / ratio).rounded()
+
+        if includeDeviationsForTesting {
+            return Autosens(ratio: ratio, newisf: newISF, deviationsUnsorted: deviationsUnsorted)
+        } else {
+            return Autosens(ratio: ratio, newisf: newISF)
+        }
+    }
+
+    /// Calculate percentile of a sorted array - direct port of JS implementation
+    private static func percentile(_ sortedArray: [Decimal], _ p: Double) -> Decimal {
+        if sortedArray.isEmpty { return 0 }
+        if p <= 0 { return sortedArray[0] }
+        if p >= 1 { return sortedArray[sortedArray.count - 1] }
+
+        let index = Double(sortedArray.count) * p
+        let lower = Int(floor(index))
+        let upper = lower + 1
+        let weight = index.truncatingRemainder(dividingBy: 1.0) // equivalent to index % 1
+
+        if upper >= sortedArray.count { return sortedArray[lower] }
+
+        let weightDecimal = Decimal(weight)
+        return sortedArray[lower] * (1 - weightDecimal) + sortedArray[upper] * weightDecimal
+    }
+
+    /// Returns true if the time is within first 5 minutes of an even hour based on local timezone
+    private static func everyOtherHourOnTheHour(glucoseDate: Date) -> Bool {
+        let calendar = Calendar.current
+        let minutes = calendar.component(.minute, from: glucoseDate)
+        let hours = calendar.component(.hour, from: glucoseDate)
+
+        if minutes >= 0, minutes < 5 {
+            if hours % 2 == 0 {
+                return true
+            }
+        }
+
+        return false
+    }
+
+    /// Advances simulation state based on carb absorption and IOB levels.
+    /// Returns the updated state
+    private static func advanceSimulationState(
+        state: SimulationState,
+        glucose: BucketedGlucose,
+        profile: Profile,
+        sensitivity: Decimal,
+        iob: Decimal,
+        deviation: Decimal
+    ) throws -> SimulationState {
+        var state = state
+
+        // BUG: This should be in a loop to handle more than one
+        // carb entry (i.e., if entered close together in time)
+        if let meal = state.meals.last, meal.date < glucose.date {
+            if meal.carbs >= 1 {
+                state.mealCOB += meal.carbs
+                state.mealCarbs += meal.carbs
+            }
+            state.meals = state.meals.dropLast()
+        }
+
+        if state.mealCOB > 0 {
+            guard let carbRatio = profile.carbRatio else {
+                throw AutosensError.missingCarbRatioInProfile
+            }
+            let ci = max(deviation, profile.min5mCarbImpact)
+            let absorbed = ci * carbRatio / sensitivity
+            state.mealCOB = max(0, state.mealCOB - absorbed)
+        }
+
+        // If mealCOB is zero but all deviations since hitting COB=0 are positive, exclude from autosens
+        if state.mealCOB > 0 || state.absorbing || state.mealCarbs > 0 {
+            state.absorbing = deviation > 0
+            // stop excluding positive deviations as soon as mealCOB=0 if meal has been absorbing for >5h
+            if state.mealStartCounter > 60, state.mealCOB < 0.5 {
+                state.absorbing = false
+            }
+            if !state.absorbing, state.mealCOB < 0.5 {
+                state.mealCarbs = 0
+            }
+
+            // check previous "type" value, and if it wasn't csf, set a mealAbsorption start flag
+            if state.type != .csf {
+                state.mealStartCounter = 0
+            }
+            state.mealStartCounter += 1
+            state.type = .csf
+        } else {
+            // check previous "type" value, and if it was csf, set a mealAbsorption end flag
+
+            guard let currentBasal = profile.currentBasal else {
+                throw AutosensError.missingCurrentBasalInProfile
+            }
+            // always exclude the first 45m after each carb entry using mealStartCounter
+            if iob > 2 * currentBasal || state.uam || state.mealStartCounter < 9 {
+                state.mealStartCounter += 1
+                state.uam = deviation > 0
+
+                state.type = .uam
+            } else {
+                state.type = .nonMeal
+            }
+        }
+
+        return state
+    }
+
+    /// Finds carbs and returns them in descending order, oldest records first
+    private static func findMeals(
+        history _: [PumpHistoryEvent],
+        carbs: [CarbsEntry],
+        profile _: Profile,
+        bucketedGlucose: [BucketedGlucose]
+    ) -> [CarbsEntry] {
+        let firstGlucoseDate = bucketedGlucose.first?.date ?? .distantPast
+        // TODO: Hook up to meal functions when they're ready
+        return carbs.sorted(by: { $0.date > $1.date }).filter({ $0.date >= firstGlucoseDate })
+    }
+
+    /// Find the last site change, falling back to 24 hours ago if not found
+    ///
+    /// - Note: The search begins at index 1 of the pump history (skipping the most recent event)
+    ///   to maintain compatibility with the original algorithm implementation
+    ///
+    /// This function is not private to enable testing, but it shouldn't be used outside of this module
+    static func determineLastSiteChange(pumpHistory: [PumpHistoryEvent], profile: Profile, clock: Date) -> Date {
+        // In Javascript the for loop for this starts at index 1, I'm not sure why
+        let mostRecentRewind = pumpHistory.dropFirst().first(where: { $0.type == .rewind })
+        guard profile.rewindResetsAutosens, let mostRecentRewind = mostRecentRewind else {
+            return clock - 24.hoursToSeconds
+        }
+
+        return mostRecentRewind.timestamp
+    }
+
+    /// Groups glucose readings into time buckets, averaging readings within 2 minutes
+    private static func bucketGlucose(glucose: [BloodGlucose], lastSiteChange: Date) -> [BucketedGlucose] {
+        let glucoseData = glucose.compactMap({ (bg: BloodGlucose) -> BucketedGlucose? in
+            guard let glucose = bg.glucose ?? bg.sgv else { return nil }
+            return BucketedGlucose(glucose: Decimal(glucose), date: bg.dateString)
+        }).reversed()
+
+        guard let first = glucoseData.first else { return [] }
+
+        var bucketedData = [first]
+        var index = 0
+        for (previousGlucose, currentGlucose) in zip(glucoseData, glucoseData.dropFirst()) {
+            guard previousGlucose.glucose >= 39, currentGlucose.glucose >= 39 else {
+                continue
+            }
+
+            guard currentGlucose.date >= lastSiteChange else {
+                continue
+            }
+
+            let elapsedTime = currentGlucose.date.timeIntervalSince(previousGlucose.date).secondsToMinutes
+            if abs(elapsedTime) > 2 {
+                index += 1
+                bucketedData.append(currentGlucose)
+            } else {
+                // BUG: This is incorrect if you have more than one reading
+                // in the same bucket, but this should be rare so we'll just
+                // port it over
+                let averageGlucose = 0.5 * (bucketedData[index].glucose + currentGlucose.glucose)
+                bucketedData[index] = BucketedGlucose(glucose: averageGlucose, date: bucketedData[index].date)
+            }
+        }
+
+        // In Javascript it has this: bucketed_data.shift();
+        return bucketedData.dropFirst().map { $0 }
+    }
+
+    /// Returns the current active temp target value, or nil if none is active
+    private static func tempTargetRunning(tempTargets: [TempTarget], time: Date) -> Decimal? {
+        // Sort temp targets by creation date (most recent first) to process in correct order
+        let sortedTargets = tempTargets.sorted { $0.createdAt > $1.createdAt }
+
+        for target in sortedTargets {
+            let startTime = target.createdAt
+            let durationSeconds = TimeInterval(target.duration * 60)
+            let expirationTime = startTime.addingTimeInterval(durationSeconds)
+
+            // Check if this is a cancellation temp target (duration = 0)
+            if time >= startTime, target.duration == 0 {
+                // Cancel all temp targets
+                return nil
+            }
+
+            // Check if temp target is currently active
+            if time >= startTime, time < expirationTime {
+                guard let targetTop = target.targetTop, let targetBottom = target.targetBottom else {
+                    return nil
+                }
+                // Calculate average of target range
+                return (targetTop + targetBottom) / 2
+            }
+        }
+
+        // No active temp target found
+        return nil
+    }
+}
+
+extension CarbsEntry {
+    var date: Date { actualDate ?? createdAt }
+}

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

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

+ 11 - 0
Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -64,4 +64,15 @@ struct OpenAPSSwift {
             return (.failure(error), iobInputs)
         }
     }
+
+    static func autosense(
+        glucose _: JSON,
+        pumpHistory _: JSON,
+        basalprofile _: JSON,
+        profile _: JSON,
+        carbs _: JSON,
+        temptargets _: JSON
+    ) -> OrefFunctionResult {
+        .failure(NSError(domain: "Some error", code: 1, userInfo: nil))
+    }
 }

+ 1 - 0
Trio/Sources/Models/Autosens.swift

@@ -3,5 +3,6 @@ import Foundation
 struct Autosens: JSON {
     let ratio: Decimal
     let newisf: Decimal?
+    var deviationsUnsorted: [Decimal]?
     var timestamp: Date?
 }

+ 18 - 2
Trio/Sources/Models/BloodGlucose.swift

@@ -61,6 +61,7 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
 
     enum CodingKeys: String, CodingKey {
         case _id
+        case idKey = "id"
         case sgv
         case direction
         case date
@@ -77,7 +78,12 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
 
     init(from decoder: Decoder) throws {
         let container = try decoder.container(keyedBy: CodingKeys.self)
-        _id = try container.decode(String.self, forKey: ._id)
+        // Try to decode from "_id" first, then fall back to "id"
+        if let idValue = try container.decodeIfPresent(String.self, forKey: ._id) {
+            _id = idValue
+        } else {
+            _id = try container.decode(String.self, forKey: .idKey)
+        }
 
         sgv = try? container.decodeIfPresent(Int.self, forKey: .sgv)
         if sgv == nil {
@@ -89,8 +95,14 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
         }
 
         direction = try container.decodeIfPresent(Direction.self, forKey: .direction)
-        date = try container.decode(Decimal.self, forKey: .date)
         dateString = try container.decode(Date.self, forKey: .dateString)
+
+        do {
+            date = try container.decode(Decimal.self, forKey: .date)
+        } catch {
+            date = Decimal(dateString.timeIntervalSince1970 * 1000).rounded()
+        }
+
         unfiltered = try container.decodeIfPresent(Decimal.self, forKey: .unfiltered)
         filtered = try container.decodeIfPresent(Decimal.self, forKey: .filtered)
         noise = try container.decodeIfPresent(Int.self, forKey: .noise)
@@ -136,6 +148,10 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
         _id ?? UUID().uuidString
     }
 
+    // this is a dummy property, we never set it. We have it for more flexible
+    // glucose record parsing (see the `init(from decoder: Decoder)` method)
+    var idKey: String?
+
     var sgv: Int?
     var direction: Direction?
     let date: Decimal

+ 54 - 0
TrioTests/OpenAPSSwiftTests/AutosensJsonTests.swift

@@ -0,0 +1,54 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Autosens using real JSON", .serialized) struct AutosensJsonTests {
+    let timeZoneForTests = TimeZoneForTests()
+
+    // static func from<T: Decodable>(string: String) throws -> T
+    func loadJson<T: Decodable>(_ name: String) throws -> T {
+        let testBundle = Bundle(for: BundleReference.self)
+        let path = testBundle.path(forResource: name, ofType: "json")!
+        let data = try Data(contentsOf: URL(fileURLWithPath: path))
+        return try JSONCoding.decoder.decode(T.self, from: data)
+    }
+
+    @Test("Test with resistance") func generateJavascriptInputs() throws {
+        let glucose: [BloodGlucose] = try loadJson("as-glucose")
+        let pump: [PumpHistoryEvent] = try loadJson("as-pump")
+        let basalProfile: [BasalProfileEntry] = try loadJson("as-basal")
+        let profile: Profile = try loadJson("as-profile")
+        let carbs: [CarbsEntry] = try loadJson("as-carbs")
+        let tempTargets: [TempTarget] = try loadJson("as-temp-targets")
+
+        let clock = Date("2025-06-08T00:14:35.481Z")!
+
+        timeZoneForTests.setTimezone(identifier: "America/Los_Angeles")
+
+        let autosensResult = try AutosensGenerator.generate(
+            glucose: glucose,
+            pumpHistory: pump,
+            basalProfile: basalProfile,
+            profile: profile,
+            carbs: carbs,
+            tempTargets: tempTargets,
+            maxDeviations: 96,
+            clock: clock,
+            includeDeviationsForTesting: true
+        )
+
+        let deviationsUnsorted: [Decimal] = try loadJson("deviationsUnsorted")
+
+        #expect(autosensResult.ratio == 1.2)
+        #expect(autosensResult.newisf == 46)
+        #expect(deviationsUnsorted.count == autosensResult.deviationsUnsorted?.count)
+
+        for (ref, calc) in zip(deviationsUnsorted, autosensResult.deviationsUnsorted!) {
+            // we can get differences due to rounding inconsistencies between
+            // javascript and swift with negative numbers
+            #expect(ref.isWithin(0.01, of: calc))
+        }
+
+        timeZoneForTests.resetTimezone()
+    }
+}

+ 175 - 0
TrioTests/OpenAPSSwiftTests/AutosensTests.swift

@@ -0,0 +1,175 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Autosens Temp Target Deviation Tests") struct TempTargetDeviationTests {
+    // Helper function to create a basic profile with highTemptargetRaisesSensitivity enabled
+    func createProfileWithSensitivity(enabled: Bool = true) -> Profile {
+        var profile = Profile()
+        profile.highTemptargetRaisesSensitivity = enabled
+        return profile
+    }
+
+    // Helper function to create temp targets at specific times
+    func createTempTarget(
+        createdAt: Date,
+        targetTop: Decimal,
+        targetBottom: Decimal,
+        duration: Decimal
+    ) -> TempTarget {
+        TempTarget(
+            name: nil,
+            createdAt: createdAt,
+            targetTop: targetTop,
+            targetBottom: targetBottom,
+            duration: duration,
+            enteredBy: nil,
+            reason: nil,
+            isPreset: nil,
+            enabled: nil,
+            halfBasalTarget: nil
+        )
+    }
+
+    @Test("should return nil when highTemptargetRaisesSensitivity is false") func returnNilWhenSensitivityDisabled() async throws {
+        let profile = createProfileWithSensitivity(enabled: false)
+        let now = Date()
+        let tempTargets = [
+            createTempTarget(
+                createdAt: now - 30.minutesToSeconds,
+                targetTop: 140,
+                targetBottom: 120,
+                duration: 60
+            )
+        ]
+
+        let result = AutosensGenerator.tempTargetDeviation(
+            tempTargets: tempTargets,
+            profile: profile,
+            time: now
+        )
+
+        #expect(result == nil)
+    }
+
+    @Test("should return nil when no temp targets are active") func returnNilWhenNoActiveTempTargets() async throws {
+        let profile = createProfileWithSensitivity()
+        let now = Date()
+        let tempTargets = [
+            createTempTarget(
+                createdAt: now - 120.minutesToSeconds, // 2 hours ago
+                targetTop: 140,
+                targetBottom: 120,
+                duration: 60 // 1 hour duration, so expired
+            )
+        ]
+
+        let result = AutosensGenerator.tempTargetDeviation(
+            tempTargets: tempTargets,
+            profile: profile,
+            time: now
+        )
+
+        #expect(result == nil)
+    }
+
+    @Test("should return nil when temp target is at or below 100") func returnNilWhenTempTargetAtOrBelow100() async throws {
+        let profile = createProfileWithSensitivity()
+        let now = Date()
+        let tempTargets = [
+            createTempTarget(
+                createdAt: now - 30.minutesToSeconds,
+                targetTop: 100,
+                targetBottom: 100,
+                duration: 60
+            )
+        ]
+
+        let result = AutosensGenerator.tempTargetDeviation(
+            tempTargets: tempTargets,
+            profile: profile,
+            time: now
+        )
+
+        #expect(result == nil)
+    }
+
+    @Test("should calculate correct deviation for temp target above 100") func calculateCorrectDeviationAbove100() async throws {
+        let profile = createProfileWithSensitivity()
+        let now = Date()
+        let tempTargets = [
+            createTempTarget(
+                createdAt: now - 30.minutesToSeconds,
+                targetTop: 140,
+                targetBottom: 120,
+                duration: 60
+            )
+        ]
+
+        let result = AutosensGenerator.tempTargetDeviation(
+            tempTargets: tempTargets,
+            profile: profile,
+            time: now
+        )
+
+        // Average target = (140 + 120) / 2 = 130
+        // Deviation = -(130 - 100) / 20 = -30 / 20 = -1.5
+        #expect(result == -1.5)
+    }
+}
+
+@Suite("Determine Last Site Change Tests") struct DetermineLastSiteChangeTests {
+    @Test(
+        "should return rewind timestamp when rewind event exists and rewindResetsAutosens is true"
+    ) func returnRewindTimestampWhenRewindExists() async throws {
+        let now = Date()
+        let rewindTime = now - 6.hoursToSeconds
+
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: "1",
+                type: .tempBasal,
+                timestamp: now - 1.hoursToSeconds,
+                amount: nil,
+                duration: nil,
+                durationMin: nil,
+                rate: 1.5,
+                temp: .absolute,
+                carbInput: nil
+            ),
+            PumpHistoryEvent(
+                id: "2",
+                type: .rewind,
+                timestamp: rewindTime,
+                amount: nil,
+                duration: nil,
+                durationMin: nil,
+                rate: nil,
+                temp: nil,
+                carbInput: nil
+            ),
+            PumpHistoryEvent(
+                id: "3",
+                type: .tempBasal,
+                timestamp: now - 12.hoursToSeconds,
+                amount: nil,
+                duration: nil,
+                durationMin: nil,
+                rate: 2.0,
+                temp: .absolute,
+                carbInput: nil
+            )
+        ]
+
+        var profile = Profile()
+        profile.rewindResetsAutosens = true
+
+        let result = AutosensGenerator.determineLastSiteChange(
+            pumpHistory: pumpHistory,
+            profile: profile,
+            clock: now
+        )
+
+        #expect(result == rewindTime)
+    }
+}

+ 7 - 42
TrioTests/OpenAPSSwiftTests/IobJsonTests.swift

@@ -15,42 +15,7 @@ import Testing
 /// from the field. This server needs to run on the same machine as the simulator where this test runs.
 /// You can find more information about it from the `trio-oref-logs` repo.
 @Suite("IoB using real pump history JSON", .serialized) struct IobJsonTests {
-    private var originalTZ: String? = ProcessInfo.processInfo.environment["TZ"]
-    private var originalDefaultTimeZone: TimeZone? = TimeZone.current
-
-    // Helper function to set timezone
-    private func setTimezone(identifier: String) {
-        // Set environment variable
-        setenv("TZ", identifier, 1)
-        tzset() // Make the change take effect
-
-        // Force update the default TimeZone
-        // This is the critical missing piece
-        if let timeZone = TimeZone(identifier: identifier) {
-            TimeZone.ReferenceType.default = timeZone
-
-            // For extra assurance, you can log to verify
-            print("Timezone set to: \(TimeZone.current.identifier)")
-        } else {
-            print("Failed to create TimeZone with identifier: \(identifier)")
-        }
-    }
-
-    // Helper function to reset timezone
-    private func resetTimezone() {
-        // Restore system timezone from environment
-        if let originalTZ = originalTZ {
-            setenv("TZ", originalTZ, 1)
-        } else {
-            unsetenv("TZ")
-        }
-        tzset()
-
-        // Restore original default TimeZone
-        if let originalTimeZone = originalDefaultTimeZone {
-            TimeZone.ReferenceType.default = originalTimeZone
-        }
-    }
+    let timeZoneForTests = TimeZoneForTests()
 
     struct IobHistoryResult: Codable {
         var insulin: Decimal?
@@ -99,12 +64,12 @@ import Testing
                 continue
             }
 
-            setTimezone(identifier: algorithmComparison.timezone)
+            timeZoneForTests.setTimezone(identifier: algorithmComparison.timezone)
 
             try await checkFixedJsAgainstSwift(iobInputs: iobInputs)
             try await checkBundleJsAgainstSwift(iobInputs: iobInputs)
 
-            resetTimezone()
+            timeZoneForTests.resetTimezone()
         }
     }
 
@@ -222,7 +187,7 @@ import Testing
         let algorithmComparison = try decoder.decode(AlgorithmComparison.self, from: data)
         let iobInputs = algorithmComparison.iobInput!
 
-        setTimezone(identifier: algorithmComparison.timezone)
+        timeZoneForTests.setTimezone(identifier: algorithmComparison.timezone)
 
         let swiftIobHistory = try IobHistory.calcTempTreatments(
             history: iobInputs.history.map { $0.computedEvent() },
@@ -258,7 +223,7 @@ import Testing
         checkHistoryConsistency(swiftTreatments: swiftIobHistory, jsTreatments: jsIobHistory)
         checkRunningBasal(swiftTreatments: swiftIobHistory, jsTreatments: jsIobHistory)
 
-        resetTimezone()
+        timeZoneForTests.resetTimezone()
     }
 
     /// simple utility for creating inputs for Javascript for use in testing
@@ -282,7 +247,7 @@ import Testing
 
         try output.write(to: outputURL)
 
-        setTimezone(identifier: algorithmComparison.timezone)
+        timeZoneForTests.setTimezone(identifier: algorithmComparison.timezone)
 
         let treatments = try IobHistory.calcTempTreatments(
             history: iobInputs.history.map { $0.computedEvent() },
@@ -294,7 +259,7 @@ import Testing
 
         let iobSomething = try IobCalculation.iobTotal(treatments: treatments, profile: iobInputs.profile, time: iobInputs.clock)
 
-        resetTimezone()
+        timeZoneForTests.resetTimezone()
 
         print(iobSomething.prettyPrintedJSON!)
 

+ 32 - 0
TrioTests/OpenAPSSwiftTests/json/autosens/as-basal.json

@@ -0,0 +1,32 @@
+[
+  {
+    "start" : "00:00:00",
+    "rate" : 0.6,
+    "minutes" : 0
+  },
+  {
+    "start" : "04:00:00",
+    "rate" : 0.55,
+    "minutes" : 240
+  },
+  {
+    "start" : "08:00:00",
+    "rate" : 0.55,
+    "minutes" : 480
+  },
+  {
+    "start" : "12:00:00",
+    "rate" : 0.5,
+    "minutes" : 720
+  },
+  {
+    "start" : "16:00:00",
+    "rate" : 0.45,
+    "minutes" : 960
+  },
+  {
+    "start" : "20:00:00",
+    "rate" : 0.6,
+    "minutes" : 1200
+  }
+]

Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
TrioTests/OpenAPSSwiftTests/json/autosens/as-carbs.json


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
TrioTests/OpenAPSSwiftTests/json/autosens/as-glucose.json


+ 157 - 0
TrioTests/OpenAPSSwiftTests/json/autosens/as-profile.json

@@ -0,0 +1,157 @@
+{
+    "max_iob": 8,
+    "max_daily_safety_multiplier": 3,
+    "current_basal_safety_multiplier": 4,
+    "autosens_max": 1.2,
+    "autosens_min": 0.7,
+    "rewind_resets_autosens": true,
+    "high_temptarget_raises_sensitivity": false,
+    "low_temptarget_lowers_sensitivity": false,
+    "sensitivity_raises_target": false,
+    "resistance_lowers_target": false,
+    "exercise_mode": false,
+    "half_basal_exercise_target": 160,
+    "maxCOB": 120,
+    "maxMealAbsorptionTime": 6,
+    "skip_neutral_temps": false,
+    "unsuspend_if_no_temp": false,
+    "min_5m_carbimpact": 8,
+    "autotune_isf_adjustmentFraction": 1,
+    "remainingCarbsFraction": 1,
+    "remainingCarbsCap": 90,
+    "enableUAM": true,
+    "A52_risk_enable": false,
+    "enableSMB_with_COB": false,
+    "enableSMB_with_temptarget": false,
+    "enableSMB_always": true,
+    "enableSMB_after_carbs": false,
+    "enableSMB_high_bg": false,
+    "enableSMB_high_bg_target": 110,
+    "allowSMB_with_high_temptarget": false,
+    "maxSMBBasalMinutes": 120,
+    "maxUAMSMBBasalMinutes": 120,
+    "SMBInterval": 3,
+    "bolus_increment": 0.05,
+    "maxDelta_bg_threshold": 0.2,
+    "curve": "ultra-rapid",
+    "useCustomPeakTime": true,
+    "insulinPeakTime": 45,
+    "carbsReqThreshold": 1,
+    "offline_hotspot": false,
+    "noisyCGMTargetMultiplier": 1.3,
+    "suspend_zeros_iob": true,
+    "enableEnliteBgproxy": false,
+    "calc_glucose_noise": false,
+    "target_bg": false,
+    "smb_delivery_ratio": 0.5,
+    "adjustmentFactor": 0.8,
+    "adjustmentFactorSigmoid": 0.5,
+    "useNewFormula": true,
+    "sigmoid": true,
+    "weightPercentage": 0.35,
+    "tddAdjBasal": false,
+    "threshold_setting": 80,
+    "dia": 10,
+    "model": "722",
+    "current_basal": 0.45,
+    "basalprofile": [
+        {
+            "start": "00:00:00",
+            "rate": 0.6,
+            "minutes": 0
+        },
+        {
+            "start": "04:00:00",
+            "rate": 0.55,
+            "minutes": 240
+        },
+        {
+            "start": "08:00:00",
+            "rate": 0.55,
+            "minutes": 480
+        },
+        {
+            "start": "12:00:00",
+            "rate": 0.5,
+            "minutes": 720
+        },
+        {
+            "start": "16:00:00",
+            "rate": 0.45,
+            "minutes": 960
+        },
+        {
+            "start": "20:00:00",
+            "rate": 0.6,
+            "minutes": 1200
+        }
+    ],
+    "max_daily_basal": 0.6,
+    "max_basal": 3,
+    "out_units": "mg/dL",
+    "min_bg": 90,
+    "max_bg": 90,
+    "bg_targets": {
+        "units": "mg/dL",
+        "user_preferred_units": "mg/dL",
+        "targets": [
+            {
+                "high": 90,
+                "offset": 0,
+                "low": 90,
+                "start": "00:00:00",
+                "max_bg": 90,
+                "min_bg": 90
+            }
+        ]
+    },
+    "sens": 55,
+    "isfProfile": {
+        "units": "mg/dL",
+        "user_preferred_units": "mg/dL",
+        "sensitivities": [
+            {
+                "offset": 0,
+                "sensitivity": 47,
+                "start": "00:00:00"
+            },
+            {
+                "offset": 240,
+                "sensitivity": 50,
+                "start": "04:00:00"
+            },
+            {
+                "offset": 480,
+                "sensitivity": 47,
+                "start": "08:00:00"
+            },
+            {
+                "offset": 720,
+                "sensitivity": 49,
+                "start": "12:00:00"
+            },
+            {
+                "offset": 960,
+                "sensitivity": 55,
+                "start": "16:00:00",
+                "endOffset": 1200
+            },
+            {
+                "offset": 1200,
+                "sensitivity": 51,
+                "start": "20:00:00"
+            }
+        ]
+    },
+    "carb_ratio": 11,
+    "carb_ratios": {
+        "units": "grams",
+        "schedule": [
+            {
+                "offset": 0,
+                "ratio": 11,
+                "start": "00:00:00"
+            }
+        ]
+    }
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
TrioTests/OpenAPSSwiftTests/json/autosens/as-pump.json


+ 26 - 0
TrioTests/OpenAPSSwiftTests/json/autosens/as-temp-targets.json

@@ -0,0 +1,26 @@
+[
+  {
+    "_id" : "49993FD5-7A1E-4B65-8DAD-0BADF347BF90",
+    "name" : "Cancel",
+    "targetBottom" : 0,
+    "created_at" : "2025-06-04T01:10:47.464Z",
+    "halfBasalTarget" : 160,
+    "targetTop" : 0,
+    "reason" : "Cancel",
+    "duration" : 0,
+    "enteredBy" : "Trio"
+  },
+  {
+    "_id" : "5A700914-327C-4C4C-8114-5125A6E9CA75",
+    "name" : "High sensitivity 110",
+    "targetBottom" : 110,
+    "created_at" : "2025-06-03T23:09:13.069Z",
+    "halfBasalTarget" : 160,
+    "targetTop" : 110,
+    "reason" : "Temp Target",
+    "duration" : 120,
+    "isPreset" : true,
+    "enabled" : true,
+    "enteredBy" : "Trio"
+  }
+]

+ 1 - 0
TrioTests/OpenAPSSwiftTests/json/autosens/deviationsUnsorted.json

@@ -0,0 +1 @@
+[6.51,11.70,0,0,0,0,2.09,2.83,2.62,1.43,4.22,4.13,2.10,2.05,2.96,-3.11,2.75,3.61,-0.49,0.40,1.26,0.25,0,1.10,-0.05,-1.20,6.65,0.57,0.55,0.50,-2.60,-4.70,3.17,3.05,1.00,0.97,0.00,0.97,1.03,3.05,2.05,4.12,2.33,-6.57,5.47,3.45,-1.53,10.45,0,0.91,-0.20,-2.53,0.28,0.07,0,-0.14,3.64,-1.40,3.55,8.50,-0.43,9.48,0,-4.69,-3.91,-1.13,-2.38,3.38,4.13,6.94,5.86,10.76,0,-2.13,-2.45,2.23,-1.08,-2.09,0,2.61,-3.69,-3.97,-2.24,1.49,0.24,5.99,7.88,0.02,4.10,4.13,4.15,0.27,-1.71,0]

+ 40 - 0
TrioTests/OpenAPSSwiftTests/utils/TimeZoneForTests.swift

@@ -0,0 +1,40 @@
+import Foundation
+
+class TimeZoneForTests {
+    private var originalTZ: String? = ProcessInfo.processInfo.environment["TZ"]
+    private var originalDefaultTimeZone: TimeZone? = TimeZone.current
+
+    // Helper function to set timezone
+    func setTimezone(identifier: String) {
+        // Set environment variable
+        setenv("TZ", identifier, 1)
+        tzset() // Make the change take effect
+
+        // Force update the default TimeZone
+        // This is the critical missing piece
+        if let timeZone = TimeZone(identifier: identifier) {
+            TimeZone.ReferenceType.default = timeZone
+
+            // For extra assurance, you can log to verify
+            print("Timezone set to: \(TimeZone.current.identifier)")
+        } else {
+            print("Failed to create TimeZone with identifier: \(identifier)")
+        }
+    }
+
+    // Helper function to reset timezone
+    func resetTimezone() {
+        // Restore system timezone from environment
+        if let originalTZ = originalTZ {
+            setenv("TZ", originalTZ, 1)
+        } else {
+            unsetenv("TZ")
+        }
+        tzset()
+
+        // Restore original default TimeZone
+        if let originalTimeZone = originalDefaultTimeZone {
+            TimeZone.ReferenceType.default = originalTimeZone
+        }
+    }
+}