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

Merge pull request #268 from kingst/makeprofile-js-to-swift-port

[Part 1 of 3] Include a native implementation of `makeProfile` (see #269)
Deniz Cengiz 1 год назад
Родитель
Сommit
526cd05d81
26 измененных файлов с 2112 добавлено и 2 удалено
  1. 136 0
      FreeAPS.xcodeproj/project.pbxproj
  2. 12 1
      FreeAPS.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme
  3. 63 1
      FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift
  4. 36 0
      FreeAPS/Sources/APS/OpenAPSSwift/Extensions/Date+MinutesFromMidnight.swift
  5. 19 0
      FreeAPS/Sources/APS/OpenAPSSwift/Extensions/Decimal+rounding.swift
  6. 9 0
      FreeAPS/Sources/APS/OpenAPSSwift/Extensions/InsulinSensitivities+Convert.swift
  7. 60 0
      FreeAPS/Sources/APS/OpenAPSSwift/JSONBridge.swift
  8. 37 0
      FreeAPS/Sources/APS/OpenAPSSwift/Models/ComputedBGTargets.swift
  9. 55 0
      FreeAPS/Sources/APS/OpenAPSSwift/Models/ComputedInsulinSensitivities.swift
  10. 138 0
      FreeAPS/Sources/APS/OpenAPSSwift/Models/Profile.swift
  11. 44 0
      FreeAPS/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift
  12. 35 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/Basal.swift
  13. 35 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/Carbs.swift
  14. 67 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/Isf.swift
  15. 33 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/ProfileError.swift
  16. 218 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift
  17. 94 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/Targets.swift
  18. 170 0
      FreeAPS/Sources/APS/OpenAPSSwift/Utils/JSONCompare.swift
  19. 29 0
      FreeAPS/Sources/APS/OpenAPSSwift/Utils/JavascriptOptional.swift
  20. 80 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileBasalTests.swift
  21. 60 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileCarbsTests.swift
  22. 62 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileIsfTests.swift
  23. 278 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileJavascriptTests.swift
  24. 95 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileJsNativeCompareTests.swift
  25. 102 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileTargetsTests.swift
  26. 145 0
      oref_swift_port_notes.md

+ 136 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -233,6 +233,28 @@
 		38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FB2737E53800574A46 /* MainStateModel.swift */; };
 		38FEF3FE2738083E00574A46 /* CGMProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FD2738083E00574A46 /* CGMProvider.swift */; };
 		38FEF413273B317A00574A46 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF412273B317A00574A46 /* HKUnit.swift */; };
+		3B5CD1EC2D4912A600CE213C /* OpenAPSSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD1E92D4912A600CE213C /* OpenAPSSwift.swift */; };
+		3B5CD1ED2D4912A600CE213C /* JSONBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD1EA2D4912A600CE213C /* JSONBridge.swift */; };
+		3B5CD2982D4AEA3C00CE213C /* Carbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2922D4AEA3C00CE213C /* Carbs.swift */; };
+		3B5CD2992D4AEA3C00CE213C /* Isf.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2932D4AEA3C00CE213C /* Isf.swift */; };
+		3B5CD29A2D4AEA3C00CE213C /* ProfileError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2942D4AEA3C00CE213C /* ProfileError.swift */; };
+		3B5CD29B2D4AEA3C00CE213C /* Basal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2912D4AEA3C00CE213C /* Basal.swift */; };
+		3B5CD29C2D4AEA3C00CE213C /* ProfileGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2952D4AEA3C00CE213C /* ProfileGenerator.swift */; };
+		3B5CD29D2D4AEA3C00CE213C /* Targets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2962D4AEA3C00CE213C /* Targets.swift */; };
+		3B5CD2A12D4AEA5100CE213C /* JavascriptOptional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD29E2D4AEA5100CE213C /* JavascriptOptional.swift */; };
+		3B5CD2A22D4AEA5100CE213C /* JSONCompare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD29F2D4AEA5100CE213C /* JSONCompare.swift */; };
+		3B5CD2A52D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2A32D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift */; };
+		3B5CD2B72D4AEA6600CE213C /* ComputedBGTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */; };
+		3B5CD2B82D4AEA6600CE213C /* ComputedInsulinSensitivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */; };
+		3B5CD2BE2D4AEA6600CE213C /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2AF2D4AEA6600CE213C /* Profile.swift */; };
+		3B5CD2C92D4AECD500CE213C /* ProfileCarbsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C32D4AECD500CE213C /* ProfileCarbsTests.swift */; };
+		3B5CD2CA2D4AECD500CE213C /* ProfileJavascriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C52D4AECD500CE213C /* ProfileJavascriptTests.swift */; };
+		3B5CD2CB2D4AECD500CE213C /* ProfileTargetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C62D4AECD500CE213C /* ProfileTargetsTests.swift */; };
+		3B5CD2CD2D4AECD500CE213C /* ProfileIsfTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C42D4AECD500CE213C /* ProfileIsfTests.swift */; };
+		3B5CD2CE2D4AECD500CE213C /* ProfileBasalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */; };
+		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 */; };
+		3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
 		45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; };
 		5075C1608E6249A51495C422 /* TargetsEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */; };
@@ -936,8 +958,30 @@
 		38FEF3FB2737E53800574A46 /* MainStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStateModel.swift; sourceTree = "<group>"; };
 		38FEF3FD2738083E00574A46 /* CGMProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMProvider.swift; sourceTree = "<group>"; };
 		38FEF412273B317A00574A46 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.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>"; };
+		3B5CD2912D4AEA3C00CE213C /* Basal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Basal.swift; sourceTree = "<group>"; };
+		3B5CD2922D4AEA3C00CE213C /* Carbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Carbs.swift; sourceTree = "<group>"; };
+		3B5CD2932D4AEA3C00CE213C /* Isf.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Isf.swift; sourceTree = "<group>"; };
+		3B5CD2942D4AEA3C00CE213C /* ProfileError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileError.swift; sourceTree = "<group>"; };
+		3B5CD2952D4AEA3C00CE213C /* ProfileGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileGenerator.swift; sourceTree = "<group>"; };
+		3B5CD2962D4AEA3C00CE213C /* Targets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Targets.swift; sourceTree = "<group>"; };
+		3B5CD29E2D4AEA5100CE213C /* JavascriptOptional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavascriptOptional.swift; sourceTree = "<group>"; };
+		3B5CD29F2D4AEA5100CE213C /* JSONCompare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCompare.swift; sourceTree = "<group>"; };
+		3B5CD2A32D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+MinutesFromMidnight.swift"; sourceTree = "<group>"; };
+		3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComputedBGTargets.swift; sourceTree = "<group>"; };
+		3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComputedInsulinSensitivities.swift; sourceTree = "<group>"; };
+		3B5CD2AF2D4AEA6600CE213C /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.swift; sourceTree = "<group>"; };
+		3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileBasalTests.swift; sourceTree = "<group>"; };
+		3B5CD2C32D4AECD500CE213C /* ProfileCarbsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCarbsTests.swift; sourceTree = "<group>"; };
+		3B5CD2C42D4AECD500CE213C /* ProfileIsfTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileIsfTests.swift; sourceTree = "<group>"; };
+		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>"; };
+		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>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.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>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
 		42369F66CF91F30624C0B3A6 /* BasalProfileEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorProvider.swift; sourceTree = "<group>"; };
@@ -1834,6 +1878,7 @@
 				38192E06261BA9960094D973 /* FetchTreatmentsManager.swift */,
 				3856933F270B57A00002C50D /* CGM */,
 				38A504F625DDA0E200C5B9E8 /* Extensions */,
+				3B5CD1EB2D4912A600CE213C /* OpenAPSSwift */,
 				388E5A5825B6F0070019842D /* OpenAPS */,
 				38A0362725ECF05300FCBB52 /* Storage */,
 			);
@@ -2284,10 +2329,79 @@
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
 				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
+				3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */,
 			);
 			path = FreeAPSTests;
 			sourceTree = "<group>";
 		};
+		3B5CD1EB2D4912A600CE213C /* OpenAPSSwift */ = {
+			isa = PBXGroup;
+			children = (
+				3B5CD2A42D4AEA5D00CE213C /* Extensions */,
+				3B5CD2B22D4AEA6600CE213C /* Models */,
+				3B5CD2972D4AEA3C00CE213C /* Profile */,
+				3B5CD2A02D4AEA5100CE213C /* Utils */,
+				3B5CD1E92D4912A600CE213C /* OpenAPSSwift.swift */,
+				3B5CD1EA2D4912A600CE213C /* JSONBridge.swift */,
+			);
+			path = OpenAPSSwift;
+			sourceTree = "<group>";
+		};
+		3B5CD2972D4AEA3C00CE213C /* Profile */ = {
+			isa = PBXGroup;
+			children = (
+				3B5CD2912D4AEA3C00CE213C /* Basal.swift */,
+				3B5CD2922D4AEA3C00CE213C /* Carbs.swift */,
+				3B5CD2932D4AEA3C00CE213C /* Isf.swift */,
+				3B5CD2942D4AEA3C00CE213C /* ProfileError.swift */,
+				3B5CD2952D4AEA3C00CE213C /* ProfileGenerator.swift */,
+				3B5CD2962D4AEA3C00CE213C /* Targets.swift */,
+			);
+			path = Profile;
+			sourceTree = "<group>";
+		};
+		3B5CD2A02D4AEA5100CE213C /* Utils */ = {
+			isa = PBXGroup;
+			children = (
+				3B5CD29E2D4AEA5100CE213C /* JavascriptOptional.swift */,
+				3B5CD29F2D4AEA5100CE213C /* JSONCompare.swift */,
+			);
+			path = Utils;
+			sourceTree = "<group>";
+		};
+		3B5CD2A42D4AEA5D00CE213C /* Extensions */ = {
+			isa = PBXGroup;
+			children = (
+				3B5CD2A32D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift */,
+				3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */,
+				3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */,
+			);
+			path = Extensions;
+			sourceTree = "<group>";
+		};
+		3B5CD2B22D4AEA6600CE213C /* Models */ = {
+			isa = PBXGroup;
+			children = (
+				3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */,
+				3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */,
+				3B5CD2AF2D4AEA6600CE213C /* Profile.swift */,
+			);
+			path = Models;
+			sourceTree = "<group>";
+		};
+		3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */ = {
+			isa = PBXGroup;
+			children = (
+				3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */,
+				3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */,
+				3B5CD2C32D4AECD500CE213C /* ProfileCarbsTests.swift */,
+				3B5CD2C42D4AECD500CE213C /* ProfileIsfTests.swift */,
+				3B5CD2C52D4AECD500CE213C /* ProfileJavascriptTests.swift */,
+				3B5CD2C62D4AECD500CE213C /* ProfileTargetsTests.swift */,
+			);
+			path = OpenAPSSwiftTests;
+			sourceTree = "<group>";
+		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -3456,6 +3570,7 @@
 				38B4F3CD25E5031100E76A18 /* Broadcaster.swift in Sources */,
 				383420D925FFEB3F002D46C1 /* Popup.swift in Sources */,
 				DD1745402C55BFC100211FAC /* AlgorithmAdvancedSettingsRootView.swift in Sources */,
+				3BCE75B32D4B38AE009E9453 /* InsulinSensitivities+Convert.swift in Sources */,
 				58645BA52CA2D347008AFCE7 /* ForecastSetup.swift in Sources */,
 				110AEDEE2C51A0AE00615CC9 /* ShortcutsConfigStateModel.swift in Sources */,
 				DD9ECB722CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift in Sources */,
@@ -3506,6 +3621,9 @@
 				BDC2EA452C3043B000E5BBD0 /* OverrideStorage.swift in Sources */,
 				3811DE4225C9D4A100A708ED /* SettingsDataFlow.swift in Sources */,
 				CEE9A6562BBB418300EB5194 /* CalibrationsRootView.swift in Sources */,
+				3B5CD2B72D4AEA6600CE213C /* ComputedBGTargets.swift in Sources */,
+				3B5CD2B82D4AEA6600CE213C /* ComputedInsulinSensitivities.swift in Sources */,
+				3B5CD2BE2D4AEA6600CE213C /* Profile.swift in Sources */,
 				3811DE2525C9D48300A708ED /* MainRootView.swift in Sources */,
 				CE94598229E9E3D30047C9C6 /* WatchConfigProvider.swift in Sources */,
 				DD1745322C55AE6000211FAC /* TargetBehavoirStateModel.swift in Sources */,
@@ -3549,6 +3667,12 @@
 				DD1745462C55C61500211FAC /* AutosensSettingsProvider.swift in Sources */,
 				DDA6E2852D2361F800C2988C /* LoopStatusView.swift in Sources */,
 				DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */,
+				3B5CD2982D4AEA3C00CE213C /* Carbs.swift in Sources */,
+				3B5CD2992D4AEA3C00CE213C /* Isf.swift in Sources */,
+				3B5CD29A2D4AEA3C00CE213C /* ProfileError.swift in Sources */,
+				3B5CD29B2D4AEA3C00CE213C /* Basal.swift in Sources */,
+				3B5CD29C2D4AEA3C00CE213C /* ProfileGenerator.swift in Sources */,
+				3B5CD29D2D4AEA3C00CE213C /* Targets.swift in Sources */,
 				DDA6E2502D22187500C2988C /* ChartLegendView.swift in Sources */,
 				3811DEAF25C9D88300A708ED /* KeyValueStorage.swift in Sources */,
 				DDD6D4D32CDE90720029439A /* HbA1cDisplayUnit.swift in Sources */,
@@ -3652,6 +3776,7 @@
 				38E989DD25F5021400C0CED0 /* PumpStatus.swift in Sources */,
 				BDFD165A2AE40438007F0DDA /* TreatmentsRootView.swift in Sources */,
 				38E98A2525F52C9300C0CED0 /* IssueReporter.swift in Sources */,
+				3BCE75B52D4B391F009E9453 /* Decimal+rounding.swift in Sources */,
 				DD1745522C55CA5D00211FAC /* UnitsLimitsSettingsStateModel.swift in Sources */,
 				DD2CC85C2D25DA1000445446 /* GlucoseTargetsView.swift in Sources */,
 				190EBCC429FF136900BA767D /* UserInterfaceSettingsDataFlow.swift in Sources */,
@@ -3768,6 +3893,8 @@
 				38E4453D274E411700EC9A94 /* Disk+Errors.swift in Sources */,
 				58D08B3A2C8DFECD00AA37D3 /* TempTargets.swift in Sources */,
 				38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */,
+				3B5CD1EC2D4912A600CE213C /* OpenAPSSwift.swift in Sources */,
+				3B5CD1ED2D4912A600CE213C /* JSONBridge.swift in Sources */,
 				CE7CA3542A064973004BE681 /* TempPresetsIntentRequest.swift in Sources */,
 				58A3D5442C96DE11003F90FC /* TempTargetStored+Helper.swift in Sources */,
 				58A3D5532C96EFA8003F90FC /* TempTargetRunStored+CoreDataClass.swift in Sources */,
@@ -3819,6 +3946,7 @@
 				FEFFA7A22929FE49007B8193 /* UIDevice+Extensions.swift in Sources */,
 				BD4E1A7C2D3686D900D21626 /* StartEndMarkerSetup.swift in Sources */,
 				F90692D3274B9A130037068D /* AppleHealthKitRootView.swift in Sources */,
+				3B5CD2A52D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift in Sources */,
 				BDF34F852C10C62E00D51995 /* GlucoseData.swift in Sources */,
 				19E1F7EC29D082FE005C8D20 /* IconConfigStateModel.swift in Sources */,
 				711C0CB42CAABE788916BC9D /* ManualTempBasalDataFlow.swift in Sources */,
@@ -3851,6 +3979,8 @@
 				DD1745292C55642100211FAC /* SettingInputSection.swift in Sources */,
 				38569349270B5DFB0002C50D /* AppGroupSource.swift in Sources */,
 				F5CA3DB1F9DC8B05792BBFAA /* CGMDataFlow.swift in Sources */,
+				3B5CD2A12D4AEA5100CE213C /* JavascriptOptional.swift in Sources */,
+				3B5CD2A22D4AEA5100CE213C /* JSONCompare.swift in Sources */,
 				BDF34F952C10D27300D51995 /* DeterminationData.swift in Sources */,
 				BA00D96F7B2FF169A06FB530 /* CGMStateModel.swift in Sources */,
 				BD7DA9A52AE06DFC00601B20 /* BolusCalculatorConfigDataFlow.swift in Sources */,
@@ -3939,7 +4069,13 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				3B5CD2C92D4AECD500CE213C /* ProfileCarbsTests.swift in Sources */,
+				3B5CD2CA2D4AECD500CE213C /* ProfileJavascriptTests.swift in Sources */,
+				3B5CD2CB2D4AECD500CE213C /* ProfileTargetsTests.swift in Sources */,
+				3B5CD2CD2D4AECD500CE213C /* ProfileIsfTests.swift in Sources */,
+				3B5CD2CE2D4AECD500CE213C /* ProfileBasalTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
+				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 			);

+ 12 - 1
FreeAPS.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme

@@ -264,7 +264,18 @@
       buildConfiguration = "Debug"
       selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
-      shouldUseLaunchSchemeArgsEnv = "YES">
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      codeCoverageEnabled = "YES"
+      onlyGenerateCoverageForSpecifiedTargets = "YES">
+      <CodeCoverageTargets>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "388E595725AD948C0019842D"
+            BuildableName = "FreeAPS.app"
+            BlueprintName = "FreeAPS"
+            ReferencedContainer = "container:FreeAPS.xcodeproj">
+         </BuildableReference>
+      </CodeCoverageTargets>
       <Testables>
          <TestableReference
             skipped = "NO">

+ 63 - 1
FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift

@@ -13,6 +13,8 @@ final class OpenAPS {
 
     let jsonConverter = JSONConverter()
 
+    private let enableNativeOref = false // TODO: Replace with a default-on setting
+
     init(storage: FileStorage) {
         self.storage = storage
     }
@@ -679,7 +681,8 @@ final class OpenAPS {
         }
     }
 
-    private func makeProfile(
+    // use `internal` protection to expose to unit tests
+    func makeProfileJavascript(
         preferences: JSON,
         pumpSettings: JSON,
         bgTargets: JSON,
@@ -715,6 +718,65 @@ final class OpenAPS {
         }
     }
 
+    private func makeProfile(
+        preferences: JSON,
+        pumpSettings: JSON,
+        bgTargets: JSON,
+        basalProfile: JSON,
+        isf: JSON,
+        carbRatio: JSON,
+        tempTargets: JSON,
+        model: JSON,
+        autotune: JSON,
+        freeaps: JSON
+    ) async throws -> RawJSON {
+        // TODO: Compare exceptions as well
+        let startJavascriptAt = Date()
+        let jsJson = try await makeProfileJavascript(
+            preferences: preferences,
+            pumpSettings: pumpSettings,
+            bgTargets: bgTargets,
+            basalProfile: basalProfile,
+            isf: isf,
+            carbRatio: carbRatio,
+            tempTargets: tempTargets,
+            model: model,
+            autotune: autotune,
+            freeaps: freeaps
+        )
+        let javascriptDuration = Date().timeIntervalSince(startJavascriptAt)
+
+        // Important: we want to make sure that this flag ensures that none
+        // of the native code runs
+        guard enableNativeOref else {
+            return jsJson
+        }
+
+        let startNativeAt = Date()
+        let nativeJson = OpenAPSSwift.makeProfile(
+            preferences: preferences,
+            pumpSettings: pumpSettings,
+            bgTargets: bgTargets,
+            basalProfile: basalProfile,
+            isf: isf,
+            carbRatio: carbRatio,
+            tempTargets: tempTargets,
+            model: model,
+            freeaps: freeaps
+        )
+        let nativeDuration = Date().timeIntervalSince(startNativeAt)
+
+        JSONCompare.logDifferences(
+            function: .makeProfile,
+            native: nativeJson,
+            nativeRuntime: nativeDuration,
+            javascript: jsJson,
+            javascriptRuntime: javascriptDuration
+        )
+
+        return jsJson
+    }
+
     private func loadJSON(name: String) -> String {
         try! String(contentsOf: Foundation.Bundle.main.url(forResource: "json/\(name)", withExtension: "json")!)
     }

+ 36 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Extensions/Date+MinutesFromMidnight.swift

@@ -0,0 +1,36 @@
+import Foundation
+
+enum MinutesFromMidnightError: LocalizedError, Equatable {
+    case invalidCalendar
+
+    var errorDescription: String? {
+        switch self {
+        case .invalidCalendar:
+            return "Unable to extract hours and minutes from the current calendar"
+        }
+    }
+}
+
+extension Date {
+    /// Returns the total minutes elapsed since midnight for the current date
+    private var minutesSinceMidnight: Int? {
+        let calendar = Calendar.current
+        let components = calendar.dateComponents([.hour, .minute], from: self)
+        guard let hour = components.hour, let minute = components.minute else {
+            return nil
+        }
+        return hour * 60 + minute
+    }
+
+    /// Checks if the current time falls within the specified range of minutes
+    /// - Parameters:
+    ///   - lowerBound: The lower bound in minutes since midnight (inclusive)
+    ///   - upperBound: The upper bound in minutes since midnight (exclusive)
+    /// - Returns: Boolean indicating if the current time is within the specified range
+    func isMinutesFromMidnightWithinRange(lowerBound: Int, upperBound: Int) throws -> Bool {
+        guard let currentMinutes = minutesSinceMidnight else {
+            throw MinutesFromMidnightError.invalidCalendar
+        }
+        return currentMinutes >= lowerBound && currentMinutes < upperBound
+    }
+}

+ 19 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Extensions/Decimal+rounding.swift

@@ -0,0 +1,19 @@
+import Foundation
+
+extension Decimal {
+    func rounded(scale: Int, roundingMode: NSDecimalNumber.RoundingMode = .plain) -> Decimal {
+        let handler = NSDecimalNumberHandler(
+            roundingMode: roundingMode,
+            scale: Int16(scale),
+            raiseOnExactness: false,
+            raiseOnOverflow: false,
+            raiseOnUnderflow: false,
+            raiseOnDivideByZero: false
+        )
+        return NSDecimalNumber(decimal: self).rounding(accordingToBehavior: handler).decimalValue
+    }
+
+    func rounded() -> Decimal {
+        rounded(scale: 0)
+    }
+}

+ 9 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Extensions/InsulinSensitivities+Convert.swift

@@ -0,0 +1,9 @@
+import Foundation
+
+extension InsulinSensitivities {
+    func computedInsulinSensitivies() -> ComputedInsulinSensitivities {
+        let sensitivities = self.sensitivities
+            .map { ComputedInsulinSensitivityEntry(sensitivity: $0.sensitivity, offset: $0.offset, start: $0.start) }
+        return ComputedInsulinSensitivities(units: units, userPreferredUnits: userPreferredUnits, sensitivities: sensitivities)
+    }
+}

+ 60 - 0
FreeAPS/Sources/APS/OpenAPSSwift/JSONBridge.swift

@@ -0,0 +1,60 @@
+import Foundation
+
+enum JSONError: Error {
+    case invalidString
+    case decodingFailed(Error)
+    case encodingFailed
+}
+
+enum JSONBridge {
+    static func preferences(from: JSON) throws -> Preferences {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func pumpSettings(from: JSON) throws -> PumpSettings {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func bgTargets(from: JSON) throws -> BGTargets {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func basalProfile(from: JSON) throws -> [BasalProfileEntry] {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func insulinSensitivities(from: JSON) throws -> InsulinSensitivities {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func carbRatios(from: JSON) throws -> CarbRatios {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func tempTargets(from: JSON) throws -> [TempTarget] {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func model(from: JSON) -> String {
+        from.rawJSON
+    }
+
+    static func freeapsSettings(from: JSON) throws -> FreeAPSSettings {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func from<T: Decodable>(string: String) throws -> T {
+        guard let data = string.data(using: .utf8) else {
+            throw JSONError.invalidString
+        }
+        return try JSONCoding.decoder.decode(T.self, from: data)
+    }
+
+    static func to<T: Encodable>(_ value: T) throws -> String {
+        let data = try JSONCoding.encoder.encode(value)
+        guard let string = String(data: data, encoding: .utf8) else {
+            throw JSONError.encodingFailed
+        }
+        return string
+    }
+}

+ 37 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Models/ComputedBGTargets.swift

@@ -0,0 +1,37 @@
+import Foundation
+
+struct ComputedBGTargetEntry: Codable {
+    var low: Decimal
+    var high: Decimal
+    var start: String
+    var offset: Int
+    var maxBg: Decimal?
+    var minBg: Decimal?
+    var temptargetSet: Bool?
+}
+
+extension ComputedBGTargetEntry {
+    private enum CodingKeys: String, CodingKey {
+        case low
+        case high
+        case start
+        case offset
+        case maxBg = "max_bg"
+        case minBg = "min_bg"
+        case temptargetSet = "temptarget_set"
+    }
+}
+
+struct ComputedBGTargets: Codable {
+    let units: GlucoseUnits
+    let userPreferredUnits: GlucoseUnits
+    var targets: [ComputedBGTargetEntry]
+}
+
+extension ComputedBGTargets {
+    private enum CodingKeys: String, CodingKey {
+        case units
+        case userPreferredUnits = "user_preferred_units"
+        case targets
+    }
+}

+ 55 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Models/ComputedInsulinSensitivities.swift

@@ -0,0 +1,55 @@
+import Foundation
+
+struct ComputedInsulinSensitivities: Codable {
+    let units: GlucoseUnits
+    let userPreferredUnits: GlucoseUnits
+    let sensitivities: [ComputedInsulinSensitivityEntry]
+}
+
+extension ComputedInsulinSensitivities {
+    private enum CodingKeys: String, CodingKey {
+        case units
+        case userPreferredUnits = "user_preferred_units"
+        case sensitivities
+    }
+}
+
+struct ComputedInsulinSensitivityEntry: Codable {
+    let sensitivity: Decimal
+    let offset: Int
+    let start: String
+    var endOffset: Int?
+    let id: UUID // we use this to help with mutating inputs, we don't serialize it
+
+    init(sensitivity: Decimal, offset: Int, start: String, endOffset: Int? = nil, id: UUID? = nil) {
+        self.sensitivity = sensitivity
+        self.offset = offset
+        self.start = start
+        self.endOffset = endOffset
+        self.id = id ?? UUID()
+    }
+
+    enum CodingKeys: CodingKey {
+        case sensitivity
+        case offset
+        case start
+        case endOffset
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(sensitivity, forKey: .sensitivity)
+        try container.encode(offset, forKey: .offset)
+        try container.encode(start, forKey: .start)
+        try container.encodeIfPresent(endOffset, forKey: .endOffset)
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        sensitivity = try container.decode(Decimal.self, forKey: .sensitivity)
+        offset = try container.decode(Int.self, forKey: .offset)
+        start = try container.decode(String.self, forKey: .start)
+        endOffset = try container.decodeIfPresent(Int.self, forKey: .endOffset)
+        id = UUID()
+    }
+}

+ 138 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Models/Profile.swift

@@ -0,0 +1,138 @@
+import Foundation
+
+struct Profile: Codable {
+    // Kotlin-defined properties from AndroidAPS OapsProfile.kt
+    // with defaults pulled from profile.js
+    var dia: Decimal?
+    var min5mCarbImpact: Decimal = 8
+    var maxIob: Decimal = 0 // if max_iob is not provided, will default to zero
+    var maxDailyBasal: Decimal?
+    var maxBasal: Decimal?
+    var minBg: Decimal?
+    var maxBg: Decimal?
+    @JavascriptOptional var targetBg: Decimal?
+    var smbDeliveryRatio: Decimal = 0.5
+    var carbRatio: Decimal?
+    var sens: Decimal?
+    var maxDailySafetyMultiplier: Decimal = 3
+    var currentBasalSafetyMultiplier: Decimal = 4
+    var highTemptargetRaisesSensitivity: Bool = false // raise sensitivity for temptargets >= 101
+    var lowTemptargetLowersSensitivity: Bool = false // lower sensitivity for temptargets <= 99
+    var sensitivityRaisesTarget: Bool = false // raise BG target when autosens detects sensitivity
+    var resistanceLowersTarget: Bool = false // lower BG target when autosens detects resistance
+    var halfBasalExerciseTarget: Decimal = 160 // when temptarget is 160 mg/dL *and* exercise_mode=true, run 50% basal
+    var maxCOB: Decimal = 120 // maximum carbs a typical body can absorb over 4 hours
+    var skipNeutralTemps: Bool = false
+    var remainingCarbsCap: Decimal = 90
+    var enableUAM: Bool = false
+    var a52RiskEnable: Bool = false
+    var smbInterval: Decimal = 3
+    var enableSMBWithCOB: Bool = false
+    var enableSMBWithTemptarget: Bool = false
+    var allowSMBWithHighTemptarget: Bool = false
+    var enableSMBAlways: Bool = false
+    var enableSMBAfterCarbs: Bool = false
+    var maxSMBBasalMinutes: Decimal = 30
+    var maxUAMSMBBasalMinutes: Decimal = 30
+    var bolusIncrement: Decimal = 0.1
+    var carbsReqThreshold: Decimal = 1
+    var currentBasal: Decimal?
+    var temptargetSet: Bool?
+    var autosensMax: Decimal = 1.2
+    var outUnits: String?
+
+    // Additional properties
+    var autosensMin: Decimal = 0.7
+    var rewindResetsAutosens: Bool = true
+    var remainingCarbsFraction: Decimal = 1.0
+    var unsuspendIfNoTemp: Bool = false
+    var autotuneIsfAdjustmentFraction: Decimal = 1.0
+    var enableSMBHighBg: Bool = false
+    var enableSMBHighBgTarget: Decimal = 110
+    var maxDeltaBgThreshold: Decimal = 0.2
+    var curve: InsulinCurve = .rapidActing
+    var useCustomPeakTime: Bool = false
+    var insulinPeakTime: Decimal = 75
+    var noisyCGMTargetMultiplier: Decimal = 1.3
+    var suspendZerosIob: Bool = true
+    var calcGlucoseNoise: Bool = false
+    var adjustmentFactor: Decimal = 0.8
+    var adjustmentFactorSigmoid: Decimal = 0.5
+    var useNewFormula: Bool = false
+    var enableDynamicCR: Bool = false
+    var sigmoid: Bool = false
+    var weightPercentage: Decimal = 0.65
+    var tddAdjBasal: Bool = false
+    var thresholdSetting: Decimal = 60
+    var model: String?
+    var basalprofile: [BasalProfileEntry]?
+    var isfProfile: ComputedInsulinSensitivities?
+    var bgTargets: ComputedBGTargets?
+    var carbRatios: CarbRatios?
+
+    private enum CodingKeys: String, CodingKey {
+        case dia
+        case min5mCarbImpact = "min_5m_carbimpact"
+        case maxIob = "max_iob"
+        case maxDailyBasal = "max_daily_basal"
+        case maxBasal = "max_basal"
+        case minBg = "min_bg"
+        case maxBg = "max_bg"
+        case targetBg = "target_bg"
+        case smbDeliveryRatio = "smb_delivery_ratio"
+        case carbRatio = "carb_ratio"
+        case sens
+        case maxDailySafetyMultiplier = "max_daily_safety_multiplier"
+        case currentBasalSafetyMultiplier = "current_basal_safety_multiplier"
+        case highTemptargetRaisesSensitivity = "high_temptarget_raises_sensitivity"
+        case lowTemptargetLowersSensitivity = "low_temptarget_lowers_sensitivity"
+        case sensitivityRaisesTarget = "sensitivity_raises_target"
+        case resistanceLowersTarget = "resistance_lowers_target"
+        case halfBasalExerciseTarget = "half_basal_exercise_target"
+        case maxCOB
+        case skipNeutralTemps = "skip_neutral_temps"
+        case remainingCarbsCap
+        case enableUAM
+        case a52RiskEnable = "A52_risk_enable"
+        case smbInterval = "SMBInterval"
+        case enableSMBWithCOB = "enableSMB_with_COB"
+        case enableSMBWithTemptarget = "enableSMB_with_temptarget"
+        case allowSMBWithHighTemptarget = "allowSMB_with_high_temptarget"
+        case enableSMBAlways = "enableSMB_always"
+        case enableSMBAfterCarbs = "enableSMB_after_carbs"
+        case maxSMBBasalMinutes
+        case maxUAMSMBBasalMinutes
+        case bolusIncrement = "bolus_increment"
+        case carbsReqThreshold
+        case currentBasal = "current_basal"
+        case temptargetSet
+        case autosensMax = "autosens_max"
+        case outUnits = "out_units"
+        case autosensMin = "autosens_min"
+        case rewindResetsAutosens = "rewind_resets_autosens"
+        case remainingCarbsFraction
+        case unsuspendIfNoTemp = "unsuspend_if_no_temp"
+        case autotuneIsfAdjustmentFraction = "autotune_isf_adjustmentFraction"
+        case enableSMBHighBg = "enableSMB_high_bg"
+        case enableSMBHighBgTarget = "enableSMB_high_bg_target"
+        case maxDeltaBgThreshold = "maxDelta_bg_threshold"
+        case curve
+        case useCustomPeakTime
+        case insulinPeakTime
+        case noisyCGMTargetMultiplier
+        case suspendZerosIob = "suspend_zeros_iob"
+        case adjustmentFactor
+        case adjustmentFactorSigmoid
+        case useNewFormula
+        case enableDynamicCR
+        case sigmoid
+        case weightPercentage
+        case tddAdjBasal
+        case thresholdSetting = "threshold_setting"
+        case model
+        case basalprofile
+        case isfProfile
+        case bgTargets = "bg_targets"
+        case carbRatios = "carb_ratios"
+    }
+}

+ 44 - 0
FreeAPS/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -0,0 +1,44 @@
+import Foundation
+
+struct OpenAPSSwift {
+    static func makeProfile(
+        preferences: JSON,
+        pumpSettings: JSON,
+        bgTargets: JSON,
+        basalProfile: JSON,
+        isf: JSON,
+        carbRatio: JSON,
+        tempTargets: JSON,
+        model: JSON,
+        freeaps: JSON
+    ) -> RawJSON {
+        do {
+            let preferences = try JSONBridge.preferences(from: preferences)
+            let pumpSettings = try JSONBridge.pumpSettings(from: pumpSettings)
+            let bgTargets = try JSONBridge.bgTargets(from: bgTargets)
+            let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
+            let isf = try JSONBridge.insulinSensitivities(from: isf)
+            let carbRatio = try JSONBridge.carbRatios(from: carbRatio)
+            let tempTargets = try JSONBridge.tempTargets(from: tempTargets)
+            let model = JSONBridge.model(from: model)
+            let freeaps = try JSONBridge.freeapsSettings(from: freeaps)
+
+            let profile = try ProfileGenerator.generate(
+                pumpSettings: pumpSettings,
+                bgTargets: bgTargets,
+                basalProfile: basalProfile,
+                isf: isf,
+                preferences: preferences,
+                carbRatios: carbRatio,
+                tempTargets: tempTargets,
+                model: model,
+                freeaps: freeaps
+            )
+
+            return try JSONBridge.to(profile)
+        } catch {
+            warning(.openAPS, "OpenAPSSwift exception \(error)")
+            return .null
+        }
+    }
+}

+ 35 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Profile/Basal.swift

@@ -0,0 +1,35 @@
+import Foundation
+
+struct Basal {
+    static func basalLookup(_ basalProfile: [BasalProfileEntry], now: Date? = nil) throws -> Decimal? {
+        let nowDate = now ?? Date()
+
+        // Original had a sort but it was a no-op if 'i' wasn't present, so we can skip it
+        let basalProfileData = basalProfile
+
+        guard let lastBasalRate = basalProfileData.last?.rate, lastBasalRate != 0 else {
+            warning(.openAPS, "Warning: bad basal schedule \(basalProfile)")
+            return nil
+        }
+
+        // Look for matching time slot
+        for (curr, next) in zip(basalProfileData, basalProfileData.dropFirst()) {
+            if try nowDate.isMinutesFromMidnightWithinRange(lowerBound: curr.minutes, upperBound: next.minutes) {
+                return curr.rate.rounded(scale: 3)
+            }
+        }
+
+        // If no matching slot found, return last basal rate
+        return lastBasalRate.rounded(scale: 3)
+    }
+
+    static func maxDailyBasal(_ basalProfile: [BasalProfileEntry]) -> Decimal? {
+        guard let maxBasal = basalProfile.map(\.rate).max() else {
+            return nil
+        }
+
+        // In Javascript Number is floating point, so we don't need to do
+        // the * 1000 / 1000
+        return maxBasal
+    }
+}

+ 35 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Profile/Carbs.swift

@@ -0,0 +1,35 @@
+import Foundation
+
+struct Carbs {
+    static func carbRatioLookup(carbRatio: CarbRatios, now: Date = Date()) -> Decimal? {
+        // Get last schedule as default
+        guard let lastSchedule = carbRatio.schedule.last else { return nil }
+        var currentRatio = lastSchedule.ratio
+
+        // Find matching schedule for current time
+        do {
+            for (curr, next) in zip(carbRatio.schedule, carbRatio.schedule.dropFirst()) {
+                if try now.isMinutesFromMidnightWithinRange(lowerBound: curr.offset, upperBound: next.offset) {
+                    currentRatio = curr.ratio
+                    break
+                }
+            }
+        } catch {
+            return nil
+        }
+
+        // Check for invalid values
+        if currentRatio < 3 || currentRatio > 150 {
+            warning(.openAPS, "Warning: carbRatio of \(currentRatio) out of bounds.")
+            return nil
+        }
+
+        // Convert exchanges to grams
+        switch carbRatio.units {
+        case .exchanges:
+            return 12 / currentRatio
+        case .grams:
+            return currentRatio
+        }
+    }
+}

+ 67 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Profile/Isf.swift

@@ -0,0 +1,67 @@
+import Foundation
+
+// I removed the cache that the Javascript version has to help keep it simple
+struct Isf {
+    static func isfLookup(
+        isfDataInput: InsulinSensitivities,
+        timestamp: Date? = nil
+    ) throws -> (Decimal, ComputedInsulinSensitivities) {
+        let now = timestamp ?? Date()
+
+        let isfData = isfDataInput.computedInsulinSensitivies()
+
+        // Sort sensitivities by offset
+        let sortedSensitivities = isfData.sensitivities.sorted { $0.offset < $1.offset }
+
+        // Verify first offset is 0
+        guard let firstSensitivity = sortedSensitivities.first,
+              firstSensitivity.offset == 0
+        else {
+            return (-1, isfData)
+        }
+
+        // Default to last entry
+        guard var isfSchedule = sortedSensitivities.last else {
+            return (-1, isfData)
+        }
+
+        var endMinutes = 1440
+
+        // Find matching sensitivity for current time
+        for (curr, next) in zip(sortedSensitivities, sortedSensitivities.dropFirst()) {
+            if try now.isMinutesFromMidnightWithinRange(lowerBound: curr.offset, upperBound: next.offset) {
+                endMinutes = next.offset
+                isfSchedule = curr
+                break
+            }
+        }
+
+        // in the Javascript implementation they cache the last entry
+        // which we don't do, but in the process they mutate the input
+        // which is visible in Profile. This logic is to update our
+        // input with the new endOffset parameter
+
+        let updatedSchedule = isfData.sensitivities.map { sensitivity in
+            if sensitivity.id == isfSchedule.id {
+                return ComputedInsulinSensitivityEntry(
+                    sensitivity: sensitivity.sensitivity,
+                    offset: sensitivity.offset,
+                    start: sensitivity.start,
+                    endOffset: endMinutes,
+                    id: sensitivity.id
+                )
+            } else {
+                return sensitivity
+            }
+        }
+
+        return (
+            isfSchedule.sensitivity,
+            ComputedInsulinSensitivities(
+                units: isfData.units,
+                userPreferredUnits: isfData.userPreferredUnits,
+                sensitivities: updatedSchedule
+            )
+        )
+    }
+}

+ 33 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Profile/ProfileError.swift

@@ -0,0 +1,33 @@
+import Foundation
+
+enum ProfileError: LocalizedError, Equatable {
+    case invalidDIA(value: Decimal)
+    case invalidCurrentBasal(value: Decimal?)
+    case invalidMaxDailyBasal(value: Decimal?)
+    case invalidMaxBasal(value: Decimal?)
+    case invalidISF(value: Decimal?)
+    case invalidCarbRatio
+    case invalidBgTargets
+    case invalidCalendar
+
+    var errorDescription: String? {
+        switch self {
+        case let .invalidDIA(value):
+            return "DIA of \(String(describing: value)) is not supported (must be > 1)"
+        case let .invalidCurrentBasal(value):
+            return "Current basal of \(String(describing: value)) is not supported (must be > 0)"
+        case let .invalidMaxDailyBasal(value):
+            return "Max daily basal of \(String(describing: value)) is not supported (must be > 0)"
+        case let .invalidMaxBasal(value):
+            return "Max basal of \(String(describing: value)) is not supported (must be >= 0.1)"
+        case let .invalidISF(value):
+            return "ISF of \(String(describing: value)) is not supported (must be >= 5)"
+        case .invalidCarbRatio:
+            return "Profile wasn't given carb ratio data, cannot calculate carb_ratio"
+        case .invalidBgTargets:
+            return "Profile wasn't given bg target data"
+        case .invalidCalendar:
+            return "Unable to extract hours and minutes from the current calendar"
+        }
+    }
+}

+ 218 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift

@@ -0,0 +1,218 @@
+import Foundation
+
+extension Profile {
+    /// Updates profile properties from preferences where CodingKeys match
+    /// This function ended up being pretty ugly, but I couldn't think of a cleaner
+    /// way. I considered converting to JSON or using Mirror, but these weren't
+    /// great so in the end I think that this approach is simpliest.
+    ///
+    /// Also, this implementation does _not_ copy any of the optional properties
+    /// since these should get set in the `generate` method.
+    mutating func update(from preferences: Preferences) {
+        // Decimal properties
+        maxIob = preferences.maxIOB
+        min5mCarbImpact = preferences.min5mCarbimpact
+        maxCOB = preferences.maxCOB
+        maxDailySafetyMultiplier = preferences.maxDailySafetyMultiplier
+        currentBasalSafetyMultiplier = preferences.currentBasalSafetyMultiplier
+        autosensMax = preferences.autosensMax
+        autosensMin = preferences.autosensMin
+        halfBasalExerciseTarget = preferences.halfBasalExerciseTarget
+        remainingCarbsCap = preferences.remainingCarbsCap
+        smbInterval = preferences.smbInterval
+        maxSMBBasalMinutes = preferences.maxSMBBasalMinutes
+        maxUAMSMBBasalMinutes = preferences.maxUAMSMBBasalMinutes
+        bolusIncrement = preferences.bolusIncrement
+        carbsReqThreshold = preferences.carbsReqThreshold
+        remainingCarbsFraction = preferences.remainingCarbsFraction
+        enableSMBHighBgTarget = preferences.enableSMB_high_bg_target
+        maxDeltaBgThreshold = preferences.maxDeltaBGthreshold
+        insulinPeakTime = preferences.insulinPeakTime
+        noisyCGMTargetMultiplier = preferences.noisyCGMTargetMultiplier
+        adjustmentFactor = preferences.adjustmentFactor
+        adjustmentFactorSigmoid = preferences.adjustmentFactorSigmoid
+        weightPercentage = preferences.weightPercentage
+        thresholdSetting = preferences.threshold_setting
+
+        // Bool properties
+        highTemptargetRaisesSensitivity = preferences.highTemptargetRaisesSensitivity
+        lowTemptargetLowersSensitivity = preferences.lowTemptargetLowersSensitivity
+        sensitivityRaisesTarget = preferences.sensitivityRaisesTarget
+        resistanceLowersTarget = preferences.resistanceLowersTarget
+        skipNeutralTemps = preferences.skipNeutralTemps
+        enableUAM = preferences.enableUAM
+        a52RiskEnable = preferences.a52RiskEnable
+        enableSMBWithCOB = preferences.enableSMBWithCOB
+        enableSMBWithTemptarget = preferences.enableSMBWithTemptarget
+        allowSMBWithHighTemptarget = preferences.allowSMBWithHighTemptarget
+        enableSMBAlways = preferences.enableSMBAlways
+        enableSMBAfterCarbs = preferences.enableSMBAfterCarbs
+        rewindResetsAutosens = preferences.rewindResetsAutosens
+        unsuspendIfNoTemp = preferences.unsuspendIfNoTemp
+        enableSMBHighBg = preferences.enableSMB_high_bg
+        useCustomPeakTime = preferences.useCustomPeakTime
+        suspendZerosIob = preferences.suspendZerosIOB
+        useNewFormula = preferences.useNewFormula
+        enableDynamicCR = preferences.enableDynamicCR
+        sigmoid = preferences.sigmoid
+        tddAdjBasal = preferences.tddAdjBasal
+
+        // Enum properties
+        curve = preferences.curve
+    }
+}
+
+enum ProfileGenerator {
+    /// This function is a port of the prepare/profile.js function from Trio, and it calls the core OpenAPS function
+    static func generate(
+        pumpSettings: PumpSettings,
+        bgTargets: BGTargets,
+        basalProfile: [BasalProfileEntry],
+        isf: InsulinSensitivities,
+        preferences: Preferences,
+        carbRatios: CarbRatios,
+        tempTargets: [TempTarget],
+        model: String,
+        freeaps _: FreeAPSSettings
+    ) throws -> Profile {
+        let model = model.replacingOccurrences(of: "\"", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
+
+        guard !carbRatios.schedule.isEmpty else {
+            throw ProfileError.invalidCarbRatio
+        }
+
+        var preferences = preferences
+        switch (preferences.curve, preferences.useCustomPeakTime) {
+        case (.rapidActing, true):
+            preferences.insulinPeakTime = max(50, min(preferences.insulinPeakTime, 120))
+        case (.rapidActing, false):
+            preferences.insulinPeakTime = 75
+        case (.ultraRapid, true):
+            preferences.insulinPeakTime = max(35, min(preferences.insulinPeakTime, 100))
+        case (.ultraRapid, false):
+            preferences.insulinPeakTime = 55
+        default:
+            // don't do anything
+            debug(.openAPS, "don't modify insulin peak time")
+        }
+
+        return try generate(
+            pumpSettings: pumpSettings,
+            bgTargets: bgTargets,
+            basalProfile: basalProfile,
+            isf: isf,
+            preferences: preferences,
+            carbRatios: carbRatios,
+            tempTargets: tempTargets,
+            model: model
+        )
+    }
+
+    /// Direct port of the OpenAPS profile generate function
+    static func generate(
+        pumpSettings: PumpSettings,
+        bgTargets: BGTargets,
+        basalProfile: [BasalProfileEntry],
+        isf: InsulinSensitivities,
+        preferences: Preferences,
+        carbRatios: CarbRatios,
+        tempTargets: [TempTarget],
+        model: String
+    ) throws -> Profile {
+        var profile = Profile() // start with the defaults
+
+        // check if inputs has overrides for any of the default prefs
+        // and apply if applicable. Note, this comes from the generate/profile.js
+        // where preferences get copied to the input then in the generate function
+        // where it checks the input for properties that match the defaults
+        profile.update(from: preferences)
+
+        // in the Javascript version this check is for 1, but in Trio
+        // the minimum dia you can set with the UI is 5
+        guard pumpSettings.insulinActionCurve >= 5 else {
+            throw ProfileError.invalidDIA(value: pumpSettings.insulinActionCurve)
+        }
+        profile.dia = pumpSettings.insulinActionCurve
+
+        profile.model = model
+        profile.skipNeutralTemps = preferences.skipNeutralTemps
+
+        profile.currentBasal = try Basal.basalLookup(basalProfile)
+        profile.basalprofile = basalProfile
+
+        let basalProfile = basalProfile
+            .map { BasalProfileEntry(start: $0.start, minutes: $0.minutes, rate: $0.rate.rounded(scale: 3)) }
+
+        profile.maxDailyBasal = Basal.maxDailyBasal(basalProfile)
+        profile.maxBasal = pumpSettings.maxBasal
+
+        // Error check: profile.currentBasal === 0 in Javascript
+        if let currentBasal = profile.currentBasal {
+            guard currentBasal != 0 else {
+                throw ProfileError.invalidCurrentBasal(value: profile.currentBasal)
+            }
+        }
+
+        // Error check: profile.max_daily_basal === 0 in Javascript
+        if let maxDailyBasal = profile.maxDailyBasal {
+            guard maxDailyBasal != 0 else {
+                throw ProfileError.invalidMaxDailyBasal(value: profile.maxDailyBasal)
+            }
+        }
+
+        // Error check: profile.max_basal < 0.1 in Javascript
+        if let maxBasal = profile.maxBasal {
+            guard maxBasal >= 0.1 else {
+                throw ProfileError.invalidMaxBasal(value: profile.maxBasal)
+            }
+        }
+
+        profile.outUnits = bgTargets.userPreferredUnits.rawValue
+        let (updatedTargets, range) = try Targets.bgTargetsLookup(targets: bgTargets, tempTargets: tempTargets, profile: profile)
+        profile.minBg = range.minBg?.rounded()
+        profile.maxBg = range.maxBg?.rounded()
+        // Note: we're using updatedTargets here because in Javascript the bgTargetsLookup
+        // function mutates the input, so we want the mutated version in the
+        // profile and we need to round the properties
+        let roundedTargets = updatedTargets.targets.map { target -> ComputedBGTargetEntry in
+            ComputedBGTargetEntry(
+                low: target.low.rounded(),
+                high: target.high.rounded(),
+                start: target.start,
+                offset: target.offset,
+                maxBg: target.maxBg?.rounded(),
+                minBg: target.minBg?.rounded(),
+                temptargetSet: target.temptargetSet
+            )
+        }
+
+        // Set the rounded targets on the profile
+        profile.bgTargets = ComputedBGTargets(
+            units: updatedTargets.units,
+            userPreferredUnits: updatedTargets.userPreferredUnits,
+            targets: roundedTargets
+        )
+
+        profile.temptargetSet = range.temptargetSet
+        let (sens, isfUpdated) = try Isf.isfLookup(isfDataInput: isf)
+        profile.sens = sens
+        profile.isfProfile = isfUpdated
+
+        // Error check: profile.sens < 5 in Javascript
+        if let sens = profile.sens {
+            guard sens >= 5 else {
+                debug(.openAPS, "ISF of \(String(describing: profile.sens)) is not supported")
+                throw ProfileError.invalidISF(value: profile.sens)
+            }
+        }
+
+        // Handle carb ratio data
+        guard let currentCarbRatio = Carbs.carbRatioLookup(carbRatio: carbRatios) else {
+            throw ProfileError.invalidCarbRatio
+        }
+        profile.carbRatio = currentCarbRatio
+        profile.carbRatios = carbRatios
+
+        return profile
+    }
+}

+ 94 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Profile/Targets.swift

@@ -0,0 +1,94 @@
+import Foundation
+
+struct Targets {
+    // The Javascript implementation was hard to port because it
+    // mutates the inputs in a way that is visible in the Profile.
+    //
+    //  TODO: See if we can get rid of the logic that mutates inputs in Javascript
+    static func lookup(
+        targets: BGTargets,
+        tempTargets: [TempTarget],
+        profile: Profile,
+        now: Date
+    ) throws -> (ComputedBGTargets, Int) {
+        // Find current target
+        var bgComputedTargets = targets.targets
+            .map { ComputedBGTargetEntry(low: $0.low, high: $0.high, start: $0.start, offset: $0.offset) }
+
+        guard !bgComputedTargets.isEmpty else {
+            throw ProfileError.invalidBgTargets
+        }
+
+        var targetIdx = bgComputedTargets.count - 1
+        for (idx, (curr, next)) in zip(bgComputedTargets, bgComputedTargets.dropFirst()).enumerated() {
+            if try now.isMinutesFromMidnightWithinRange(lowerBound: curr.offset, upperBound: next.offset) {
+                targetIdx = idx
+                break
+            }
+        }
+
+        // Apply profile target if specified
+        if let targetBg = profile.targetBg {
+            bgComputedTargets[targetIdx].low = targetBg
+        }
+        bgComputedTargets[targetIdx].high = bgComputedTargets[targetIdx].low
+
+        // Handle temp targets
+        let sortedTempTargets = tempTargets.sorted { $0.createdAt > $1.createdAt }
+
+        for target in sortedTempTargets {
+            let start = target.createdAt
+            let expires = start.addingTimeInterval(Double(target.duration) * 60)
+
+            if now >= start, target.duration == 0 {
+                // Cancel temp targets
+                break
+            } else if let targetBottom = target.targetBottom,
+                      let targetTop = target.targetTop
+            {
+                if now >= start, now < expires {
+                    bgComputedTargets[targetIdx].high = targetTop
+                    bgComputedTargets[targetIdx].low = targetBottom
+                    bgComputedTargets[targetIdx].temptargetSet = true
+                    break
+                }
+            } else {
+                warning(.openAPS, "eventualBG target range invalid: \(target.targetBottom ?? -1)-\(target.targetTop ?? -1)")
+                break
+            }
+        }
+
+        return (
+            ComputedBGTargets(units: targets.units, userPreferredUnits: targets.userPreferredUnits, targets: bgComputedTargets),
+            targetIdx
+        )
+    }
+
+    static func boundTargetRange(_ entry: ComputedBGTargetEntry) -> ComputedBGTargetEntry {
+        var target = entry
+
+        // hard-code lower bounds for min_bg and max_bg in case pump is set too low, or units are wrong
+        var maxBg = max(80, target.high)
+        var minBg = max(80, target.low)
+        // hard-code upper bound for min_bg in case pump is set too high
+        minBg = min(200, minBg)
+        maxBg = min(200, maxBg)
+
+        target.minBg = minBg
+        target.maxBg = maxBg
+
+        return target
+    }
+
+    static func bgTargetsLookup(
+        targets: BGTargets,
+        tempTargets: [TempTarget],
+        profile: Profile,
+        now: Date = Date()
+    ) throws -> (ComputedBGTargets, ComputedBGTargetEntry) {
+        var (computedBgTargets, targetIdx) = try lookup(targets: targets, tempTargets: tempTargets, profile: profile, now: now)
+        let currentTarget = boundTargetRange(computedBgTargets.targets[targetIdx])
+        computedBgTargets.targets[targetIdx] = currentTarget
+        return (computedBgTargets, currentTarget)
+    }
+}

+ 170 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Utils/JSONCompare.swift

@@ -0,0 +1,170 @@
+import Foundation
+
+enum JSONValue: Codable {
+    case string(String)
+    case number(Double)
+    case boolean(Bool)
+    case array([JSONValue])
+    case object([String: JSONValue])
+    case null
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.singleValueContainer()
+
+        if container.decodeNil() {
+            self = .null
+            return
+        }
+
+        if let string = try? container.decode(String.self) {
+            self = .string(string)
+        } else if let number = try? container.decode(Double.self) {
+            self = .number(number)
+        } else if let boolean = try? container.decode(Bool.self) {
+            self = .boolean(boolean)
+        } else if let array = try? container.decode([JSONValue].self) {
+            self = .array(array)
+        } else if let object = try? container.decode([String: JSONValue].self) {
+            self = .object(object)
+        } else {
+            throw DecodingError.typeMismatch(JSONValue.self, DecodingError.Context(
+                codingPath: decoder.codingPath,
+                debugDescription: "Invalid JSON value"
+            ))
+        }
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.singleValueContainer()
+        switch self {
+        case let .string(value): try container.encode(value)
+        case let .number(value): try container.encode(value)
+        case let .boolean(value): try container.encode(value)
+        case let .array(value): try container.encode(value)
+        case let .object(value): try container.encode(value)
+        case .null: try container.encodeNil()
+        }
+    }
+}
+
+struct ValueDifference: Codable {
+    let js: JSONValue
+    let native: JSONValue
+    let jsKeyMissing: Bool
+    let nativeKeyMissing: Bool
+}
+
+enum JSONCompare {
+    enum Function {
+        case makeProfile
+
+        // since we're removing some keys from our Profile that exist in Javascript
+        // we need to let the difference function know which keys to ignore when
+        // calculating differences
+        func keysToIgnore() -> Set<String> {
+            switch self {
+            case .makeProfile:
+                return Set(["calc_glucose_noise", "enableEnliteBgproxy", "exercise_mode", "offline_hotspot"])
+            }
+        }
+    }
+
+    static func logDifferences(
+        function: Function,
+        native: String,
+        nativeRuntime: TimeInterval,
+        javascript: String,
+        javascriptRuntime: TimeInterval
+    ) {
+        guard let differences = try? differences(function: function, native: native, javascript: javascript) else {
+            warning(.openAPS, "Exception calculating differences")
+            return
+        }
+
+        // TODO: For now we'll just print this out to the console but we'll add proper logging next
+        debug(.openAPS, "\(function) -> n: \(nativeRuntime)s, js: \(javascriptRuntime)s")
+        prettyPrint(differences)
+    }
+
+    static func prettyPrint(_ differences: [String: ValueDifference]) {
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+
+        if let data = try? encoder.encode(differences),
+           let prettyString = String(data: data, encoding: .utf8)
+        {
+            debug(.openAPS, prettyString)
+        }
+    }
+
+    static func differences(function: Function, native: String, javascript: String) throws -> [String: ValueDifference] {
+        guard let jsData = javascript.data(using: .utf8),
+              let nativeData = native.data(using: .utf8),
+              let jsDict = try JSONSerialization.jsonObject(with: jsData) as? [String: Any],
+              let nativeDict = try JSONSerialization.jsonObject(with: nativeData) as? [String: Any]
+        else {
+            throw NSError(domain: "JSONBridge", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format"])
+        }
+
+        var differences: [String: ValueDifference] = [:]
+
+        // Check all keys present in either dictionary
+        Set(jsDict.keys).union(nativeDict.keys).forEach { key in
+            let jsValue = jsDict[key].map(convertToJSONValue) ?? .null
+            let nativeValue = nativeDict[key].map(convertToJSONValue) ?? .null
+
+            if !valuesAreEqual(jsValue, nativeValue) {
+                differences[key] = ValueDifference(
+                    js: jsValue,
+                    native: nativeValue,
+                    jsKeyMissing: !jsDict.keys.contains(key),
+                    nativeKeyMissing: !nativeDict.keys.contains(key)
+                )
+            }
+        }
+
+        let keysToIgnore = function.keysToIgnore()
+        return differences.filter { !keysToIgnore.contains($0.key) }
+    }
+
+    private static func convertToJSONValue(_ value: Any) -> JSONValue {
+        switch value {
+        case let string as String:
+            return .string(string)
+        case let number as NSNumber:
+            return .number(number.doubleValue)
+        case let bool as Bool:
+            return .boolean(bool)
+        case let array as [Any]:
+            return .array(array.map(convertToJSONValue))
+        case let dict as [String: Any]:
+            return .object(dict.mapValues(convertToJSONValue))
+        case is NSNull:
+            return .null
+        default:
+            return .null
+        }
+    }
+
+    private static func valuesAreEqual(_ value1: JSONValue, _ value2: JSONValue) -> Bool {
+        switch (value1, value2) {
+        case (.null, .null):
+            return true
+        case let (.string(s1), .string(s2)):
+            return s1 == s2
+        case let (.number(n1), .number(n2)):
+            return n1 == n2
+        case let (.boolean(b1), .boolean(b2)):
+            return b1 == b2
+        case let (.array(a1), .array(a2)):
+            return a1.count == a2.count && zip(a1, a2).allSatisfy(valuesAreEqual)
+        case let (.object(o1), .object(o2)):
+            return o1.keys == o2.keys && o1.keys.allSatisfy { key in
+                guard let v1 = o1[key], let v2 = o2[key] else { return false }
+                return valuesAreEqual(v1, v2)
+            }
+        default:
+            return false
+        }
+    }
+}

+ 29 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Utils/JavascriptOptional.swift

@@ -0,0 +1,29 @@
+@propertyWrapper struct JavascriptOptional<T> {
+    var wrappedValue: T?
+
+    init(wrappedValue: T?) {
+        self.wrappedValue = wrappedValue
+    }
+}
+
+extension JavascriptOptional: Codable where T: Codable {
+    init(from decoder: Decoder) throws {
+        let container = try decoder.singleValueContainer()
+        if let value = try? container.decode(T.self) {
+            wrappedValue = value
+        } else if (try? container.decode(Bool.self)) == false {
+            wrappedValue = nil
+        } else {
+            throw DecodingError.dataCorruptedError(in: container, debugDescription: "Expected number or false")
+        }
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.singleValueContainer()
+        if let value = wrappedValue {
+            try container.encode(value)
+        } else {
+            try container.encode(false)
+        }
+    }
+}

+ 80 - 0
FreeAPSTests/OpenAPSSwiftTests/ProfileBasalTests.swift

@@ -0,0 +1,80 @@
+import Foundation
+@testable import FreeAPS
+import Testing
+
+@Suite("Basal Tests") struct BasalTests {
+    @Test("should find current basal rate in a sample profile") func findCurrentBasalRate() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 1, hour: 2))!
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0),
+            BasalProfileEntry(start: "02:00", minutes: 120, rate: 2.0),
+            BasalProfileEntry(start: "03:00", minutes: 180, rate: 3.0)
+        ]
+
+        let rate = try Basal.basalLookup(basalProfile, now: now)
+        #expect(rate == 2.0)
+    }
+
+    @Test("should find current basal rate for midnight in a sample profile") func findMidnightBasalRate() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 1, hour: 0))!
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0),
+            BasalProfileEntry(start: "02:00", minutes: 120, rate: 2.0),
+            BasalProfileEntry(start: "03:00", minutes: 180, rate: 3.0)
+        ]
+
+        let rate = try Basal.basalLookup(basalProfile, now: now)
+        #expect(rate == 1.0)
+    }
+
+    @Test("should find current basal rate for 3am in a sample profile") func findThreeAmBasalRate() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 1, hour: 3))!
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0),
+            BasalProfileEntry(start: "02:00", minutes: 120, rate: 2.0),
+            BasalProfileEntry(start: "03:00", minutes: 180, rate: 3.0)
+        ]
+
+        let rate = try Basal.basalLookup(basalProfile, now: now)
+        #expect(rate == 3.0)
+    }
+
+    @Test("should return nil with an empty profile") func handleEmptyProfile() async throws {
+        let rate = try Basal.basalLookup([])
+        #expect(rate == nil)
+    }
+
+    @Test("should handle a profile with just one rate") func handleSingleRateProfile() async throws {
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0)
+        ]
+
+        let rate = try Basal.basalLookup(basalProfile)
+        #expect(rate == 1.0)
+    }
+
+    @Test("should return nil with a zero rate") func handleZeroRate() async throws {
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 0.0)
+        ]
+
+        let rate = try Basal.basalLookup(basalProfile)
+        #expect(rate == nil)
+    }
+
+    @Test("should properly compute maxDailyBasal") func computeMaxDailyBasal() async throws {
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0),
+            BasalProfileEntry(start: "02:00", minutes: 120, rate: 2.0),
+            BasalProfileEntry(start: "03:00", minutes: 180, rate: 3.0)
+        ]
+
+        let maxRate = Basal.maxDailyBasal(basalProfile)
+        #expect(maxRate == 3.0)
+    }
+
+    @Test("should return nil for maxDailyBasal with empty profile") func handleEmptyProfileForMaxDaily() async throws {
+        let maxRate = Basal.maxDailyBasal([])
+        #expect(maxRate == nil)
+    }
+}

+ 60 - 0
FreeAPSTests/OpenAPSSwiftTests/ProfileCarbsTests.swift

@@ -0,0 +1,60 @@
+import Foundation
+@testable import FreeAPS
+import Testing
+
+@Suite("Carb Ratio Profile") struct CarbRatioTests {
+    let standardSchedule = CarbRatios(
+        units: .grams,
+        schedule: [
+            CarbRatioEntry(start: "00:00:00", offset: 0, ratio: 15),
+            CarbRatioEntry(start: "03:00:00", offset: 180, ratio: 18),
+            CarbRatioEntry(start: "06:00:00", offset: 360, ratio: 20)
+        ]
+    )
+
+    @Test("should return current carb ratio from schedule") func currentCarbRatio() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 2))!
+        let ratio = Carbs.carbRatioLookup(carbRatio: standardSchedule, now: now)
+        #expect(ratio == 15)
+    }
+
+    @Test("should handle ratio schedule changes") func handleScheduleChanges() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 4))!
+        let ratio = Carbs.carbRatioLookup(carbRatio: standardSchedule, now: now)
+        #expect(ratio == 18)
+    }
+
+    @Test("should handle exchanges unit conversion") func handleExchanges() async throws {
+        let exchangeSchedule = CarbRatios(
+            units: .exchanges,
+            schedule: [
+                CarbRatioEntry(start: "00:00:00", offset: 0, ratio: 12)
+            ]
+        )
+        let ratio = Carbs.carbRatioLookup(carbRatio: exchangeSchedule)
+        #expect(ratio == 1) // 12 grams per exchange
+    }
+
+    @Test("should reject invalid ratios") func rejectInvalidRatios() async throws {
+        let invalidSchedule = CarbRatios(
+            units: .grams,
+            schedule: [
+                CarbRatioEntry(start: "00:00:00", offset: 0, ratio: 2),
+                CarbRatioEntry(start: "03:00:00", offset: 180, ratio: 15)
+            ]
+        )
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 2))!
+        var ratio = Carbs.carbRatioLookup(carbRatio: invalidSchedule, now: now)
+        #expect(ratio == nil)
+
+        let invalidSchedule2 = CarbRatios(
+            units: .grams,
+            schedule: [
+                CarbRatioEntry(start: "00:00:00", offset: 0, ratio: 200)
+            ]
+        )
+
+        ratio = Carbs.carbRatioLookup(carbRatio: invalidSchedule2, now: now)
+        #expect(ratio == nil)
+    }
+}

+ 62 - 0
FreeAPSTests/OpenAPSSwiftTests/ProfileIsfTests.swift

@@ -0,0 +1,62 @@
+import Foundation
+@testable import FreeAPS
+import Testing
+
+@Suite("ISF Profile") struct ISFTests {
+    let standardISF = InsulinSensitivities(
+        units: .mgdL,
+        userPreferredUnits: .mgdL,
+        sensitivities: [
+            InsulinSensitivityEntry(sensitivity: 100, offset: 0, start: "00:00:00"),
+            InsulinSensitivityEntry(sensitivity: 80, offset: 180, start: "03:00:00"),
+            InsulinSensitivityEntry(sensitivity: 90, offset: 360, start: "06:00:00")
+        ]
+    )
+
+    @Test("should return current insulin sensitivity factor from schedule") func currentISF() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 2))!
+        let (sensitivity, _) = try Isf.isfLookup(isfDataInput: standardISF, timestamp: now)
+        #expect(sensitivity == 100)
+    }
+
+    @Test("should handle sensitivity schedule changes") func handleScheduleChanges() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 4))!
+        let (sensitivity, _) = try Isf.isfLookup(isfDataInput: standardISF, timestamp: now)
+        #expect(sensitivity == 80)
+    }
+
+    @Test("should use last sensitivity if past schedule end") func useLastSensitivity() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 23))!
+        let (sensitivity, _) = try Isf.isfLookup(isfDataInput: standardISF, timestamp: now)
+        #expect(sensitivity == 90)
+    }
+
+    @Test("should produce the same result without a cache") func cacheLastResult() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 4, minute: 30))!
+        let (sensitivity1, _) = try Isf.isfLookup(isfDataInput: standardISF, timestamp: now)
+        let (sensitivity2, _) = try Isf.isfLookup(isfDataInput: standardISF, timestamp: now)
+        #expect(sensitivity1 == sensitivity2)
+        #expect(sensitivity1 == 80)
+    }
+
+    @Test("should provide updated inputs with the `endOffset` parameter") func updatedInputs() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 4))!
+        let (sensitivity, isfUpdated) = try Isf.isfLookup(isfDataInput: standardISF, timestamp: now)
+        #expect(sensitivity == 80)
+        #expect(isfUpdated.sensitivities[0].endOffset == nil)
+        #expect(isfUpdated.sensitivities[1].endOffset == 360)
+        #expect(isfUpdated.sensitivities[2].endOffset == nil)
+    }
+
+    @Test("should return -1 for invalid profile with non-zero first offset") func handleInvalidProfile() async throws {
+        let invalidISF = InsulinSensitivities(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            sensitivities: [
+                InsulinSensitivityEntry(sensitivity: 100, offset: 30, start: "00:30:00")
+            ]
+        )
+        let (sensitivity, _) = try Isf.isfLookup(isfDataInput: invalidISF)
+        #expect(sensitivity == -1)
+    }
+}

+ 278 - 0
FreeAPSTests/OpenAPSSwiftTests/ProfileJavascriptTests.swift

@@ -0,0 +1,278 @@
+import Foundation
+@testable import FreeAPS
+import Testing
+
+struct ProfileGeneratorTests {
+    // Base test inputs that match the JavaScript test setup
+    private func createBaseInputs() -> (
+        PumpSettings,
+        BGTargets,
+        [BasalProfileEntry],
+        InsulinSensitivities,
+        Preferences,
+        CarbRatios,
+        [TempTarget],
+        String,
+        FreeAPSSettings
+    ) {
+        let pumpSettings = PumpSettings(
+            insulinActionCurve: 10,
+            maxBolus: 10,
+            maxBasal: 2
+        )
+
+        let bgTargets = BGTargets(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            targets: [
+                BGTargetEntry(low: 100, high: 120, start: "00:00", offset: 0)
+            ]
+        )
+
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0)
+        ]
+
+        let isf = InsulinSensitivities(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            sensitivities: [
+                InsulinSensitivityEntry(sensitivity: 100, offset: 0, start: "00:00")
+            ]
+        )
+
+        let preferences = Preferences()
+
+        let carbRatios = CarbRatios(
+            units: .grams,
+            schedule: [
+                CarbRatioEntry(start: "00:00", offset: 0, ratio: 20)
+            ]
+        )
+
+        let tempTargets: [TempTarget] = []
+        let model = "523"
+        let freeaps = FreeAPSSettings()
+
+        return (pumpSettings, bgTargets, basalProfile, isf, preferences, carbRatios, tempTargets, model, freeaps)
+    }
+
+    @Test("Basic profile generation should create profile with correct values") func testBasicProfileGeneration() throws {
+        let inputs = createBaseInputs()
+
+        let profile = try ProfileGenerator.generate(
+            pumpSettings: inputs.0,
+            bgTargets: inputs.1,
+            basalProfile: inputs.2,
+            isf: inputs.3,
+            preferences: inputs.4,
+            carbRatios: inputs.5,
+            tempTargets: inputs.6,
+            model: inputs.7,
+            freeaps: inputs.8
+        )
+
+        #expect(profile.maxIob == 0)
+        #expect(profile.dia == 10)
+        #expect(profile.sens == 100)
+        #expect(profile.currentBasal == 1)
+        #expect(profile.maxBg == 100)
+        #expect(profile.minBg == 100)
+        #expect(profile.carbRatio == 20)
+    }
+
+    @Test("Profile with active temp target should use temp target values") func testProfileWithTempTarget() throws {
+        var inputs = createBaseInputs()
+
+        // Create temp target 5 minutes ago that lasts 20 minutes
+        let currentTime = Date()
+        let creationDate = currentTime.addingTimeInterval(-5 * 60)
+
+        let tempTarget = TempTarget(
+            name: "Eating Soon",
+            createdAt: creationDate,
+            targetTop: 80,
+            targetBottom: 80,
+            duration: 20,
+            enteredBy: "Test",
+            reason: "Eating Soon",
+            isPreset: nil,
+            enabled: nil,
+            halfBasalTarget: nil
+        )
+
+        inputs.6 = [tempTarget]
+
+        let profile = try ProfileGenerator.generate(
+            pumpSettings: inputs.0,
+            bgTargets: inputs.1,
+            basalProfile: inputs.2,
+            isf: inputs.3,
+            preferences: inputs.4,
+            carbRatios: inputs.5,
+            tempTargets: inputs.6,
+            model: inputs.7,
+            freeaps: inputs.8
+        )
+
+        #expect(profile.maxIob == 0)
+        #expect(profile.dia == 10)
+        #expect(profile.sens == 100)
+        #expect(profile.currentBasal == 1)
+        #expect(profile.maxBg == 80)
+        #expect(profile.minBg == 80)
+        #expect(profile.carbRatio == 20)
+        #expect(profile.temptargetSet == true)
+    }
+
+    @Test("Profile with expired temp target should use default values") func testProfileWithExpiredTempTarget() throws {
+        var inputs = createBaseInputs()
+
+        // Create temp target 90 minutes ago
+        let currentTime = Date()
+        let creationDate = currentTime.addingTimeInterval(-90 * 60)
+
+        let tempTarget = TempTarget(
+            name: "Eating Soon",
+            createdAt: creationDate,
+            targetTop: 80,
+            targetBottom: 80,
+            duration: 20,
+            enteredBy: "Test",
+            reason: "Eating Soon",
+            isPreset: nil,
+            enabled: nil,
+            halfBasalTarget: nil
+        )
+
+        inputs.6 = [tempTarget]
+
+        let profile = try ProfileGenerator.generate(
+            pumpSettings: inputs.0,
+            bgTargets: inputs.1,
+            basalProfile: inputs.2,
+            isf: inputs.3,
+            preferences: inputs.4,
+            carbRatios: inputs.5,
+            tempTargets: inputs.6,
+            model: inputs.7,
+            freeaps: inputs.8
+        )
+
+        #expect(profile.maxIob == 0)
+        #expect(profile.dia == 10)
+        #expect(profile.sens == 100)
+        #expect(profile.currentBasal == 1)
+        #expect(profile.maxBg == 100)
+        #expect(profile.minBg == 100)
+        #expect(profile.carbRatio == 20)
+    }
+
+    @Test("Profile with zero duration temp target should use default values") func testProfileWithZeroDurationTempTarget() throws {
+        var inputs = createBaseInputs()
+
+        // Create temp target 5 minutes ago with 0 duration
+        let currentTime = Date()
+        let creationDate = currentTime.addingTimeInterval(-5 * 60)
+
+        let tempTarget = TempTarget(
+            name: "Eating Soon",
+            createdAt: creationDate,
+            targetTop: 80,
+            targetBottom: 80,
+            duration: 0,
+            enteredBy: "Test",
+            reason: "Eating Soon",
+            isPreset: nil,
+            enabled: nil,
+            halfBasalTarget: nil
+        )
+
+        inputs.6 = [tempTarget]
+
+        let profile = try ProfileGenerator.generate(
+            pumpSettings: inputs.0,
+            bgTargets: inputs.1,
+            basalProfile: inputs.2,
+            isf: inputs.3,
+            preferences: inputs.4,
+            carbRatios: inputs.5,
+            tempTargets: inputs.6,
+            model: inputs.7,
+            freeaps: inputs.8
+        )
+
+        #expect(profile.maxIob == 0)
+        #expect(profile.dia == 10)
+        #expect(profile.sens == 100)
+        #expect(profile.currentBasal == 1)
+        #expect(profile.maxBg == 100)
+        #expect(profile.minBg == 100)
+        #expect(profile.carbRatio == 20)
+    }
+
+    @Test("Profile generation with invalid DIA should throw error") func testInvalidDIA() throws {
+        var inputs = createBaseInputs()
+        inputs.0 = PumpSettings(
+            insulinActionCurve: 1,
+            maxBolus: 10,
+            maxBasal: 2
+        )
+
+        #expect(throws: ProfileError.invalidDIA(value: 1)) {
+            _ = try ProfileGenerator.generate(
+                pumpSettings: inputs.0,
+                bgTargets: inputs.1,
+                basalProfile: inputs.2,
+                isf: inputs.3,
+                preferences: inputs.4,
+                carbRatios: inputs.5,
+                tempTargets: inputs.6,
+                model: inputs.7,
+                freeaps: inputs.8
+            )
+        }
+    }
+
+    @Test("Profile generation with zero basal rate should throw error") func testCurrentBasalZero() throws {
+        var inputs = createBaseInputs()
+        inputs.2 = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 0.0)
+        ]
+
+        // the reason it throws this error is due to some complex logic
+        // in Javascript around the handling of nil and 0 basal rate entries
+        #expect(throws: ProfileError.invalidMaxDailyBasal(value: 0)) {
+            _ = try ProfileGenerator.generate(
+                pumpSettings: inputs.0,
+                bgTargets: inputs.1,
+                basalProfile: inputs.2,
+                isf: inputs.3,
+                preferences: inputs.4,
+                carbRatios: inputs.5,
+                tempTargets: inputs.6,
+                model: inputs.7,
+                freeaps: inputs.8
+            )
+        }
+    }
+
+    @Test("Profile should store model string correctly") func testModelString() throws {
+        var inputs = createBaseInputs()
+        inputs.7 = "\"554\"\n"
+
+        let profile = try ProfileGenerator.generate(
+            pumpSettings: inputs.0,
+            bgTargets: inputs.1,
+            basalProfile: inputs.2,
+            isf: inputs.3,
+            preferences: inputs.4,
+            carbRatios: inputs.5,
+            tempTargets: inputs.6,
+            model: inputs.7,
+            freeaps: inputs.8
+        )
+
+        #expect(profile.model == "554")
+    }
+}

+ 95 - 0
FreeAPSTests/OpenAPSSwiftTests/ProfileJsNativeCompareTests.swift

@@ -0,0 +1,95 @@
+import Foundation
+@testable import FreeAPS
+import Testing
+
+@Suite("Profile js vs native comparison") struct ProfileJsNativeCompareTests {
+    // Base test inputs that match the JavaScript test setup
+    private func createBaseInputs() -> (
+        Preferences,
+        PumpSettings,
+        BGTargets,
+        [BasalProfileEntry],
+        InsulinSensitivities,
+        CarbRatios,
+        [TempTarget],
+        String,
+        FreeAPSSettings
+    ) {
+        let pumpSettings = PumpSettings(
+            insulinActionCurve: 10,
+            maxBolus: 10,
+            maxBasal: 2
+        )
+
+        let bgTargets = BGTargets(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            targets: [
+                BGTargetEntry(low: 100, high: 120, start: "00:00", offset: 0)
+            ]
+        )
+
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0)
+        ]
+
+        let isf = InsulinSensitivities(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            sensitivities: [
+                InsulinSensitivityEntry(sensitivity: 100, offset: 0, start: "00:00")
+            ]
+        )
+
+        let preferences = Preferences()
+
+        let carbRatios = CarbRatios(
+            units: .grams,
+            schedule: [
+                CarbRatioEntry(start: "00:00", offset: 0, ratio: 20)
+            ]
+        )
+
+        let tempTargets: [TempTarget] = []
+        let model = "\"250\""
+        let freeaps = FreeAPSSettings()
+
+        return (preferences, pumpSettings, bgTargets, basalProfile, isf, carbRatios, tempTargets, model, freeaps)
+    }
+
+    @Test("should compare Profile for js and native with base inputs") func withBasicInputs() async throws {
+        let inputs = createBaseInputs()
+        let openAps = OpenAPS(storage: BaseFileStorage())
+        let profileJs = try! await openAps.makeProfileJavascript(
+            preferences: inputs.0,
+            pumpSettings: inputs.1,
+            bgTargets: inputs.2,
+            basalProfile: inputs.3,
+            isf: inputs.4,
+            carbRatio: inputs.5,
+            tempTargets: inputs.6,
+            model: inputs.7,
+            autotune: RawJSON.null,
+            freeaps: inputs.8
+        )
+
+        let profileNative = OpenAPSSwift.makeProfile(
+            preferences: inputs.0,
+            pumpSettings: inputs.1,
+            bgTargets: inputs.2,
+            basalProfile: inputs.3,
+            isf: inputs.4,
+            carbRatio: inputs.5,
+            tempTargets: inputs.6,
+            model: inputs.7,
+            freeaps: inputs.8
+        )
+
+        let differences = try! JSONCompare.differences(function: .makeProfile, native: profileNative, javascript: profileJs)
+
+        if !differences.isEmpty {
+            JSONCompare.prettyPrint(differences)
+        }
+        #expect(differences.isEmpty)
+    }
+}

+ 102 - 0
FreeAPSTests/OpenAPSSwiftTests/ProfileTargetsTests.swift

@@ -0,0 +1,102 @@
+import Foundation
+@testable import FreeAPS
+import Testing
+
+@Suite("Target Profile") struct TargetTests {
+    let standardTargets = BGTargets(
+        units: .mgdL,
+        userPreferredUnits: .mgdL,
+        targets: [
+            BGTargetEntry(low: 100, high: 120, start: "00:00:00", offset: 0),
+            BGTargetEntry(low: 90, high: 110, start: "03:00:00", offset: 180),
+            BGTargetEntry(low: 110, high: 130, start: "06:00:00", offset: 360)
+        ]
+    )
+
+    let tempTargets = [
+        TempTarget(
+            name: nil,
+            createdAt: Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 2))!,
+            targetTop: 100,
+            targetBottom: 80,
+            duration: 120,
+            enteredBy: nil,
+            reason: nil,
+            isPreset: nil,
+            enabled: nil,
+            halfBasalTarget: nil
+        )
+    ]
+
+    let profile = Profile()
+
+    @Test("should return correct target from schedule") func correctTargetFromSchedule() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 1))!
+        let (_, result) = try Targets
+            .bgTargetsLookup(targets: standardTargets, tempTargets: tempTargets, profile: profile, now: now)
+        #expect(result.high == 100)
+        #expect(result.low == 100)
+    }
+
+    @Test("should override from Profile targetBg") func profileOverride() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 1))!
+        var profile = Profile()
+        profile.targetBg = 110
+        let (_, result) = try Targets
+            .bgTargetsLookup(targets: standardTargets, tempTargets: tempTargets, profile: profile, now: now)
+        #expect(result.high == 110)
+        #expect(result.low == 110)
+    }
+
+    @Test("should handle target schedule changes") func handleScheduleChanges() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 4))!
+        let (_, result) = try Targets
+            .bgTargetsLookup(targets: standardTargets, tempTargets: tempTargets, profile: profile, now: now)
+        #expect(result.high == 90)
+        #expect(result.low == 90)
+    }
+
+    @Test("should handle temp targets") func handleTempTargets() async throws {
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 2, minute: 30))!
+        let (_, result) = try Targets
+            .bgTargetsLookup(targets: standardTargets, tempTargets: tempTargets, profile: profile, now: now)
+        #expect(result.high == 100)
+        #expect(result.low == 80)
+        #expect(result.temptargetSet == true)
+    }
+
+    @Test("should handle temp target cancellation") func handleTempTargetCancellation() async throws {
+        let cancelTempTargets = [
+            TempTarget(
+                name: nil,
+                createdAt: Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 2, minute: 30))!,
+                targetTop: 0,
+                targetBottom: 0,
+                duration: 0,
+                enteredBy: nil,
+                reason: nil,
+                isPreset: nil,
+                enabled: nil,
+                halfBasalTarget: nil
+            )
+        ]
+        let now = Calendar.current.date(from: DateComponents(year: 2025, month: 1, day: 26, hour: 2, minute: 45))!
+        let (_, result) = try Targets
+            .bgTargetsLookup(targets: standardTargets, tempTargets: cancelTempTargets, profile: profile, now: now)
+        #expect(result.high == 100)
+        #expect(result.low == 100)
+    }
+
+    @Test("should enforce hard limits on target range") func enforceHardLimits() async throws {
+        let extremeTargets = BGTargets(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            targets: [
+                BGTargetEntry(low: 40, high: 250, start: "00:00:00", offset: 0)
+            ]
+        )
+        let (_, result) = try Targets.bgTargetsLookup(targets: extremeTargets, tempTargets: [], profile: profile)
+        #expect(result.maxBg == 80)
+        #expect(result.minBg == 80)
+    }
+}

+ 145 - 0
oref_swift_port_notes.md

@@ -0,0 +1,145 @@
+# Port Notes
+
+As we're going through the port from Javascript to Swift, we'll use
+this file to keep track of notes. Currently we outline our high level
+plan and identify the risks that we have observed so far.
+
+The good news is that from a preliminary inspection, the functions
+that I've looked at in detail are pure functions, meaning that they
+take inputs and produce an output without any side effects. All of the
+state handling is on the native Swift side in Trio (at least so
+far). Pure functions will be easier to test and less risky to port
+incrementally.
+
+## Plan
+
+At the highest level, our plan is to first do a line-by-line port of
+the Javascript implementation to build confidence that it works, then
+to make it more "Swift-y" after we have confidence in the logic. Doing
+a line-by-line port first makes it easier for us to debug, but we will
+use more idiomatic Swift patterns where it makes sense.
+
+Also, we plan to release this as a SPM so that other iOS / OpenAPS
+systems can pick up this library, if it makes sense. But I'm open to
+something different if people have strong opinions here.
+
+Our plan is:
+
+1. Port one function at a time. The functions, in order, are:
+  - `makeProfile`
+  - `iob`
+  - `meal`
+  - `autosense`
+  - `determineBasal`
+
+2. For each function, the process will be:
+  - Write the code in Swift
+  - Port the Javascript tests to Swift to confirm they work
+  - Write new unit tests to get full code coverage (ideally)
+  - Run the native function in Trio in a shadow mode, where we compute the results and simply compare with the Javascript implementation, logging any differences.
+
+3. We should run each function in shadow mode for a week without any
+inconsistencies before considering moving it to live execution. After
+we move to live execution of a native function, we should continue to
+run the Javascript implementation in shadow mode for 2 weeks to
+continue to check for inconsistencies.
+
+4. Once all functions are running natively and without inconsistencies
+for two weeks, we can remove the Javascript implementation. After we
+remove the Javascript implementation, we will consider the
+line-by-line port to be complete, and can make decisions about any
+further changes we'd like to make to the Swift implementation to
+improve maintainability.
+
+## Concurrency
+
+Our goal is to make each of the functions pure functions, meaning that
+they don't have any side effects and they're deterministic (given the
+same inputs they'll produce the same outputs). There are some caveats
+with floating point numbers and time (see [risks](#risks)), but so far
+it looks like it'll be possible.
+
+Having pure functions is a big benefit from a correctness perspective,
+it makes testing easier and it makes it easier for people to use it
+since they don't have to worry about ordering or sequencing
+functions. Javascript has single-threaded semantics with an event
+system, but we can ignore this if we can keep our functions pure.
+
+## Risks
+
+Here is a list of where we think bugs might crop up, so we're writing
+them down to make sure we can keep an eye on it.
+
+- **Javascript pass-by-reference.** Javascript uses pass-by-reference
+    semantics, so if code modifies an input parameter then that value
+    is changed. In our Swift port, we instead use pass-by-value
+    semantics, trying to carefully navigate any visible changes that
+    can come from modifications, which does happen in OpenAPS.
+
+- **Javascript dynamic properties.** Javascript can add properties on
+    the fly, which is hard to get right. Our plan is to use static
+    typing and make sure that we include properties that Javascript
+    would generate dynamically, but this is a potential source of
+    inconsistencies.
+
+- **Javascript type switching.** There is at least one property
+     (Profile.target_bg) where the Javascript implementation uses
+     boolean `false` as a proxy for Optional none, where the property
+     is a Number. I have a property annotation to deal with it, but
+     it's something we'll want to get rid of after the port. The Swift
+     implementation does _not_ use this behavior, we try to constrain
+     it to the serialization routines to maintain JSON compatibility.
+
+- **var now = new Date();** There are several places where the
+    Javascript implementation gets the current time using `new
+    Date()`. This style of time management can lead to issues if we're
+    right at a boundary when it runs. Since this is how the Javascript
+    is implemented we use it too, but we'll want to fix that soon.
+
+- **Double vs Decimal.** In Swift we use the Decimal class for
+    floating point computation. However, our goal is to match the
+    current Javascript implementation, which uses Double, so we need
+    to keep an eye on this because the two can be different.
+
+- **Trio-specific inputs.** There are places where the Trio
+    implementation it a little different than what the Javascript
+    expects. An example is `BasalProfileEntry` doesn't have an `i`
+    property, so the sorting function for these entries in Javascript
+    is a no-op, so we excluded it.
+
+- **Preferences -> Profile.** The Javascript implementation copies
+    input properties into the Profile if they exist. In Trio, in
+    Javascript we copy the Preferences to the input for this
+    purpose. In this library, we do this copy by hard-coding all
+    properties that have the same CodingKeys, but this was a manual
+    process and something we need to remember to change if either
+    Profile or Preferences changes. We'll fix this with v2, but for
+    now this was the cleanest way I could come up with for handling it
+    in Swift. See the Profile extension that implements `update` for
+    more details.
+
+## Todos
+
+So far, the biggest cleanup items are to see if we can avoid
+reproducing the logic that mutates inputs. There are a few TODOs in
+the code to mark these to evaluate later, but for now we'll just
+produce the same JSON that the Javascript library does.
+
+The next biggest change is to be consistent with time. There are a
+bunch of places that the Javascript uses the current time of day, we
+should pass in one time to both algorithms so that they produce
+consistent results.
+
+In terms of enhancements after the port, here are some issues that we
+created to track some cleanup that we should do:
+- [Refactor outUnits in Profile](https://github.com/nightscout/Trio-dev/issues/289)
+- [Allow 0 basal rates](https://github.com/nightscout/Trio-dev/issues/288)
+- [Use insulin-based curves](https://github.com/nightscout/Trio-dev/issues/287)
+
+## Sources
+
+For our port, we're using:
+
+- trio-oref (tcd branch) git SHA: ade267da32435df5e8edca5738a24b687f8ba001
+
+- Trio-dev (core-data-sync-trio branch) git SHA: dc43b0ae8fb106d7b30cf97e29d8a931efbf1339