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

Move from SPM to direct Trio implementation

Sam King 1 год назад
Родитель
Сommit
39a3431f62
27 измененных файлов с 1789 добавлено и 42 удалено
  1. 124 17
      FreeAPS.xcodeproj/project.pbxproj
  2. 1 2
      FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift
  3. 14 0
      FreeAPS/Sources/APS/OpenAPSSwift/Extensions/BGTargets+Convert.swift
  4. 36 0
      FreeAPS/Sources/APS/OpenAPSSwift/Extensions/Date+MinutesFromMidnight.swift
  5. 19 0
      FreeAPS/Sources/APS/OpenAPSSwift/Extensions/Decimal+rounding.swift
  6. 24 0
      FreeAPS/Sources/APS/OpenAPSSwift/Extensions/InsulinSensitivities+Convert.swift
  7. 9 10
      FreeAPS/Sources/APS/OpenAPSSwift/JSONBridge.swift
  8. 19 0
      FreeAPS/Sources/APS/OpenAPSSwift/Models/Autotune.swift
  9. 37 0
      FreeAPS/Sources/APS/OpenAPSSwift/Models/ComputedBGTargets.swift
  10. 55 0
      FreeAPS/Sources/APS/OpenAPSSwift/Models/ComputedInsulinSensitivities.swift
  11. 145 0
      FreeAPS/Sources/APS/OpenAPSSwift/Models/Profile.swift
  12. 1 2
      FreeAPS/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift
  13. 35 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/Basal.swift
  14. 35 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/Carbs.swift
  15. 67 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/Isf.swift
  16. 33 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/ProfileError.swift
  17. 247 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift
  18. 100 0
      FreeAPS/Sources/APS/OpenAPSSwift/Profile/Targets.swift
  19. 155 0
      FreeAPS/Sources/APS/OpenAPSSwift/Utils/JSONCompare.swift
  20. 29 0
      FreeAPS/Sources/APS/OpenAPSSwift/Utils/JavascriptOptional.swift
  21. 1 1
      FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/GlucoseTargetSetup.swift
  22. 80 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileBasalTests.swift
  23. 60 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileCarbsTests.swift
  24. 62 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileIsfTests.swift
  25. 285 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileJavascriptTests.swift
  26. 115 0
      FreeAPSTests/OpenAPSSwiftTests/ProfileTargetsTests.swift
  27. 1 10
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

+ 124 - 17
FreeAPS.xcodeproj/project.pbxproj

@@ -235,7 +235,27 @@
 		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 */; };
-		3B5CD2762D4A74FF00CE213C /* OpenAPSKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3B5CD2752D4A74FF00CE213C /* OpenAPSKit */; };
+		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 */; };
+		3B5CD2B32D4AEA6600CE213C /* Autotune.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2A82D4AEA6600CE213C /* Autotune.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 */; };
+		3BCE75B72D4B3DCF009E9453 /* BGTargets+Convert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCE75B62D4B3DC4009E9453 /* BGTargets+Convert.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 */; };
@@ -941,6 +961,27 @@
 		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>"; };
+		3B5CD2A82D4AEA6600CE213C /* Autotune.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Autotune.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>"; };
+		3BCE75B62D4B3DC4009E9453 /* BGTargets+Convert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BGTargets+Convert.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>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
@@ -1299,7 +1340,6 @@
 				B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */,
 				CE95BF5B2BA770C300DC3DE3 /* LoopKit.framework in Frameworks */,
 				38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */,
-				3B5CD2762D4A74FF00CE213C /* OpenAPSKit in Frameworks */,
 				3833B46D26012030003021B3 /* Algorithms in Frameworks */,
 				CEB434FD28B90B7C00B70274 /* SwiftCharts in Frameworks */,
 				CE95BF5F2BA7715800DC3DE3 /* MockKit.framework in Frameworks */,
@@ -2291,6 +2331,7 @@
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
 				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
+				3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */,
 			);
 			path = FreeAPSTests;
 			sourceTree = "<group>";
@@ -2298,12 +2339,72 @@
 		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 = (
+				3BCE75B62D4B3DC4009E9453 /* BGTargets+Convert.swift */,
+				3B5CD2A32D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift */,
+				3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */,
+				3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */,
+			);
+			path = Extensions;
+			sourceTree = "<group>";
+		};
+		3B5CD2B22D4AEA6600CE213C /* Models */ = {
+			isa = PBXGroup;
+			children = (
+				3B5CD2A82D4AEA6600CE213C /* Autotune.swift */,
+				3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */,
+				3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */,
+				3B5CD2AF2D4AEA6600CE213C /* Profile.swift */,
+			);
+			path = Models;
+			sourceTree = "<group>";
+		};
+		3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */ = {
+			isa = PBXGroup;
+			children = (
+				3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */,
+				3B5CD2C32D4AECD500CE213C /* ProfileCarbsTests.swift */,
+				3B5CD2C42D4AECD500CE213C /* ProfileIsfTests.swift */,
+				3B5CD2C52D4AECD500CE213C /* ProfileJavascriptTests.swift */,
+				3B5CD2C62D4AECD500CE213C /* ProfileTargetsTests.swift */,
+			);
+			path = OpenAPSSwiftTests;
+			sourceTree = "<group>";
+		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -3140,7 +3241,6 @@
 				38DF1788276FC8C400B3528F /* SwiftMessages */,
 				CEB434FC28B90B7C00B70274 /* SwiftCharts */,
 				B958F1B62BA0711600484851 /* MKRingProgressView */,
-				3B5CD2752D4A74FF00CE213C /* OpenAPSKit */,
 			);
 			productName = FreeAPS;
 			productReference = 388E595825AD948C0019842D /* FreeAPS.app */;
@@ -3283,7 +3383,6 @@
 				38DF1787276FC8C300B3528F /* XCRemoteSwiftPackageReference "SwiftMessages" */,
 				CEB434FB28B90B7C00B70274 /* XCRemoteSwiftPackageReference "SwiftCharts" */,
 				B958F1B52BA0711600484851 /* XCRemoteSwiftPackageReference "MKRingProgressView" */,
-				3B5CD2742D4A74FF00CE213C /* XCRemoteSwiftPackageReference "OpenAPSKit" */,
 			);
 			productRefGroup = 388E595925AD948C0019842D /* Products */;
 			projectDirPath = "";
@@ -3474,6 +3573,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 */,
@@ -3524,6 +3624,10 @@
 				BDC2EA452C3043B000E5BBD0 /* OverrideStorage.swift in Sources */,
 				3811DE4225C9D4A100A708ED /* SettingsDataFlow.swift in Sources */,
 				CEE9A6562BBB418300EB5194 /* CalibrationsRootView.swift in Sources */,
+				3B5CD2B32D4AEA6600CE213C /* Autotune.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 */,
@@ -3534,6 +3638,7 @@
 				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
 				388E5A5C25B6F0770019842D /* JSON.swift in Sources */,
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,
+				3BCE75B72D4B3DCF009E9453 /* BGTargets+Convert.swift in Sources */,
 				5837A5302BD2E3C700A5DC04 /* CarbEntryStored+helper.swift in Sources */,
 				389A572026079BAA00BC102F /* Interpolation.swift in Sources */,
 				DD9ECB702CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift in Sources */,
@@ -3567,6 +3672,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 */,
@@ -3670,6 +3781,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 */,
@@ -3839,6 +3951,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 */,
@@ -3871,6 +3984,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 */,
@@ -3959,6 +4074,11 @@
 			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 */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
@@ -4630,14 +4750,6 @@
 				minimumVersion = 9.0.0;
 			};
 		};
-		3B5CD2742D4A74FF00CE213C /* XCRemoteSwiftPackageReference "OpenAPSKit" */ = {
-			isa = XCRemoteSwiftPackageReference;
-			repositoryURL = "https://github.com/kingst/OpenAPSKit.git";
-			requirement = {
-				kind = upToNextMajorVersion;
-				minimumVersion = 1.0.0;
-			};
-		};
 		B958F1B52BA0711600484851 /* XCRemoteSwiftPackageReference "MKRingProgressView" */ = {
 			isa = XCRemoteSwiftPackageReference;
 			repositoryURL = "https://github.com/maxkonovalov/MKRingProgressView.git";
@@ -4682,11 +4794,6 @@
 			package = 38B17B6425DD90E0005CAE3D /* XCRemoteSwiftPackageReference "SwiftDate" */;
 			productName = SwiftDate;
 		};
-		3B5CD2752D4A74FF00CE213C /* OpenAPSKit */ = {
-			isa = XCSwiftPackageProductDependency;
-			package = 3B5CD2742D4A74FF00CE213C /* XCRemoteSwiftPackageReference "OpenAPSKit" */;
-			productName = OpenAPSKit;
-		};
 		B958F1B62BA0711600484851 /* MKRingProgressView */ = {
 			isa = XCSwiftPackageProductDependency;
 			package = B958F1B52BA0711600484851 /* XCRemoteSwiftPackageReference "MKRingProgressView" */;

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

@@ -2,7 +2,6 @@ import Combine
 import CoreData
 import Foundation
 import JavaScriptCore
-import OpenAPSKit
 
 final class OpenAPS {
     private let jsWorker = JavaScriptWorker()
@@ -767,7 +766,7 @@ final class OpenAPS {
         )
         let nativeDuration = Date().timeIntervalSince(startNativeAt)
 
-        OpenAPSKit.JSONCompare.logDifferences(
+        JSONCompare.logDifferences(
             label: "makeProfile",
             native: nativeJson,
             nativeRuntime: nativeDuration,

+ 14 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Extensions/BGTargets+Convert.swift

@@ -0,0 +1,14 @@
+import Foundation
+
+extension BGTargets {
+    func inMgDl() -> BGTargets {
+        switch units {
+        case .mgdL:
+            return self
+        case .mmolL:
+            let targets = targets
+                .map { BGTargetEntry(low: $0.low * 18, high: $0.high * 18, start: $0.start, offset: $0.offset) }
+            return BGTargets(units: .mgdL, userPreferredUnits: userPreferredUnits, targets: targets)
+        }
+    }
+}

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

@@ -0,0 +1,36 @@
+import Foundation
+
+public enum MinutesFromMidnightError: LocalizedError, Equatable {
+    case invalidCalendar
+
+    public 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)
+    }
+}

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

@@ -0,0 +1,24 @@
+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)
+    }
+
+    func inMgDl() -> InsulinSensitivities {
+        switch units {
+        case .mgdL:
+            return self
+        case .mmolL:
+            let sensitivities = self.sensitivities
+                .map { InsulinSensitivityEntry(sensitivity: $0.sensitivity * 18, offset: $0.offset, start: $0.start) }
+            return InsulinSensitivities(
+                units: .mgdL,
+                userPreferredUnits: userPreferredUnits,
+                sensitivities: sensitivities
+            )
+        }
+    }
+}

+ 9 - 10
FreeAPS/Sources/APS/OpenAPSSwift/JSONBridge.swift

@@ -1,5 +1,4 @@
 import Foundation
-import OpenAPSKit
 
 enum JSONError: Error {
     case invalidString
@@ -8,31 +7,31 @@ enum JSONError: Error {
 }
 
 enum JSONBridge {
-    static func preferences(from: JSON) throws -> OKPreferences {
+    static func preferences(from: JSON) throws -> Preferences {
         try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func pumpSettings(from: JSON) throws -> OKPumpSettings {
+    static func pumpSettings(from: JSON) throws -> PumpSettings {
         try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func bgTargets(from: JSON) throws -> OKBGTargets {
+    static func bgTargets(from: JSON) throws -> BGTargets {
         try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func basalProfile(from: JSON) throws -> [OKBasalProfileEntry] {
+    static func basalProfile(from: JSON) throws -> [BasalProfileEntry] {
         try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func insulinSensitivities(from: JSON) throws -> OKInsulinSensitivities {
+    static func insulinSensitivities(from: JSON) throws -> InsulinSensitivities {
         try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func carbRatios(from: JSON) throws -> OKCarbRatios {
+    static func carbRatios(from: JSON) throws -> CarbRatios {
         try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func tempTargets(from: JSON) throws -> [OKTempTarget] {
+    static func tempTargets(from: JSON) throws -> [TempTarget] {
         try JSONBridge.from(string: from.rawJSON)
     }
 
@@ -40,12 +39,12 @@ enum JSONBridge {
         from.rawJSON
     }
 
-    static func autotune(from: JSON) throws -> OKAutotune? {
+    static func autotune(from: JSON) throws -> Autotune? {
         guard from.rawJSON != RawJSON.null else { return nil }
         return try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func freeapsSettings(from: JSON) throws -> OKFreeAPSSettings {
+    static func freeapsSettings(from: JSON) throws -> FreeAPSSettings {
         try JSONBridge.from(string: from.rawJSON)
     }
 

+ 19 - 0
FreeAPS/Sources/APS/OpenAPSSwift/Models/Autotune.swift

@@ -0,0 +1,19 @@
+import Foundation
+
+struct Autotune: Codable {
+    var createdAt: Date?
+    let basalProfile: [BasalProfileEntry]?
+    let isfProfile: ComputedInsulinSensitivities?
+    let sensitivity: Double
+    let carbRatio: Double?
+}
+
+extension Autotune {
+    private enum CodingKeys: String, CodingKey {
+        case createdAt = "created_at"
+        case basalProfile = "basalprofile"
+        case sensitivity = "sens"
+        case carbRatio = "carb_ratio"
+        case isfProfile
+    }
+}

+ 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
+
+    public 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
+    }
+
+    public 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)
+    }
+
+    public 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()
+    }
+}

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

@@ -0,0 +1,145 @@
+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 exerciseMode: Bool = false // when true, > 100 mg/dL high temp target adjusts sensitivityRatio
+    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 offlineHotspot: Bool = false
+    var noisyCGMTargetMultiplier: Decimal = 1.3
+    var suspendZerosIob: Bool = true
+    var enableEnliteBgproxy: Bool = false
+    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 exerciseMode = "exercise_mode"
+        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 offlineHotspot = "offline_hotspot"
+        case noisyCGMTargetMultiplier
+        case suspendZerosIob = "suspend_zeros_iob"
+        case enableEnliteBgproxy
+        case calcGlucoseNoise = "calc_glucose_noise"
+        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"
+    }
+}

+ 1 - 2
FreeAPS/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -1,5 +1,4 @@
 import Foundation
-import OpenAPSKit
 
 struct OpenAPSSwift {
     static func makeProfile(
@@ -26,7 +25,7 @@ struct OpenAPSSwift {
             let autotune = try JSONBridge.autotune(from: autotune)
             let freeaps = try JSONBridge.freeapsSettings(from: freeaps)
 
-            let profile = try OpenAPSKit.ProfileGenerator.generate(
+            let profile = try ProfileGenerator.generate(
                 pumpSettings: pumpSettings,
                 bgTargets: bgTargets,
                 basalProfile: basalProfile,

+ 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 {
+            print("ERROR: 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 {
+            print("Error: 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
+
+public 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
+
+    public 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"
+        }
+    }
+}

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

@@ -0,0 +1,247 @@
+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
+        exerciseMode = preferences.exerciseMode
+        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,
+        autotune _: Autotune?,
+        freeaps _: FreeAPSSettings
+    ) throws -> Profile {
+        let bgTargets = bgTargets.inMgDl()
+        let isf = isf.inMgDl()
+        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
+            print("don't modify insulin peak time")
+        }
+
+        /* From Javascript
+         for (var pref in preferences) {
+           if (preferences.hasOwnProperty(pref)) {
+             inputs[pref] = preferences[pref];
+           }
+         }
+
+         inputs.max_iob = inputs.max_iob || 0;
+         */
+        // we don't need the JS logic above because it is handled by
+        // our update function
+
+        // in Trio it looks like autotune is always null
+        /*
+          var basalProfile = basalProfile
+          var carbRatios = carbRatios
+         if let autotune = autotune {
+             if let basal = autotune.basalProfile {
+                 basalProfile = basal
+             }
+             // onlyAutotuneBasals is not defined in Swift
+             if let isfProfile = autotune.isfProfile {
+                 // TODO: should we convert this to mg/dL as well?
+                 isf = isfProfile
+             }
+             if let carbRatio = autotune.carbRatio {
+                 carbRatios.schedule[0].ratio = carbRatio
+             }
+         }
+          */
+        return try generate(
+            pumpSettings: pumpSettings,
+            bgTargets: bgTargets,
+            basalProfile: basalProfile,
+            isf: isf,
+            preferences: preferences,
+            carbRatios: carbRatios,
+            tempTargets: tempTargets,
+            model: model
+        )
+    }
+
+    /// Direct port of the OpenASP 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 = opts && opts.type ? opts : defaults( );
+        var profile = Profile() // uses 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)
+
+        if pumpSettings.insulinActionCurve > 1 {
+            profile.dia = pumpSettings.insulinActionCurve
+        } else {
+            throw ProfileError.invalidDIA(value: 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
+
+        // this check is an error check profile.currentBasal === 0 in Javascript
+        guard let currentBasal = profile.currentBasal, abs(currentBasal) > 0 else {
+            throw ProfileError.invalidCurrentBasal(value: profile.currentBasal)
+        }
+
+        guard let maxDailyBasal = profile.maxDailyBasal, abs(maxDailyBasal) > 0 else {
+            throw ProfileError.invalidMaxDailyBasal(value: profile.maxDailyBasal)
+        }
+
+        guard let maxBasal = profile.maxBasal, maxBasal >= 0.1 else {
+            throw ProfileError.invalidMaxBasal(value: profile.maxBasal)
+        }
+
+        // var range = targets.bgTargetsLookup(inputs, profile);
+        profile.outUnits = bgTargets.userPreferredUnits.rawValue
+        let (updatedTargets, range) = try Targets.bgTargetsLookup(targets: bgTargets, tempTargets: tempTargets, profile: profile)
+        // profile.min_bg = Math.round(range.min_bg);
+        // profile.max_bg = Math.round(range.max_bg);
+        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
+        )
+
+        // delete profile.bg_targets.raw;
+        // Note: we don't need this in Swift as we don't have the raw property
+
+        profile.temptargetSet = range.temptargetSet
+        let (sens, isfUpdated) = try Isf.isfLookup(isfDataInput: isf)
+        profile.sens = sens
+        profile.isfProfile = isfUpdated
+
+        guard let sens = profile.sens, sens >= 5 else {
+            print("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
+    }
+}

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

@@ -0,0 +1,100 @@
+import Foundation
+
+struct Targets {
+    ///  The Javascript implementation was hard to port. First, it mutates
+    ///  the inputs in a way that is visible in the Profile. Second, there
+    ///  is a line of code where it sets the high value to low but only if
+    ///  it's not a temp target. I'm going to port it as is for now, but this
+    ///  is worth revisiting after we're done with the port.
+    ///
+    //  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 {
+                print("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
+        // Convert from mmol/L to mg/dL if needed
+        if target.high < 20 { target.high *= 18 }
+        if target.low < 20 { target.low *= 18 }
+
+        // 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)
+    }
+}

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

@@ -0,0 +1,155 @@
+import Foundation
+
+enum JSONValue: Codable {
+    case string(String)
+    case number(Double)
+    case boolean(Bool)
+    case array([JSONValue])
+    case object([String: JSONValue])
+    case null
+
+    public 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"
+            ))
+        }
+    }
+
+    public 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()
+        }
+    }
+}
+
+public struct ValueDifference: Codable {
+    let js: JSONValue
+    let native: JSONValue
+    let jsKeyMissing: Bool
+    let nativeKeyMissing: Bool
+}
+
+public enum JSONCompare {
+    public static func logDifferences(
+        label: String,
+        native: String,
+        nativeRuntime: TimeInterval,
+        javascript: String,
+        javascriptRuntime: TimeInterval
+    ) {
+        guard let differences = try? differences(native: native, javascript: javascript) else {
+            print("Exception calculating differences")
+            return
+        }
+
+        // TODO: For now we'll just print this out to the console but we'll add proper logging next
+        print("\(label) -> n: \(nativeRuntime)s, js: \(javascriptRuntime)s")
+        prettyPrint(differences)
+    }
+
+    public 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)
+        {
+            print(prettyString)
+        }
+    }
+
+    public static func differences(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)
+                )
+            }
+        }
+
+        return differences
+    }
+
+    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 public struct JavascriptOptional<T> {
+    public var wrappedValue: T?
+
+    public init(wrappedValue: T?) {
+        self.wrappedValue = wrappedValue
+    }
+}
+
+extension JavascriptOptional: Codable where T: Codable {
+    public 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")
+        }
+    }
+
+    public func encode(to encoder: Encoder) throws {
+        var container = encoder.singleValueContainer()
+        if let value = wrappedValue {
+            try container.encode(value)
+        } else {
+            try container.encode(false)
+        }
+    }
+}

+ 1 - 1
FreeAPS/Sources/Modules/Home/HomeStateModel+Setup/GlucoseTargetSetup.swift

@@ -77,7 +77,7 @@ extension Home.StateModel {
                 )
             )
         }
-      
+
         return targetProfiles
     }
 }

+ 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)
+    }
+}

+ 285 - 0
FreeAPSTests/OpenAPSSwiftTests/ProfileJavascriptTests.swift

@@ -0,0 +1,285 @@
+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,
+        Autotune?,
+        FreeAPSSettings
+    ) {
+        let pumpSettings = PumpSettings(
+            insulinActionCurve: 3,
+            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 autotune: Autotune? = nil
+        let freeaps = FreeAPSSettings()
+
+        return (pumpSettings, bgTargets, basalProfile, isf, preferences, carbRatios, tempTargets, model, autotune, 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,
+            autotune: inputs.8,
+            freeaps: inputs.9
+        )
+
+        #expect(profile.maxIob == 0)
+        #expect(profile.dia == 3)
+        #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,
+            autotune: inputs.8,
+            freeaps: inputs.9
+        )
+
+        #expect(profile.maxIob == 0)
+        #expect(profile.dia == 3)
+        #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,
+            autotune: inputs.8,
+            freeaps: inputs.9
+        )
+
+        #expect(profile.maxIob == 0)
+        #expect(profile.dia == 3)
+        #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,
+            autotune: inputs.8,
+            freeaps: inputs.9
+        )
+
+        #expect(profile.maxIob == 0)
+        #expect(profile.dia == 3)
+        #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,
+                autotune: inputs.8,
+                freeaps: inputs.9
+            )
+        }
+    }
+
+    @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)
+        ]
+
+        #expect(throws: ProfileError.invalidCurrentBasal(value: nil)) {
+            _ = 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,
+                autotune: inputs.8,
+                freeaps: inputs.9
+            )
+        }
+    }
+
+    @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,
+            autotune: inputs.8,
+            freeaps: inputs.9
+        )
+
+        #expect(profile.model == "554")
+    }
+}

+ 115 - 0
FreeAPSTests/OpenAPSSwiftTests/ProfileTargetsTests.swift

@@ -0,0 +1,115 @@
+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 bound target range for mmol/L input") func boundMmolTargets() async throws {
+        let mmolTargets = BGTargets(
+            units: .mmolL,
+            userPreferredUnits: .mmolL,
+            targets: [
+                BGTargetEntry(low: 3, high: 4, start: "00:00:00", offset: 0)
+            ]
+        )
+        let (_, result) = try Targets.bgTargetsLookup(targets: mmolTargets, tempTargets: [], profile: profile)
+        #expect(result.maxBg == 80)
+        #expect(result.minBg == 80)
+    }
+
+    @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)
+    }
+}

+ 1 - 10
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,5 +1,5 @@
 {
-  "originHash" : "46d76138047ae4a072c17dbca57a7f0f33fdfc4ac6e414d933801acd7488e4bb",
+  "originHash" : "52d77fc35af7fe71614051dee0b291e2a0d38522eac7ae4d37d2442e81c7530c",
   "pins" : [
     {
       "identity" : "cryptoswift",
@@ -20,15 +20,6 @@
       }
     },
     {
-      "identity" : "openapskit",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/kingst/OpenAPSKit.git",
-      "state" : {
-        "revision" : "5abb2e9735c71172abf38697825a90f24fbcad61",
-        "version" : "1.0.0"
-      }
-    },
-    {
       "identity" : "slidebutton",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/no-comment/SlideButton",