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

Merge pull request #544 from nightscout/determine-basal-to-swift-oref

[Part 1 of many] Oref determine-basal to Swift
Sam King 10 месяцев назад
Родитель
Сommit
ede485d3f4
31 измененных файлов с 2472 добавлено и 50 удалено
  1. 78 0
      Trio.xcodeproj/project.pbxproj
  2. 8 0
      Trio/Sources/APS/Extensions/DecimalExtensions.swift
  3. 108 31
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  4. 41 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DeterminationError.swift
  5. 31 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasal+Dosing.swift
  6. 346 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasal+Helpers.swift
  7. 413 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift
  8. 11 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedBGTargets+Getter.swift
  9. 24 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedInsulinSensitivities+Getter.swift
  10. 11 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+Autosens.swift
  11. 95 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+TherapySettingGetter.swift
  12. 75 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/CarbImpactParams.swift
  13. 146 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator+Forecasts.swift
  14. 289 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift
  15. 16 0
      Trio/Sources/APS/OpenAPSSwift/JSONBridge.swift
  16. 21 2
      Trio/Sources/APS/OpenAPSSwift/Logging/AlgorithmComparison.swift
  17. 12 6
      Trio/Sources/APS/OpenAPSSwift/Logging/JSONCompare.swift
  18. 26 0
      Trio/Sources/APS/OpenAPSSwift/Logging/OrefFunction.swift
  19. 7 0
      Trio/Sources/APS/OpenAPSSwift/Models/AdjustedGlucoseTargets.swift
  20. 34 0
      Trio/Sources/APS/OpenAPSSwift/Models/ForecastResult.swift
  21. 23 0
      Trio/Sources/APS/OpenAPSSwift/Models/GlucoseStatus.swift
  22. 71 0
      Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift
  23. 123 0
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  24. 37 0
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  25. 41 0
      TrioTests/CoreDataTests/GlucoseStorageTests.swift
  26. 104 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalDeltaCalculationTests.swift
  27. 259 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalSMBEnablementTests.swift
  28. 4 2
      TrioTests/OpenAPSSwiftTests/IobJsonTests.swift
  29. 2 1
      TrioTests/OpenAPSSwiftTests/MealJsonTests.swift
  30. 2 1
      TrioTests/OpenAPSSwiftTests/ProfileJavascriptTests.swift
  31. 14 7
      TrioTests/OpenAPSSwiftTests/ProfileJsNativeCompareTests.swift

+ 78 - 0
Trio.xcodeproj/project.pbxproj

@@ -632,6 +632,21 @@
 		DD2CC85C2D25DA1000445446 /* GlucoseTargetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2CC85B2D25D9CE00445446 /* GlucoseTargetsView.swift */; };
 		DD3078682D42F5CE00DE0490 /* WatchGlucoseObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3078672D42F5CE00DE0490 /* WatchGlucoseObject.swift */; };
 		DD30786A2D42F94000DE0490 /* GarminDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3078692D42F94000DE0490 /* GarminDevice.swift */; };
+		DD30B9C72E06257900DA677C /* DetermineBasalGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9C62E06257300DA677C /* DetermineBasalGenerator.swift */; };
+		DD30B9CA2E062A3400DA677C /* ForecastGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */; };
+		DD30B9CC2E062A7000DA677C /* ForecastResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9CB2E062A7000DA677C /* ForecastResult.swift */; };
+		DD30B9CE2E062AA300DA677C /* ForecastGenerator+Forecasts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9CD2E062AA300DA677C /* ForecastGenerator+Forecasts.swift */; };
+		DD30B9FE2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */; };
+		DD30BA002E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */; };
+		DD30BA022E074F0F00DA677C /* GlucoseStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */; };
+		DD30BA062E07667000DA677C /* DetermineBasal+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA052E07667000DA677C /* DetermineBasal+Helpers.swift */; };
+		DD30BA082E076CAA00DA677C /* DeterminationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA072E076CAA00DA677C /* DeterminationError.swift */; };
+		DD30BA0E2E07700000DA677C /* Profile+Autosens.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA0D2E076FFC00DA677C /* Profile+Autosens.swift */; };
+		DD30BA122E07764300DA677C /* Profile+TherapySettingGetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA112E07763E00DA677C /* Profile+TherapySettingGetter.swift */; };
+		DD30BA162E0780A500DA677C /* AdjustedGlucoseTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA152E0780A500DA677C /* AdjustedGlucoseTargets.swift */; };
+		DD30BA182E078F8900DA677C /* ComputedInsulinSensitivities+Getter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA172E078F8100DA677C /* ComputedInsulinSensitivities+Getter.swift */; };
+		DD30BA1A2E08AB9F00DA677C /* CarbImpactParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */; };
+		DD30BA1C2E08BA8800DA677C /* DetermineBasal+Dosing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA1B2E08BA8100DA677C /* DetermineBasal+Dosing.swift */; };
 		DD32CF982CC82463003686D6 /* TrioRemoteControl+Bolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */; };
 		DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */; };
 		DD32CF9C2CC82499003686D6 /* TrioRemoteControl+TempTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */; };
@@ -1535,6 +1550,21 @@
 		DD2CC85B2D25D9CE00445446 /* GlucoseTargetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseTargetsView.swift; sourceTree = "<group>"; };
 		DD3078672D42F5CE00DE0490 /* WatchGlucoseObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchGlucoseObject.swift; sourceTree = "<group>"; };
 		DD3078692D42F94000DE0490 /* GarminDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminDevice.swift; sourceTree = "<group>"; };
+		DD30B9C62E06257300DA677C /* DetermineBasalGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalGenerator.swift; sourceTree = "<group>"; };
+		DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastGenerator.swift; sourceTree = "<group>"; };
+		DD30B9CB2E062A7000DA677C /* ForecastResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastResult.swift; sourceTree = "<group>"; };
+		DD30B9CD2E062AA300DA677C /* ForecastGenerator+Forecasts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ForecastGenerator+Forecasts.swift"; sourceTree = "<group>"; };
+		DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalSMBEnablementTests.swift; sourceTree = "<group>"; };
+		DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalDeltaCalculationTests.swift; sourceTree = "<group>"; };
+		DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatus.swift; sourceTree = "<group>"; };
+		DD30BA052E07667000DA677C /* DetermineBasal+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DetermineBasal+Helpers.swift"; sourceTree = "<group>"; };
+		DD30BA072E076CAA00DA677C /* DeterminationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationError.swift; sourceTree = "<group>"; };
+		DD30BA0D2E076FFC00DA677C /* Profile+Autosens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+Autosens.swift"; sourceTree = "<group>"; };
+		DD30BA112E07763E00DA677C /* Profile+TherapySettingGetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+TherapySettingGetter.swift"; sourceTree = "<group>"; };
+		DD30BA152E0780A500DA677C /* AdjustedGlucoseTargets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustedGlucoseTargets.swift; sourceTree = "<group>"; };
+		DD30BA172E078F8100DA677C /* ComputedInsulinSensitivities+Getter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComputedInsulinSensitivities+Getter.swift"; sourceTree = "<group>"; };
+		DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbImpactParams.swift; sourceTree = "<group>"; };
+		DD30BA1B2E08BA8100DA677C /* DetermineBasal+Dosing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DetermineBasal+Dosing.swift"; sourceTree = "<group>"; };
 		DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Bolus.swift"; sourceTree = "<group>"; };
 		DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Meal.swift"; sourceTree = "<group>"; };
 		DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+TempTarget.swift"; sourceTree = "<group>"; };
@@ -2798,6 +2828,8 @@
 		3B5CD1EB2D4912A600CE213C /* OpenAPSSwift */ = {
 			isa = PBXGroup;
 			children = (
+				DD30B9C82E06295700DA677C /* Forecasts */,
+				DD30B9C52E0624C600DA677C /* DetermineBasal */,
 				3B139EF12DF06CD100D40797 /* Autosens */,
 				3B5CD2A42D4AEA5D00CE213C /* Extensions */,
 				3B1C5C282D68E1E3004E9273 /* Iob */,
@@ -2836,6 +2868,11 @@
 		3B5CD2A42D4AEA5D00CE213C /* Extensions */ = {
 			isa = PBXGroup;
 			children = (
+				DD30BA172E078F8100DA677C /* ComputedInsulinSensitivities+Getter.swift */,
+				DD30BA112E07763E00DA677C /* Profile+TherapySettingGetter.swift */,
+				DD30BA0D2E076FFC00DA677C /* Profile+Autosens.swift */,
+				3B4550522D862BF200551B0D /* PumpHistoryEvent+Duplicates.swift */,
+				3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */,
 				3B5CD2A32D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift */,
 				3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */,
 				3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */,
@@ -2850,6 +2887,9 @@
 		3B5CD2B22D4AEA6600CE213C /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				DD30BA152E0780A500DA677C /* AdjustedGlucoseTargets.swift */,
+				DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */,
+				DD30B9CB2E062A7000DA677C /* ForecastResult.swift */,
 				3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */,
 				3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */,
 				3B1C5C2D2D68E220004E9273 /* ComputedPumpHistoryEvent.swift */,
@@ -2862,6 +2902,8 @@
 		3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */ = {
 			isa = PBXGroup;
 			children = (
+				DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */,
+				DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */,
 				3BF92F2C2D86DEE9006B545A /* javascript */,
 				3B1C5C3C2D68E269004E9273 /* json */,
 				3BFA5BF72D989F380072B082 /* mocks */,
@@ -3669,6 +3711,27 @@
 			path = View;
 			sourceTree = "<group>";
 		};
+		DD30B9C52E0624C600DA677C /* DetermineBasal */ = {
+			isa = PBXGroup;
+			children = (
+				DD30BA1B2E08BA8100DA677C /* DetermineBasal+Dosing.swift */,
+				DD30BA072E076CAA00DA677C /* DeterminationError.swift */,
+				DD30BA052E07667000DA677C /* DetermineBasal+Helpers.swift */,
+				DD30B9C62E06257300DA677C /* DetermineBasalGenerator.swift */,
+			);
+			path = DetermineBasal;
+			sourceTree = "<group>";
+		};
+		DD30B9C82E06295700DA677C /* Forecasts */ = {
+			isa = PBXGroup;
+			children = (
+				DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */,
+				DD30B9CD2E062AA300DA677C /* ForecastGenerator+Forecasts.swift */,
+				DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */,
+			);
+			path = Forecasts;
+			sourceTree = "<group>";
+		};
 		DD3A3CEC2D29CFBA00AE478E /* Helper */ = {
 			isa = PBXGroup;
 			children = (
@@ -4507,6 +4570,7 @@
 				38B4F3CD25E5031100E76A18 /* Broadcaster.swift in Sources */,
 				383420D925FFEB3F002D46C1 /* Popup.swift in Sources */,
 				DD4C57AA2D73B3E2001BFF2C /* RestartLiveActivityIntentRequest.swift in Sources */,
+				DD30BA1A2E08AB9F00DA677C /* CarbImpactParams.swift in Sources */,
 				DD1745402C55BFC100211FAC /* AlgorithmAdvancedSettingsRootView.swift in Sources */,
 				3BCE75B32D4B38AE009E9453 /* InsulinSensitivities+Convert.swift in Sources */,
 				58645BA52CA2D347008AFCE7 /* ForecastSetup.swift in Sources */,
@@ -4560,6 +4624,7 @@
 				388E595C25AD948C0019842D /* TrioApp.swift in Sources */,
 				38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */,
 				DD1745352C55AE7E00211FAC /* TargetBehavoirRootView.swift in Sources */,
+				DD30BA022E074F0F00DA677C /* GlucoseStatus.swift in Sources */,
 				DDFF20502DB2C11900AB8A96 /* WatchStateSnapshot.swift in Sources */,
 				5887527C2BD986E1008B081D /* OpenAPSBattery.swift in Sources */,
 				38569348270B5DFB0002C50D /* GlucoseSource.swift in Sources */,
@@ -4622,9 +4687,11 @@
 				DD1745462C55C61500211FAC /* AutosensSettingsProvider.swift in Sources */,
 				DD6A4E802DBEC3EE008C4B26 /* StartupForceCloseWarningStepView.swift in Sources */,
 				DDA6E2852D2361F800C2988C /* LoopStatusView.swift in Sources */,
+				DD30BA122E07764300DA677C /* Profile+TherapySettingGetter.swift in Sources */,
 				DDF691012DA2CA11008BF16C /* AppDiagnosticsDataFlow.swift in Sources */,
 				DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */,
 				3B5CD2982D4AEA3C00CE213C /* Carbs.swift in Sources */,
+				DD30BA182E078F8900DA677C /* ComputedInsulinSensitivities+Getter.swift in Sources */,
 				3B5CD2992D4AEA3C00CE213C /* Isf.swift in Sources */,
 				3B5CD29A2D4AEA3C00CE213C /* ProfileError.swift in Sources */,
 				3B5CD29B2D4AEA3C00CE213C /* Basal.swift in Sources */,
@@ -4741,6 +4808,7 @@
 				DD4FFF332D458EE600B6CFF9 /* GarminWatchState.swift in Sources */,
 				DD6B7CB22C7B6F0800B75029 /* Rounding.swift in Sources */,
 				38DAB28A260D349500F74C1A /* FetchGlucoseManager.swift in Sources */,
+				DD30B9CE2E062AA300DA677C /* ForecastGenerator+Forecasts.swift in Sources */,
 				BDB8998A2C565D0C006F3298 /* CarbsGlucose+helper.swift in Sources */,
 				BD7DB88E2D2C4A17003D3155 /* BolusCalculationManager.swift in Sources */,
 				38F37828261260DC009DB701 /* Color+Extensions.swift in Sources */,
@@ -4793,6 +4861,7 @@
 				58645B9D2CA2D275008AFCE7 /* DeterminationSetup.swift in Sources */,
 				DDFF202F2DB1D14500AB8A96 /* NotificationPermissionStepView.swift in Sources */,
 				491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */,
+				DD30B9CC2E062A7000DA677C /* ForecastResult.swift in Sources */,
 				491D6FBE2D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift in Sources */,
 				491D6FBF2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift in Sources */,
 				491D6FC02D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift in Sources */,
@@ -4846,6 +4915,7 @@
 				BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */,
 				CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */,
 				385CEA8225F23DFD002D6D5B /* NightscoutStatus.swift in Sources */,
+				DD30BA162E0780A500DA677C /* AdjustedGlucoseTargets.swift in Sources */,
 				DD17453C2C55BFAD00211FAC /* AlgorithmAdvancedSettingsProvider.swift in Sources */,
 				F90692AA274B7AAE0037068D /* HealthKitManager.swift in Sources */,
 				38887CCE25F5725200944304 /* IOBEntry.swift in Sources */,
@@ -4867,6 +4937,7 @@
 				3B139EF32DF06CE100D40797 /* AutosensGenerator.swift in Sources */,
 				DDC38E102D9B377800ADCB46 /* OnboardingView+Util.swift in Sources */,
 				BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */,
+				DD30B9C72E06257900DA677C /* DetermineBasalGenerator.swift in Sources */,
 				3B1C5C332D68E233004E9273 /* PumpHistory+copy.swift in Sources */,
 				3B1C5C342D68E233004E9273 /* TimeExtensions.swift in Sources */,
 				1BBB001DAD60F3B8CEA4B1C7 /* ISFEditorStateModel.swift in Sources */,
@@ -4961,6 +5032,7 @@
 				F90692D3274B9A130037068D /* AppleHealthKitRootView.swift in Sources */,
 				3B5CD2A52D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift in Sources */,
 				BDF34F852C10C62E00D51995 /* GlucoseData.swift in Sources */,
+				DD30BA082E076CAA00DA677C /* DeterminationError.swift in Sources */,
 				19E1F7EC29D082FE005C8D20 /* IconConfigStateModel.swift in Sources */,
 				711C0CB42CAABE788916BC9D /* ManualTempBasalDataFlow.swift in Sources */,
 				DD1745222C55524800211FAC /* SMBSettingsProvider.swift in Sources */,
@@ -4968,6 +5040,7 @@
 				583684062BD178DB00070A60 /* GlucoseStored+helper.swift in Sources */,
 				49B9B57F2D5768D2009C6B59 /* AdjustmentStored+Helper.swift in Sources */,
 				F90692D6274B9A450037068D /* HealthKitStateModel.swift in Sources */,
+				DD30BA1C2E08BA8800DA677C /* DetermineBasal+Dosing.swift in Sources */,
 				BD1661312B82ADAB00256551 /* CustomProgressView.swift in Sources */,
 				C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */,
 				38E4453B274E411700EC9A94 /* Disk+VolumeInformation.swift in Sources */,
@@ -5021,6 +5094,7 @@
 				0F7A65FBD2CD8D6477ED4539 /* GlucoseNotificationSettingsProvider.swift in Sources */,
 				3171D2818C7C72CD1584BB5E /* GlucoseNotificationSettingsStateModel.swift in Sources */,
 				DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */,
+				DD30B9CA2E062A3400DA677C /* ForecastGenerator.swift in Sources */,
 				DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */,
 				DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */,
 				BDC530FF2D0F6BE300088832 /* ContactImageManager.swift in Sources */,
@@ -5032,6 +5106,7 @@
 				BD4D738D2D15A4080052227B /* TDDStored+CoreDataClass.swift in Sources */,
 				BD4D738E2D15A4080052227B /* TDDStored+CoreDataProperties.swift in Sources */,
 				DDE179582C910127003CDDB7 /* ForecastValue+CoreDataClass.swift in Sources */,
+				DD30BA062E07667000DA677C /* DetermineBasal+Helpers.swift in Sources */,
 				DDE179592C910127003CDDB7 /* ForecastValue+CoreDataProperties.swift in Sources */,
 				DDE1795A2C910127003CDDB7 /* CarbEntryStored+CoreDataClass.swift in Sources */,
 				DDE1795B2C910127003CDDB7 /* CarbEntryStored+CoreDataProperties.swift in Sources */,
@@ -5040,6 +5115,7 @@
 				BDDAF9EF2D00554500B34E7A /* SelectionPopoverView.swift in Sources */,
 				DDE1795F2C910127003CDDB7 /* PumpEventStored+CoreDataProperties.swift in Sources */,
 				DDE179602C910127003CDDB7 /* StatsData+CoreDataClass.swift in Sources */,
+				DD30BA0E2E07700000DA677C /* Profile+Autosens.swift in Sources */,
 				DD3F1F852D9DD84000DCE7B3 /* DeliveryLimitsStepView.swift in Sources */,
 				DDE179612C910127003CDDB7 /* StatsData+CoreDataProperties.swift in Sources */,
 				DDE179622C910127003CDDB7 /* Forecast+CoreDataClass.swift in Sources */,
@@ -5091,6 +5167,7 @@
 				3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */,
 				3BEF6AB32D97316F0076089D /* MealTotalTests.swift in Sources */,
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
+				DD30B9FE2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift in Sources */,
 				3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */,
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
@@ -5120,6 +5197,7 @@
 				3BEF6AB72D9750780076089D /* MealJsonTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
+				DD30BA002E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;

+ 8 - 0
Trio/Sources/APS/Extensions/DecimalExtensions.swift

@@ -5,3 +5,11 @@ extension Decimal {
         max(min(self, pickerSetting.max), pickerSetting.min)
     }
 }
+
+extension Collection where Element == Decimal {
+    /// Returns the arithmetic mean, or zero if empty.
+    var mean: Decimal {
+        guard !isEmpty else { return .zero }
+        return reduce(.zero, +) / Decimal(count)
+    }
+}

+ 108 - 31
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -371,7 +371,8 @@ final class OpenAPS {
             pumpHistory: pumpHistoryJSON,
             preferences: preferences,
             basalProfile: basalProfile,
-            trioCustomOrefVariables: trioCustomOrefVariables
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            useSwiftOref: useSwiftOref
         )
 
         debug(.openAPS, "\(simulation ? "[SIMULATION]" : "") OREF DETERMINATION: \(orefDetermination)")
@@ -834,40 +835,116 @@ final class OpenAPS {
         pumpHistory: JSON,
         preferences: JSON,
         basalProfile: JSON,
-        trioCustomOrefVariables: JSON
+        trioCustomOrefVariables: JSON,
+        useSwiftOref: Bool
     ) async throws -> RawJSON {
-        try await withCheckedThrowingContinuation { continuation in
-            jsWorker.inCommonContext { worker in
-                worker.evaluateBatch(scripts: [
-                    Script(name: Prepare.log),
-                    Script(name: Prepare.determineBasal),
-                    Script(name: Bundle.basalSetTemp),
-                    Script(name: Bundle.getLastGlucose),
-                    Script(name: Bundle.determineBasal)
-                ])
-
-                if let middleware = self.middlewareScript(name: OpenAPS.Middleware.determineBasal) {
-                    worker.evaluate(script: middleware)
-                }
+        let clock = Date()
+        let startJavascriptAt = Date()
+        let jsResult = await determineBasalJavascript(
+            glucose: glucose,
+            currentTemp: currentTemp,
+            iob: iob,
+            profile: profile,
+            autosens: autosens,
+            meal: meal,
+            microBolusAllowed: microBolusAllowed,
+            reservoir: reservoir,
+            pumpHistory: pumpHistory,
+            preferences: preferences,
+            basalProfile: basalProfile,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            clock: clock
+        )
+        let javascriptDuration = Date().timeIntervalSince(startJavascriptAt)
 
-                let result = worker.call(function: Function.generate, with: [
-                    iob,
-                    currentTemp,
-                    glucose,
-                    profile,
-                    autosens,
-                    meal,
-                    microBolusAllowed,
-                    reservoir,
-                    Date(),
-                    pumpHistory,
-                    preferences,
-                    basalProfile,
-                    trioCustomOrefVariables
-                ])
+        // Important: we want to make sure that this flag ensures that none
+        // of the native code runs
+        guard useSwiftOref else {
+            return try jsResult.returnOrThrow()
+        }
 
-                continuation.resume(returning: result)
+        let startSwiftAt = Date()
+        let (swiftResult, determineBasalInputs) = OpenAPSSwift.determineBasal(
+            glucose: glucose,
+            currentTemp: currentTemp,
+            iob: iob,
+            profile: profile,
+            autosens: autosens,
+            meal: meal,
+            microBolusAllowed: microBolusAllowed,
+            reservoir: reservoir,
+            pumpHistory: pumpHistory,
+            preferences: preferences,
+            basalProfile: basalProfile,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            clock: clock
+        )
+        let swiftDuration = Date().timeIntervalSince(startSwiftAt)
+
+        JSONCompare.logDifferences(
+            function: .determineBasal,
+            swift: swiftResult,
+            swiftDuration: swiftDuration,
+            javascript: jsResult,
+            javascriptDuration: javascriptDuration,
+            determineBasalInputs: determineBasalInputs
+        )
+
+        return try jsResult.returnOrThrow()
+    }
+
+    private func determineBasalJavascript(
+        glucose: JSON,
+        currentTemp: JSON,
+        iob: JSON,
+        profile: JSON,
+        autosens: JSON,
+        meal: JSON,
+        microBolusAllowed: Bool,
+        reservoir: JSON,
+        pumpHistory: JSON,
+        preferences: JSON,
+        basalProfile: JSON,
+        trioCustomOrefVariables: JSON,
+        clock: Date
+    ) async -> OrefFunctionResult {
+        do {
+            let result = try await withCheckedThrowingContinuation { continuation in
+                jsWorker.inCommonContext { worker in
+                    worker.evaluateBatch(scripts: [
+                        Script(name: Prepare.log),
+                        Script(name: Prepare.determineBasal),
+                        Script(name: Bundle.basalSetTemp),
+                        Script(name: Bundle.getLastGlucose),
+                        Script(name: Bundle.determineBasal)
+                    ])
+
+                    if let middleware = self.middlewareScript(name: OpenAPS.Middleware.determineBasal) {
+                        worker.evaluate(script: middleware)
+                    }
+
+                    let result = worker.call(function: Function.generate, with: [
+                        iob,
+                        currentTemp,
+                        glucose,
+                        profile,
+                        autosens,
+                        meal,
+                        microBolusAllowed,
+                        reservoir,
+                        clock,
+                        pumpHistory,
+                        preferences,
+                        basalProfile,
+                        trioCustomOrefVariables
+                    ])
+
+                    continuation.resume(returning: result)
+                }
             }
+            return .success(result)
+        } catch {
+            return .failure(error)
         }
     }
 

+ 41 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DeterminationError.swift

@@ -0,0 +1,41 @@
+import Foundation
+
+enum DeterminationError: LocalizedError, Equatable {
+    case missingGlucoseStatus
+    case missingProfile
+    case staleGlucoseData(ageMinutes: Double)
+    case glucoseOutOfRange(glucose: Decimal)
+    case cgmNoiseTooHigh(noise: Int)
+    case noDelta
+    case missingIob
+    case missingInputs
+    case eventualGlucoseCalculationError(sensitivity: Decimal, deviation: Decimal)
+    case determinationError
+
+    var errorDescription: String? {
+        switch self {
+        case .missingGlucoseStatus:
+            return String(localized: "No glucose status; cannot determine basal.")
+        case .missingProfile:
+            return String(localized: "No profile; cannot determine basal.")
+        case let .staleGlucoseData(ageMinutes):
+            return String(localized: "Glucose data is too old (\(ageMinutes) min ago).")
+        case let .glucoseOutOfRange(glucose):
+            return String(localized: "Glucose out of range: \(glucose.description).")
+        case let .cgmNoiseTooHigh(noise):
+            return String(localized: "CGM noise level too high: \(noise).")
+        case .noDelta:
+            return String(localized: "No glucose delta (flat readings); cannot determine trend.")
+        case .missingIob:
+            return String(localized: "No IOB data available; cannot determine basal.")
+        case .missingInputs:
+            return String(localized: "Missing required inputs; cannot determine basal.")
+        case let .eventualGlucoseCalculationError(sensitivity, deviation):
+            return String(
+                localized: "Could not calculate eventual glucose. Sensitivity: \(sensitivity.description), Deviation: \(deviation.description)"
+            )
+        case .determinationError:
+            return String(localized: "Unknown determination error.")
+        }
+    }
+}

+ 31 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasal+Dosing.swift

@@ -0,0 +1,31 @@
+import Foundation
+
+extension DeterminationGenerator {
+    struct DosingMetrics {
+        var rate: Decimal?
+        var duration: Decimal?
+        var units: Decimal? // microbolus
+        var insulinReq: Decimal?
+        var carbsReq: Decimal?
+        var reason: String
+        var manualBolusErrorString: Int?
+        var insulinForManualBolus: Decimal?
+        var minGuardBG: Decimal?
+        var minPredBG: Decimal?
+        var smbEnabled: Bool
+    }
+
+    static func determineDosing(
+        profile _: Profile,
+        currentTemp _: TempBasal,
+        iobData _: IobResult,
+        mealData _: ComputedCarbs,
+        autosensData _: Autosens,
+        forecastResult _: ForecastResult,
+        glucoseStatus _: GlucoseStatus,
+        enableSMB _: Bool,
+        currentTime _: Date
+    ) -> DosingMetrics? {
+        nil
+    }
+}

+ 346 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasal+Helpers.swift

@@ -0,0 +1,346 @@
+import Foundation
+
+extension DeterminationGenerator {
+    /// Smooths given CGM readings, and computes rolling delta statistics
+    /// (i.e., last, short-term, and long-term).
+    ///
+    /// Mirrors JavaScript oref `glucose-get-last.js` logic.
+    ///
+    /// - Returns: A `GlucoseStatus` containing:
+    ///   - `glucose`: the most recent glucose value (mg/dL),
+    ///   - `delta`: the 5-minute delta (mg/dL per 5m),
+    ///   - `shortAvgDelta`: the average delta over ~5–15 minutes,
+    ///   - `longAvgDelta`: the average delta over ~20–40 minutes,
+    ///   - `noise`: the CGM noise level (if any),
+    ///   - `date`: the timestamp of the “now” reading,
+    ///   - `lastCalIndex`: index of the last calibration record (always `nil` here),
+    ///   - `device`: the source device string.
+    ///
+    /// - Throws: Any `CoreDataError` or other error encountered during fetch or context work.
+    /// - Returns: `nil` if no valid glucose readings are found in the past day.
+    static func getGlucoseStatus(glucoseReadings: [BloodGlucose]) throws -> GlucoseStatus? {
+        // FIXME: put this here for now; use implementation in GlucoseStorage later (already implemented and commented out for now)
+        guard glucoseReadings.isNotEmpty else {
+            return nil
+        }
+
+        // Sort descending (newest first)
+        let sorted = glucoseReadings.sorted { $0.date > $1.date }
+
+        let mostRecentGlucose = sorted[0]
+        var mostRecentGlucoseReading: Int = mostRecentGlucose.glucose!
+        var mostRecentGlucoseDate: Date = mostRecentGlucose.dateString
+
+        var lastDeltas: [Decimal] = []
+        var shortDeltas: [Decimal] = []
+        var longDeltas: [Decimal] = []
+
+        // Walk older entries to compute deltas
+        for entry in sorted.dropFirst() {
+            // JS oref has logic here around skipping calibration readings.
+            // We never calibration record (never happens here, since type=="sgv")
+            // so we omit this check
+
+            // only use readings >38 mg/dL (to skip code values, <39)
+            guard let glucose = entry.glucose, glucose > 38 else { continue }
+
+            let minutesAgo = mostRecentGlucoseDate.timeIntervalSince(entry.dateString) / 60
+            guard minutesAgo != 0 else { continue }
+            // compute mg/dL per 5 m as a Decimal:
+            let change = Decimal(mostRecentGlucoseReading - glucose)
+            let avgDelta = (change / Decimal(minutesAgo)) * Decimal(5)
+
+            // very-recent (<2.5 m) smooths "now"
+            if minutesAgo > -2, minutesAgo <= 2.5 {
+                mostRecentGlucoseReading = (mostRecentGlucoseReading + glucose) / 2
+                mostRecentGlucoseDate = Date(
+                    timeIntervalSince1970: (
+                        mostRecentGlucoseDate.timeIntervalSince1970 + entry.dateString
+                            .timeIntervalSince1970
+                    ) / 2
+                )
+            }
+            // short window (~5–15 m)
+            else if minutesAgo > 2.5, minutesAgo <= 17.5 {
+                shortDeltas.append(avgDelta)
+                if minutesAgo < 7.5 {
+                    lastDeltas.append(avgDelta)
+                }
+            }
+            // long window (~20–40 m)
+            else if minutesAgo > 17.5, minutesAgo < 42.5 {
+                longDeltas.append(avgDelta)
+            }
+        }
+
+        // compute means (or zero)
+        let lastDelta: Decimal = lastDeltas.mean
+        let shortAvg: Decimal = shortDeltas.mean
+        let longAvg: Decimal = longDeltas.mean
+
+        return GlucoseStatus(
+            delta: lastDelta.rounded(toPlaces: 2),
+            glucose: Decimal(mostRecentGlucoseReading),
+            noise: Int(sorted[0].noise ?? 0),
+            shortAvgDelta: shortAvg.rounded(toPlaces: 2),
+            longAvgDelta: longAvg.rounded(toPlaces: 2),
+            date: mostRecentGlucoseDate,
+            lastCalIndex: nil,
+            device: "", // FIXME: will be filled once this gets moved back to GlucoseStorage
+        )
+    }
+
+    static func calculateExpectedDelta(
+        targetGlucose: Decimal,
+        eventualGlucose: Decimal,
+        glucoseImpact: Decimal
+    ) -> Decimal {
+        // JS expects glucose to rise/fall at rate of glucose impact
+        // adjusted by the rate at which glucose would need to rise/fall
+        // to move eventual glucose to target over a 2 hr window
+        // TODO: expects that glucose can only be available in 5min chunks. do we need to change this handling?
+
+        let fiveMinuteBlocks = (2 * 60) / 5
+        let delta = targetGlucose - eventualGlucose
+        return (glucoseImpact + Decimal(Int(delta) / fiveMinuteBlocks)).rounded(toPlaces: 1)
+    }
+
+    /// Determines whether SMBs are enabled based on profile settings,
+    /// computed meal data, CGM conditions, and any active overrides.
+    ///
+    /// Mirrors the JavaScript oref's `enable_smb()` logic.
+    ///
+    /// - Parameters:
+    ///   - glucose: The latest blood glucose reading.
+    ///   - profile: The user profile containing SMB preferences and temp-target flags.
+    ///   - autosens: The autosens data (not used in this logic).
+    ///   - mealData: Computed carbs-on-board and related meal information.
+    ///   - override: An optional override controlling SMB scheduling and hard-off flags.
+    ///   - shouldProtectDueToHIGH: `true` if CGM indicates a HIGH reading requiring SMB disable.
+    ///   - currentTime: The current system time for scheduled-off evaluation.
+    /// - Returns: `true` if SMBs should be enabled, `false` otherwise.
+    static func isSMBEnabled(
+        glucose: BloodGlucose,
+        profile: Profile,
+        autosens _: Autosens,
+        mealData: ComputedCarbs?,
+        override: Override?,
+        shouldProtectDueToHIGH: Bool,
+        currentTime: Date
+    ) -> Bool {
+        if let override = override {
+            if override.smbIsScheduledOff {
+                let startHour = override.start
+                let endHour = override.end
+                let hour = Calendar.current.component(.hour, from: currentTime)
+
+                // disable SMB during the scheduled-off window [start, end)
+                if startHour < endHour {
+                    if hour >= Int(startHour), hour < Int(endHour) {
+                        return false
+                    }
+                }
+                // disable SMB if window wraps midnight
+                else if startHour > endHour {
+                    if hour >= Int(startHour) || hour < Int(endHour) {
+                        return false
+                    }
+                }
+                // special cases: off all day or single-hour off
+                else {
+                    if startHour == 0, endHour == 0 {
+                        return false
+                    }
+                    if hour == Int(startHour) {
+                        return false
+                    }
+                }
+            } else if override.smbIsOff {
+                // hard-off override disables SMB entirely
+                return false
+            }
+        }
+
+        if let hasActiveTempTarget = profile.temptargetSet, hasActiveTempTarget {
+            // disable SMB when a high temp target is active and not allowed
+            if !profile.allowSMBWithHighTemptarget,
+               let targetGlucose = profile.targetBg,
+               targetGlucose > 100
+            {
+                return false
+            }
+
+            // enable SMB when a low temp target is active
+            if profile.enableSMBWithTemptarget,
+               let targetGlucose = profile.targetBg,
+               targetGlucose < 100
+            {
+                return true
+            }
+        }
+
+        // disable SMB for invalid CGM readings (HIGH)
+        if shouldProtectDueToHIGH {
+            return false
+        }
+
+        // enable SMB unconditionally if always-on preference is set
+        if profile.enableSMBAlways {
+            return true
+        }
+
+        // enable SMB when carbs-on-board (COB) exists
+        if profile.enableSMBWithCOB,
+           let cob = mealData?.mealCOB,
+           cob > 0
+        {
+            return true
+        }
+
+        // enable SMB for the full post-carb window
+        if profile.enableSMBAfterCarbs,
+           let carbs = mealData?.carbs,
+           carbs > 0
+        {
+            return true
+        }
+
+        // enable SMB when BG exceeds the high-BG threshold
+        if profile.enableSMBHighBg,
+           let glucoseVal = glucose.glucose ?? glucose.sgv,
+           glucoseVal >= Int(profile.enableSMBHighBgTarget)
+        {
+            return true
+        }
+
+        // no enable condition met → disable SMB
+        return false
+    }
+
+    static func calculateSensitivityRatio(
+        profile: Profile,
+        autosens: Autosens?,
+        targetGlucose: Decimal,
+        temptargetSet: Bool
+    ) -> Decimal {
+        let normalTarget: Decimal = 100
+        let halfBasalTarget = profile.halfBasalExerciseTarget
+        var ratio: Decimal = 1
+
+        // High temp target raises sensitivity or low temp lowers it
+        if (profile.highTemptargetRaisesSensitivity && temptargetSet && targetGlucose > normalTarget) ||
+            (profile.lowTemptargetLowersSensitivity && temptargetSet && targetGlucose < normalTarget)
+        {
+            let c = halfBasalTarget - normalTarget
+            if c * (c + targetGlucose - normalTarget) <= 0 {
+                ratio = profile.autosensMax
+            } else {
+                ratio = c / (c + targetGlucose - normalTarget)
+            }
+            ratio = min(ratio, profile.autosensMax)
+            // You can round here if needed: ratio = ratio.rounded(2)
+            return ratio
+        }
+        // Use autosens if present
+        if let autosens = autosens {
+            return autosens.ratio
+        }
+        // Otherwise default to 1.0 (no adjustment)
+        return 1.0
+    }
+
+    static func computeAdjustedBasal(currentBasalRate: Decimal, sensitivityRatio: Decimal) -> Decimal {
+        // FIXME: Ideally, we round this here to allowed pump basal increments
+        currentBasalRate * sensitivityRatio
+    }
+
+    static func computeAdjustedSensitivity(sensitivity: Decimal, sensitivityRatio: Decimal) -> Decimal {
+        guard sensitivityRatio != 1.0 else { return sensitivity }
+        return (sensitivity / sensitivityRatio).rounded(toPlaces: 1)
+    }
+
+    static func checkCurrentTempBasalRateSafety(
+        currentTemp: TempBasal,
+        lastTempTarget: IobResult.LastTemp?,
+        currentTime: Date
+    ) -> Bool {
+        guard let lastTemp = lastTempTarget, let lastTempDate = lastTemp.timestamp,
+              let lastTempDuration = lastTemp.duration else { return true }
+        // TODO: throw error for malformed IobResult? Can this be malformed?
+
+        let lastTempAge = Int(currentTime.timeIntervalSince(lastTempDate) / 60) // in minutes
+//        let tempModulus = Int(lastTempAge + currentTemp.duration) % 30 // only used in JS as output; will leave it here for now
+
+        if currentTemp.rate != lastTemp.rate, lastTempAge > 10, currentTemp.duration > 0 {
+            // Rates don’t match and temp is old: cancel temp
+            return false
+        }
+        let lastTempEnded = lastTempAge - Int(lastTempDuration) // TODO: check if this comes in minutes
+
+        if lastTempEnded > 5, lastTempAge > 10 {
+            // Last temp ended long ago but temp is running: cancel temp
+            return false
+        }
+
+        return true
+    }
+
+    /// Adjust glucose targets (min, max, target) based on autosens and/or noise.
+    /// - Returns: adjusted targets and new threshold
+    static func adjustGlucoseTargets(
+        profile: Profile,
+        autosens: Autosens?,
+        temptargetSet: Bool,
+        targetGlucose: Decimal,
+        minGlucose: Decimal,
+        maxGlucose: Decimal,
+        noise: Int
+    ) -> (targets: AdjustedGlucoseTargets, threshold: Decimal) {
+        var minGlucose = minGlucose
+        var maxGlucose = maxGlucose
+        var targetGlucose = targetGlucose
+
+        // Only adjust glucose targets for autosens if no temp target set
+        if !temptargetSet, let autosens = autosens {
+            if (profile.sensitivityRaisesTarget && autosens.ratio < 1) ||
+                (profile.resistanceLowersTarget && autosens.ratio > 1)
+            {
+                minGlucose = ((minGlucose - 60) / autosens.ratio + 60).rounded(toPlaces: 0)
+                maxGlucose = ((maxGlucose - 60) / autosens.ratio + 60).rounded(toPlaces: 0)
+                targetGlucose = max(80, ((targetGlucose - 60) / autosens.ratio + 60).rounded(toPlaces: 0))
+            }
+        }
+
+        // Raise target for noisy/CGM data
+        if noise >= 2 {
+            let noisyCGMTargetMultiplier = max(1.1, profile.noisyCGMTargetMultiplier)
+            minGlucose = min(200, minGlucose * noisyCGMTargetMultiplier).rounded(toPlaces: 0)
+            targetGlucose = min(200, targetGlucose * noisyCGMTargetMultiplier).rounded(toPlaces: 0)
+            maxGlucose = min(200, maxGlucose * noisyCGMTargetMultiplier).rounded(toPlaces: 0)
+        }
+
+        // Calculate threshold: minGlucose thresholds: 80->60, 90->65, etc.
+        var threshold = minGlucose - 0.5 * (minGlucose - 40)
+        threshold = min(max(profile.thresholdSetting, threshold, 60), 120)
+        threshold = threshold.rounded(toPlaces: 0)
+
+        return (AdjustedGlucoseTargets(minGlucose: minGlucose, maxGlucose: maxGlucose, targetGlucose: targetGlucose), threshold)
+    }
+
+    static func buildGlucoseImpactSeries(
+        iobDataSeries: [IobResult],
+        sensitivity: Decimal,
+        withZeroTemp: Bool = false
+    ) -> [Decimal] {
+        // FIXME: this is assuming 5min steps...
+        // Activity is U/hr
+        if withZeroTemp {
+            return iobDataSeries.map { iobWithZeroTemp in
+                -iobWithZeroTemp.activity * sensitivity * 5 }
+        } else {
+            return iobDataSeries.map { iob in
+                -iob.activity * sensitivity * 5
+            }
+        }
+    }
+}

+ 413 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -0,0 +1,413 @@
+import Foundation
+
+protocol OverrideHandler {
+    func overrideProfileParameters(profile: Profile, override: Override?) throws -> Profile
+
+    // TODO: handle mutation of profile parameters that the user can alter using Overrides
+    /// This could also possibly be handled via an extension of our existing `ProfileGenerator` (?)
+}
+
+enum DeterminationGenerator {
+    // override data can just be fetched from the DB
+    // handling via overrideManager ?
+
+    static func generate(
+        profile: Profile,
+        currentTemp: TempBasal,
+        iobData: [IobResult],
+        mealData: ComputedCarbs,
+        autosensData: Autosens,
+        reservoirData _: Decimal,
+        glucose: [BloodGlucose],
+        currentTime: Date
+    ) throws -> Determination? {
+        let glucoseStatus = try Self.getGlucoseStatus(glucoseReadings: glucose)
+
+        try checkDeterminationInputs(
+            glucoseStatus: glucoseStatus,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            profile: profile,
+            currentTime: currentTime,
+        )
+
+        guard let glucoseStatus = glucoseStatus else { throw DeterminationError.missingInputs }
+
+        let currentGlucose: Decimal = glucoseStatus.glucose
+
+        if let errorDetermination = handleTempBasalCases(
+            glucoseStatus: glucoseStatus,
+            profile: profile,
+            currentTemp: currentTemp,
+            currentTime: currentTime
+        ) {
+            return errorDetermination
+        }
+
+        let sensitivityRatio = calculateSensitivityRatio(
+            profile: profile,
+            autosens: autosensData,
+            targetGlucose: profile.targetBg ?? 120,
+            temptargetSet: profile.temptargetSet ?? false
+        )
+
+        let basal = computeAdjustedBasal(
+            currentBasalRate: profile.currentBasal ?? profile.basalFor(time: currentTime),
+            sensitivityRatio: sensitivityRatio
+        )
+        let sensitivity = computeAdjustedSensitivity(
+            sensitivity: profile.sens ?? profile.sensitivityFor(time: currentTime),
+            sensitivityRatio: sensitivityRatio
+        )
+
+        // Safety check: current temp vs. last temp in iob
+        if !checkCurrentTempBasalRateSafety(
+            currentTemp: currentTemp,
+            lastTempTarget: iobData[0].lastTemp,
+            currentTime: currentTime
+        ) {
+            let reason =
+                "Safety check: currentTemp does not match lastTemp in IOB or lastTemp ended long ago; canceling temp basal."
+            return Determination(
+                id: UUID(),
+                reason: reason,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: 0,
+                duration: 0,
+                iob: iobData[0].iob,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: .absolute,
+                bg: glucoseStatus.glucose,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: profile.targetBg,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: nil,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: profile.carbRatio,
+                received: false
+            )
+        }
+
+        let (adjustedGlucoseTargets, threshold) = adjustGlucoseTargets(
+            profile: profile,
+            autosens: autosensData,
+            temptargetSet: profile.temptargetSet ?? false,
+            targetGlucose: profile.targetBg ?? 100, // TODO: grab from therapy settings
+            minGlucose: profile.minBg ?? 70, // TODO: can we force unwrap?
+            maxGlucose: profile.maxBg ?? 180,
+            noise: 1
+        )
+
+        let glucoseImpactSeries = buildGlucoseImpactSeries(iobDataSeries: iobData, sensitivity: sensitivity)
+        let glucoseImpactSeriesWithZeroTemp = buildGlucoseImpactSeries(
+            iobDataSeries: iobData,
+            sensitivity: sensitivity,
+            withZeroTemp: true
+        )
+
+        let currentGlucoseImpact = glucoseImpactSeries[0]
+
+        let minDelta = min(glucoseStatus.delta, glucoseStatus.shortAvgDelta)
+        let minAvgDelta = min(glucoseStatus.shortAvgDelta, glucoseStatus.longAvgDelta)
+        let longAvgDelta = glucoseStatus.longAvgDelta
+
+        let intervals: Decimal = 6 // 30 / 5
+
+        var deviation = (intervals * (minDelta - currentGlucoseImpact)).rounded(toPlaces: 0)
+        if deviation < 0 {
+            deviation = (intervals * (minAvgDelta - currentGlucoseImpact)).rounded(toPlaces: 0)
+            if deviation < 0 {
+                deviation = (intervals * (longAvgDelta - currentGlucoseImpact)).rounded(toPlaces: 0)
+            }
+        }
+
+        // Calculate what oref calls "naive eventual glucose"
+        let currentIob = iobData[0].iob
+
+        let naiveEventualGlucose: Decimal
+        if currentIob > 0 {
+            naiveEventualGlucose = (currentGlucose - (currentIob * sensitivity)).rounded(toPlaces: 0)
+        } else {
+            naiveEventualGlucose =
+                (currentGlucose - (currentIob * min(profile.sens ?? profile.sensitivityFor(time: currentTime), sensitivity)))
+                    .rounded(toPlaces: 0)
+        }
+
+        let eventualGlucose = naiveEventualGlucose + deviation
+
+        // Safety: if we ever get an invalid Decimal (very rare with Decimal), handle
+        guard eventualGlucose.isFinite else {
+            throw DeterminationError.eventualGlucoseCalculationError(sensitivity: sensitivity, deviation: deviation)
+        }
+
+        let forecastResult = ForecastGenerator.generate(
+            glucose: currentGlucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            glucoseImpactSeriesWithZeroTemp: glucoseImpactSeriesWithZeroTemp,
+            iobData: iobData,
+            mealData: mealData,
+            profile: profile,
+            adjustedSensitivity: sensitivity,
+            sensitivityRatio: sensitivityRatio,
+            naiveEventualGlucose: naiveEventualGlucose,
+            eventualGlucose: eventualGlucose,
+            threshold: threshold,
+            currentTime: currentTime
+        )
+
+        // used for pre dosing decision sanity later on
+        let expectedDelta = calculateExpectedDelta(
+            targetGlucose: profile.targetBg ?? 100,
+            eventualGlucose: eventualGlucose,
+            glucoseImpact: currentGlucoseImpact
+        )
+
+        // TODO: STOPPING at LINE 1152
+
+        // FIXME: properly populate all fields!
+        let temporaryResult = Determination(
+            id: UUID(),
+            reason: "FOR TESTING: output after forecasting",
+            units: nil,
+            insulinReq: nil,
+            eventualBG: Int(forecastResult.eventualGlucose),
+            sensitivityRatio: sensitivityRatio, // this would only the AS-adjusted one for now
+            rate: nil,
+            duration: nil,
+            iob: iobData.first?.iob,
+            cob: mealData.mealCOB,
+            predictions: Predictions(
+                iob: forecastResult.iob.map { Int($0) },
+                zt: forecastResult.zt.map { Int($0) },
+                cob: forecastResult.cob.map { Int($0) },
+                uam: forecastResult.uam.map { Int($0) }
+            ),
+            deliverAt: currentTime,
+            carbsReq: nil,
+            temp: nil,
+            bg: currentGlucose,
+            reservoir: nil,
+            isf: nil,
+            timestamp: currentTime,
+            tdd: nil,
+            current_target: nil,
+            insulinForManualBolus: nil,
+            manualBolusErrorString: nil,
+            minDelta: nil,
+            expectedDelta: expectedDelta,
+            minGuardBG: forecastResult.minGuardGlucose,
+            minPredBG: forecastResult.minForecastedGlucose,
+            threshold: threshold,
+            carbRatio: nil,
+            received: false,
+        )
+
+        // TODO: how to handle output?
+        // TODO: how to handle logging?
+
+        return temporaryResult
+    }
+
+    static func checkDeterminationInputs(
+        glucoseStatus: GlucoseStatus?,
+        currentTemp _: TempBasal?,
+        iobData: [IobResult]?,
+        profile: Profile?,
+        currentTime: Date = Date()
+    ) throws {
+        guard let glucoseStatus = glucoseStatus else {
+            throw DeterminationError.missingGlucoseStatus
+        }
+        guard let profile = profile else {
+            throw DeterminationError.missingProfile
+        }
+        let glucoseAge = currentTime.timeIntervalSince(glucoseStatus.date)
+        if glucoseAge > 15 * 60 {
+            throw DeterminationError.staleGlucoseData(ageMinutes: glucoseAge / 60)
+        }
+        if glucoseStatus.glucose < 39 || glucoseStatus.glucose > 600 {
+            throw DeterminationError.glucoseOutOfRange(glucose: glucoseStatus.glucose)
+        }
+        if glucoseStatus.delta == 0 {
+            throw DeterminationError.noDelta
+        }
+        guard let _ = iobData else {
+            throw DeterminationError.missingIob
+        }
+    }
+
+    static func handleTempBasalCases(
+        glucoseStatus: GlucoseStatus,
+        profile: Profile,
+        currentTemp: TempBasal?,
+        currentTime: Date
+    ) -> Determination? {
+        let glucose = glucoseStatus.glucose
+        let noise = glucoseStatus.noise
+        let bgTime = glucoseStatus.date
+        let minAgo = Decimal(currentTime.timeIntervalSince(bgTime) / 60) // minutes
+        let shortAvgDelta = glucoseStatus.shortAvgDelta
+        let longAvgDelta = glucoseStatus.longAvgDelta
+        let delta = glucoseStatus.delta
+        let device = glucoseStatus.device
+
+        // Always use profile-supplied basal
+        let basal = profile.currentBasal ?? profile.basalFor(time: currentTime)
+
+        // Compose tick for log
+        let tick: String = (delta > -0.5) ? "+\(delta.rounded(toPlaces: 0))" : "\(delta.rounded(toPlaces: 0))"
+        let minDelta = min(delta, shortAvgDelta)
+        let minAvgDelta = min(shortAvgDelta, longAvgDelta)
+        let maxDelta = max(delta, shortAvgDelta, longAvgDelta)
+
+        var reason = ""
+
+        // === ERROR CONDITIONS ===
+        // xDrip code 38 = sensor error; BG <= 10 = ???/calibrating; noise >= 3 = high noise
+        if glucose <= 10 || glucose == 38 || noise >= 3 {
+            reason = "CGM is calibrating, in ??? state, or noise is high"
+        }
+        // minAgo (BG age) > 12 or < -5 = old/future BG
+        if minAgo > 12 || minAgo < -5 {
+            reason =
+                "If current system time \(currentTime) is correct, then BG data is too old. The last BG data was read \(minAgo) min ago at \(bgTime)"
+        }
+        // CGM data unchanged (flat)
+        if shortAvgDelta == 0 && longAvgDelta == 0 {
+            if glucoseStatus.lastCalIndex != nil, glucoseStatus.lastCalIndex! < 3 {
+                reason = "CGM was just calibrated"
+            } else {
+                reason =
+                    "CGM data is unchanged (\(glucose)+\(delta)) for 5m w/ \(shortAvgDelta) mg/dL ~15m change & \(longAvgDelta) mg/dL ~45m change"
+            }
+        }
+
+        let errorDetected =
+            glucose <= 10 ||
+            glucose == 38 ||
+            noise >= 3 ||
+            minAgo > 12 ||
+            minAgo < -5 ||
+            (shortAvgDelta == 0 && longAvgDelta == 0)
+
+        // === IF ERROR, CANCEL/SHORTEN TEMPS ===
+        guard errorDetected, let currentTemp = currentTemp else { return nil }
+
+        if currentTemp.rate >= basal {
+            // Cancel high temp: set 0U/hr for 0m (neutralizes)
+            let reasonWithAction = reason + ". Canceling high temp basal of \(currentTemp.rate)U/hr."
+            return Determination(
+                id: UUID(),
+                reason: reasonWithAction,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: 0,
+                duration: 0,
+                iob: nil,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: .absolute,
+                bg: glucose,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: profile.targetBg,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: minDelta,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: profile.carbRatio,
+                received: false
+            )
+        } else if currentTemp.rate == 0, currentTemp.duration > 30 {
+            // Shorten long zero temp to 30m
+            let reasonWithAction = reason + ". Shortening \(currentTemp.duration)m long zero temp to 30m."
+            return Determination(
+                id: UUID(),
+                reason: reasonWithAction,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: 0,
+                duration: 30,
+                iob: nil,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: .absolute,
+                bg: glucose,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: profile.targetBg,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: minDelta,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: profile.carbRatio,
+                received: false
+            )
+        } else {
+            // Do nothing (temp already safe)
+            let reasonWithAction = reason + ". Temp \(currentTemp.rate) <= current basal \(basal)U/hr; doing nothing."
+            return Determination(
+                id: UUID(),
+                reason: reasonWithAction,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: currentTemp.rate,
+                duration: Decimal(currentTemp.duration),
+                iob: nil,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: currentTemp.temp,
+                bg: glucose,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: profile.targetBg,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: minDelta,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: profile.carbRatio,
+                received: false
+            )
+        }
+    }
+}

+ 11 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedBGTargets+Getter.swift

@@ -0,0 +1,11 @@
+// import Foundation
+//
+// extension ComputedBGTargets {
+//    func targetEntry(for time: Date = Date()) -> ComputedBGTargetEntry? {
+//        // Assumes targets are sorted by start/offset ascending, wrap at midnight
+//        let nowMinutes = Calendar.current.component(.hour, from: time) * 60 +
+//            Calendar.current.component(.minute, from: time)
+//        // Find last entry with offset <= nowMinutes
+//        return targets.last(where: { $0.offset <= nowMinutes }) ?? targets.first
+//    }
+// }

+ 24 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedInsulinSensitivities+Getter.swift

@@ -0,0 +1,24 @@
+import Foundation
+
+extension ComputedInsulinSensitivities {
+    /// Returns the insulin sensitivity (ISF) for a specific Date (using the closest entry).
+    func sensitivity(for date: Date) -> Decimal? {
+        guard !sensitivities.isEmpty else { return nil }
+        // Assumes all offsets are in minutes from midnight
+        let calendar = Calendar.current
+        let components = calendar.dateComponents([.hour, .minute], from: date)
+        let minutesSinceMidnight = (components.hour ?? 0) * 60 + (components.minute ?? 0)
+
+        // Find the entry whose offset is the largest but not greater than the time
+        let sorted = sensitivities.sorted(by: { $0.offset < $1.offset })
+        var current = sorted.first
+        for entry in sorted {
+            if entry.offset <= minutesSinceMidnight {
+                current = entry
+            } else {
+                break
+            }
+        }
+        return current?.sensitivity
+    }
+}

+ 11 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+Autosens.swift

@@ -0,0 +1,11 @@
+import Foundation
+
+// Extend Profile for easy ISF replacement
+extension Profile {
+    func withAutosensISF(_ autosens: Autosens) -> Profile {
+        guard let newisf = autosens.newisf else { return self }
+        var copy = self
+        copy.sens = newisf
+        return copy
+    }
+}

+ 95 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+TherapySettingGetter.swift

@@ -0,0 +1,95 @@
+import Foundation
+
+extension Profile {
+    /// Returns the basal rate for the given time (default: now), or 0 if not found.
+    func basalFor(time: Date = Date()) -> Decimal {
+        guard let entries = basalprofile, !entries.isEmpty else {
+            return currentBasal ?? 0
+        }
+
+        let calendar = Calendar.current
+
+        // Get today's midnight
+        let startOfDay = calendar.startOfDay(for: time)
+        let nowMinutes = calendar.dateComponents([.minute], from: startOfDay, to: time).minute ?? 0
+
+        for (index, entry) in entries.enumerated() {
+            let startMinutes = entry.minutes
+            let endMinutes: Int
+
+            if index < entries.count - 1 {
+                endMinutes = entries[index + 1].minutes
+            } else {
+                endMinutes = 24 * 60 // 1440, end of day
+            }
+
+            if nowMinutes >= startMinutes, nowMinutes < endMinutes {
+                return entry.rate
+            }
+        }
+        return 0.1
+    }
+
+    /// Returns the ISF (insulin sensitivity factor) for the given time (default: now), or 200 if not found.
+    func sensitivityFor(time: Date = Date()) -> Decimal {
+        guard let isfProfile = isfProfile,
+              !isfProfile.sensitivities.isEmpty
+        else {
+            // Fallback to single value, if present
+            return sens ?? 200
+        }
+
+        let calendar = Calendar.current
+        let startOfDay = calendar.startOfDay(for: time)
+        let nowMinutes = calendar.dateComponents([.minute], from: startOfDay, to: time).minute ?? 0
+
+        let entries = isfProfile.sensitivities.sorted { $0.offset < $1.offset }
+
+        for (index, entry) in entries.enumerated() {
+            let startMinutes = entry.offset
+            let endMinutes: Int
+            if index < entries.count - 1 {
+                endMinutes = entries[index + 1].offset
+            } else {
+                endMinutes = 24 * 60 // 1440, end of day
+            }
+
+            if nowMinutes >= startMinutes, nowMinutes < endMinutes {
+                return entry.sensitivity
+            }
+        }
+        return sens ?? 200
+    }
+
+    /// Returns the carb ratio for the given time (default: now), or the top-level value, or 10 if not found.
+    func carbRatioFor(time: Date = Date()) -> Decimal {
+        // First: try using the dynamic schedule
+        if let carbRatios = carbRatios, !carbRatios.schedule.isEmpty {
+            let calendar = Calendar.current
+            let startOfDay = calendar.startOfDay(for: time)
+            let nowMinutes = calendar.dateComponents([.minute], from: startOfDay, to: time).minute ?? 0
+
+            let entries = carbRatios.schedule.sorted { $0.offset < $1.offset }
+
+            for (index, entry) in entries.enumerated() {
+                let startMinutes = entry.offset
+                let endMinutes: Int
+                if index < entries.count - 1 {
+                    endMinutes = entries[index + 1].offset
+                } else {
+                    endMinutes = 24 * 60 // 1440, end of day
+                }
+
+                if nowMinutes >= startMinutes, nowMinutes < endMinutes {
+                    return entry.ratio
+                }
+            }
+        }
+        // Second: fallback to flat profile value if present
+        if let carbRatio = self.carbRatio {
+            return carbRatio
+        }
+        // Third: fallback default (safe assumption)
+        return 30
+    }
+}

+ 75 - 0
Trio/Sources/APS/OpenAPSSwift/Forecasts/CarbImpactParams.swift

@@ -0,0 +1,75 @@
+import Foundation
+
+struct CarbImpactParams {
+    let carbSensivityFactor: Decimal
+    let cappedCarbImpact: Decimal
+    let remainingCarbAbsorptionTime: Decimal
+    let maxAbsorptionIntervals: Int
+    let triangleIntervals: Int
+    let remainingCarbImpactPeak: Decimal
+
+    static func calculate(
+        adjustedSensitivity: Decimal,
+        profile: Profile,
+        mealData: ComputedCarbs,
+        carbImpact: Decimal,
+        sensitivityRatio: Decimal,
+        currentTime: Date
+    ) -> CarbImpactParams {
+        let carbSensivityFactor = adjustedSensitivity / (profile.carbRatio ?? profile.carbRatioFor(time: currentTime))
+
+        // Initial carb impact in mg/dL per 5m
+        let initialCarbImpact = carbImpact * carbSensivityFactor
+        let maxCarbAbsorptionRate: Decimal = 30 // g/h
+        let maxCarbImpact = (maxCarbAbsorptionRate * carbSensivityFactor * 5 / 60).rounded(toPlaces: 1)
+        let cappedCarbImpact = min(initialCarbImpact, maxCarbImpact)
+
+        let computedRemainingCarbAbsorptionTime = ForecastGenerator.calculateRemainingCarbAbsorptionTime(
+            sensitivityRatio: sensitivityRatio,
+            maxMealAbsorptionTime: profile.maxMealAbsorptionTime,
+            mealCOB: mealData.mealCOB,
+            lastCarbTime: Date(timeIntervalSince1970: mealData.lastCarbTime),
+            currentTime: currentTime
+        )
+        // Clamp remainingTime for more robustness
+        let remainingCarbAbsorptionTime = min(computedRemainingCarbAbsorptionTime, profile.maxMealAbsorptionTime)
+
+        // Convert remainingCarbAbsorptionTime (hours) to intervals (each 5m):
+        let dynamicAbsorptionIntervals = Int((remainingCarbAbsorptionTime * 60) / 5)
+        // Number of 5-minute intervals over which we expect *all* carbs to absorb
+        let maxAbsorptionIntervals = Int(profile.maxMealAbsorptionTime * Decimal(60) / 5)
+        // Use smaller of both computed intervals, the dynamic and the max-clamped one as the actual # of decay triangle interval
+        let triangleIntervals = min(dynamicAbsorptionIntervals, maxAbsorptionIntervals)
+
+        // Total CI (mg/dL)
+        let totalCarbImpact = max(0, cappedCarbImpact / 5 * 60 * remainingCarbAbsorptionTime / 2)
+        // Total carbs absorbed from CI (g)
+        let totalCarbsAbsorbed: Decimal = totalCarbImpact / carbSensivityFactor
+
+        // Remaining carbs cap/fraction logic
+        let remainingCarbsCap = min(90, profile.remainingCarbsCap)
+        let remainingCarbsFraction = min(1, profile.remainingCarbsFraction)
+        let remainingCarbsIgnore = 1 - remainingCarbsFraction
+
+        var remainingCarbs = max(0, mealData.mealCOB - totalCarbsAbsorbed - mealData.carbs * remainingCarbsIgnore)
+        remainingCarbs = min(remainingCarbsCap, remainingCarbs)
+
+        // /\ triangle for remaining carbs
+        // Peak impact (mg/dL per 5m) of the *remaining* carbs
+        let remainingCarbImpactPeak: Decimal
+        if remainingCarbAbsorptionTime > 0 {
+            remainingCarbImpactPeak = (remainingCarbs * carbSensivityFactor * 5 / 60) / (remainingCarbAbsorptionTime / 2)
+        } else {
+            remainingCarbImpactPeak = 0
+        }
+
+        return CarbImpactParams(
+            carbSensivityFactor: carbSensivityFactor,
+            cappedCarbImpact: cappedCarbImpact,
+            remainingCarbAbsorptionTime: remainingCarbAbsorptionTime,
+            maxAbsorptionIntervals: maxAbsorptionIntervals,
+            triangleIntervals: triangleIntervals,
+            remainingCarbImpactPeak: remainingCarbImpactPeak
+        )
+    }
+}

+ 146 - 0
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator+Forecasts.swift

@@ -0,0 +1,146 @@
+import Foundation
+
+extension ForecastGenerator {
+    // TODO: Dynamic ISF not yet supported
+
+    static func forecastIOB(
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
+        deviation: Decimal,
+    ) -> [Decimal] {
+        var result = [startingGlucose]
+        for (count, glucoseImpact) in glucoseImpactSeries.enumerated() {
+            let forecastedDeviation = deviation * (1 - min(1, Decimal(count) / (60 / 5)))
+            let next = result.last! + glucoseImpact + forecastedDeviation
+            result.append(next.clamp(lowerBound: 39, upperBound: 401))
+        }
+        return ForecastGenerator.trimFlatTails(result, lookback: 90 / 5)
+    }
+
+    static func forecastCOB(
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
+        mealData: ComputedCarbs,
+        profile: Profile,
+        carbImpact: Decimal,
+        deviation: Decimal,
+        adjustedSensitivity: Decimal,
+        sensitivityRatio: Decimal,
+        currentTime: Date
+    ) -> [Decimal] {
+        // Start with the current BG
+        var result = [startingGlucose]
+
+        let carbImpactParams = CarbImpactParams.calculate(
+            adjustedSensitivity: adjustedSensitivity,
+            profile: profile,
+            mealData: mealData,
+            carbImpact: carbImpact,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
+        )
+
+        // How many intervals we spread the initial CI decay over?
+        // We use twice the absorption window (so that by 2x the window, CI has decayed to zero).
+        let decayIntervals = max(carbImpactParams.maxAbsorptionIntervals * 2, 1)
+
+        // Helper: negative deviation only (never positive)
+        let forecastedDeviation = min(0, deviation)
+
+        // Build forecast out to glucoseImpactSeries.count (usually 48)
+        for seriesCount in 1 ..< glucoseImpactSeries.count {
+            let insulinEffect = glucoseImpactSeries[seriesCount]
+
+            // Linearly decay the *observed* carb impact from initialCI → 0
+            let decayFactor = max(0, 1 - seriesCount / decayIntervals)
+            let forecastedCarbImpact = carbImpactParams.cappedCarbImpact * Decimal(decayFactor)
+
+            // Add a simple triangle bump for remaining carbs:
+            // – ramp up linearly to peak over the first half of the window,
+            // – ramp down linearly over the second half,
+            // – zero afterwards.
+            let triangle: Decimal
+            if carbImpactParams.triangleIntervals > 0, seriesCount <= carbImpactParams.triangleIntervals {
+                // FIXME: integer division here might be slightly off for odd number intervals.
+                // FIXME: For perfect symmetry we could use let halfTriangle = (triangleIntervals + 1) / 2 — Change this?!
+                let halfTriangle = carbImpactParams.triangleIntervals / 2
+                if seriesCount <= halfTriangle {
+                    // Ramp up
+                    triangle = carbImpactParams.remainingCarbImpactPeak * Decimal(seriesCount) / Decimal(halfTriangle)
+                } else {
+                    // Ramp down
+                    triangle = carbImpactParams
+                        .remainingCarbImpactPeak * Decimal(carbImpactParams.triangleIntervals - seriesCount) /
+                        Decimal(halfTriangle)
+                }
+            } else {
+                triangle = 0
+            }
+
+            let next = result.last!
+                + insulinEffect
+                + forecastedDeviation
+                + forecastedCarbImpact
+                + triangle
+
+            result.append(next.clamp(lowerBound: 39, upperBound: 1500))
+        }
+
+        return ForecastGenerator.trimFlatTails(result, lookback: 12)
+    }
+
+    static func forecastUAM(
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
+        mealData: ComputedCarbs,
+        carbImpact: Decimal,
+        deviation: Decimal
+    ) -> [Decimal] {
+        var result = [startingGlucose]
+
+        let slopeFromDeviations = mealData.slopeFromMinDeviation
+        let ticksInThreeHours: Decimal = 36 // 3 * 60 / 5
+
+        let unannouncedCarbImpact = carbImpact
+
+        for glucoseImpact in 1 ..< glucoseImpactSeries.count {
+            let insulinEffect = glucoseImpactSeries[glucoseImpact]
+            let forecastedDeviaton = min(0, deviation)
+
+            // In JS: predUCIslope = max(0, uci + (tick * slopeFromDeviations))
+            let forecastedUnannouncedCarbImpactSlope = max(
+                0,
+                unannouncedCarbImpact + Decimal(glucoseImpact) * slopeFromDeviations
+            )
+
+            // In JS: predUCImax = max(0, uci * (1 - tick / ticksInThreeHours))
+            let maxForecastedUnannouncedCarbImpact = max(
+                0,
+                unannouncedCarbImpact * (1 - Decimal(glucoseImpact) / ticksInThreeHours)
+            )
+            let forecastedUnannouncedCarbImpact = min(
+                forecastedUnannouncedCarbImpactSlope,
+                maxForecastedUnannouncedCarbImpact
+            )
+
+            let next = result.last! + insulinEffect + forecastedDeviaton + forecastedUnannouncedCarbImpact
+
+            result.append(next.clamp(lowerBound: 39, upperBound: 401))
+        }
+
+        return ForecastGenerator.trimFlatTails(result, lookback: 12)
+    }
+
+    static func forecastZT(
+        startingGlucose: Decimal,
+        glucoseImpactSeriesWithZeroTemp: [Decimal],
+        deviation: Decimal
+    ) -> [Decimal] {
+        // essentially insulin effect only, but with zero-temp ISF if needed
+        Self.forecastIOB(
+            startingGlucose: startingGlucose,
+            glucoseImpactSeries: glucoseImpactSeriesWithZeroTemp,
+            deviation: deviation
+        )
+    }
+}

+ 289 - 0
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift

@@ -0,0 +1,289 @@
+import Foundation
+
+/// The top-level orchestrator
+enum ForecastGenerator {
+    public static func generate(
+        glucose: Decimal,
+        glucoseImpactSeries: [Decimal],
+        glucoseImpactSeriesWithZeroTemp: [Decimal],
+        iobData _: [IobResult],
+        mealData: ComputedCarbs,
+        profile: Profile,
+        adjustedSensitivity: Decimal,
+        sensitivityRatio: Decimal,
+        naiveEventualGlucose _: Decimal,
+        eventualGlucose: Decimal,
+        threshold: Decimal,
+        currentTime: Date
+    ) -> ForecastResult {
+        let carbImpact = mealData
+            .currentDeviation * (profile.carbRatio ?? profile.carbRatioFor(time: currentTime)) /
+            (profile.sens ?? profile.sensitivityFor(time: currentTime))
+        let deviation = mealData.currentDeviation
+
+        // JS oref initializes all xxxPredBGs array with current glucose, we do the same, then generate
+        let iobForecast = [glucose] + forecastIOB(
+            startingGlucose: glucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            deviation: deviation,
+        )
+
+        let cobForecast = [glucose] + forecastCOB(
+            startingGlucose: glucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            mealData: mealData,
+            profile: profile,
+            carbImpact: carbImpact,
+            deviation: deviation,
+            adjustedSensitivity: adjustedSensitivity,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
+        )
+
+        let uamForecast = [glucose] + forecastUAM(
+            startingGlucose: glucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            mealData: mealData,
+            carbImpact: carbImpact,
+            deviation: deviation
+        )
+
+        let ztForecast = [glucose] + forecastZT(
+            startingGlucose: glucose,
+            glucoseImpactSeriesWithZeroTemp: glucoseImpactSeriesWithZeroTemp,
+            deviation: deviation
+        )
+
+        let computedForecastSelection = Self.computeForecastSelection(
+            iob: iobForecast,
+            cob: cobForecast,
+            uam: uamForecast,
+            zt: ztForecast,
+            currentGlucose: glucose
+        )
+
+        let carbImpactParams = CarbImpactParams.calculate(
+            adjustedSensitivity: adjustedSensitivity,
+            profile: profile,
+            mealData: mealData,
+            carbImpact: carbImpact,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
+        )
+
+        let carbImpactDuration = carbImpact > 0 ? min(
+            carbImpactParams.remainingCarbAbsorptionTime * 60 / 5 / 2,
+            max(0, mealData.mealCOB * carbImpactParams.carbSensivityFactor / carbImpact)
+        ) : 0
+
+        let blendedForecasts = Self.blendForecasts(
+            selectionResult: computedForecastSelection,
+            carbs: mealData.carbs,
+            mealCOB: mealData.mealCOB,
+            enableUAM: profile.enableUAM,
+            carbImpactDuration: carbImpactDuration,
+            remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
+            fractionCarbsLeft: mealData.carbs > 0 ? mealData.mealCOB / mealData.carbs : 0,
+            threshold: threshold,
+            targetGlucose: profile.targetBg ?? 100,
+            currentGlucose: glucose
+        )
+
+        return ForecastResult(
+            iob: iobForecast,
+            cob: cobForecast,
+            uam: uamForecast,
+            zt: ztForecast,
+            eventualGlucose: eventualGlucose,
+            minForecastedGlucose: blendedForecasts.minForecastedGlucose,
+            minGuardGlucose: blendedForecasts.minGuardGlucose
+        )
+    }
+
+    /// Calculates the dynamic remaining carb absorption time in hours, per oref0 logic.
+    /// - Parameters:
+    ///   - sensitivityRatio: ratio from autosens (usually 1.0 if not present)
+    ///   - mealCOB: unabsorbed carbs (grams)
+    ///   - lastCarbTime: timestamp of last carb entry (Date? or nil)
+    ///   - currentTime: now
+    /// - Returns: Remaining CA time in hours (Decimal)
+    static func calculateRemainingCarbAbsorptionTime(
+        sensitivityRatio: Decimal,
+        maxMealAbsorptionTime: Decimal,
+        mealCOB: Decimal,
+        lastCarbTime: Date?,
+        currentTime: Date
+    ) -> Decimal {
+        var minRemainingCarbAbsorptionTime: Decimal = min(3, maxMealAbsorptionTime) // hours
+        if sensitivityRatio > 0 {
+            minRemainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime / sensitivityRatio
+        }
+        if mealCOB > 0 {
+            let assumedCarbAbsorptionRate: Decimal = 20 // g/h
+            minRemainingCarbAbsorptionTime = max(minRemainingCarbAbsorptionTime, mealCOB / assumedCarbAbsorptionRate)
+        }
+        var remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime
+        if let lastCarbTime = lastCarbTime {
+            let lastCarbAgeMin = Decimal(currentTime.timeIntervalSince(lastCarbTime) / 60)
+            remainingCarbAbsorptionTime += 1.5 * (lastCarbAgeMin / 60)
+        }
+        return remainingCarbAbsorptionTime.rounded(toPlaces: 1)
+    }
+
+    static func computeForecastSelection(
+        iob: [Decimal],
+        cob: [Decimal],
+        uam: [Decimal],
+        zt: [Decimal],
+        currentGlucose: Decimal
+    ) -> ForecastSelectionResult {
+        // In the JS, minPredBG is only considered after insulin peak, so use dropFirst
+        let iobAfter90min = iob.dropFirst(18) // 90m at 5m intervals = 18
+        let cobAfter90min = cob.dropFirst(18)
+        let uamAfter60min = uam.dropFirst(12) // 60m at 5m intervals = 12
+
+        let minIOBForecastGlucose = iobAfter90min.min() ?? Decimal(999)
+        let minCOBForecastGlucose = cobAfter90min.min() ?? Decimal(999)
+        let minUAMForecastGlucose = uamAfter60min.min() ?? Decimal(999)
+
+        let minIOBGuardGlucose = iob.min() ?? Decimal(999)
+        let minCOBGuardGlucose = cob.min() ?? Decimal(999)
+        let minUAMGuardGlucose = uam.min() ?? Decimal(999)
+        let minZTGuardGlucose = zt.min() ?? Decimal(999)
+
+        let maxIOBForecastGlucose = iob.max() ?? currentGlucose
+        let maxCOBForecastGlucose = cob.max() ?? currentGlucose
+        let maxUAMForecastGlucose = uam.max() ?? currentGlucose
+
+        let lastIOBForecastGlucose = iob.last ?? currentGlucose
+        let lastCOBForecastGlucose = cob.last ?? currentGlucose
+        let lastUAMForecastGlucose = uam.last ?? currentGlucose
+        let lastZTForecastGlucose = zt.last ?? currentGlucose
+
+        return ForecastSelectionResult(
+            minIOBForecastGlucose: minIOBForecastGlucose,
+            minCOBForecastGlucose: minCOBForecastGlucose,
+            minUAMForecastGlucose: minUAMForecastGlucose,
+            minIOBGuardGlucose: minIOBGuardGlucose,
+            minCOBGuardGlucose: minCOBGuardGlucose,
+            minUAMGuardGlucose: minUAMGuardGlucose,
+            minZTGuardGlucose: minZTGuardGlucose,
+            maxIOBForecastGlucose: maxIOBForecastGlucose,
+            maxCOBForecastGlucose: maxCOBForecastGlucose,
+            maxUAMForecastGlucose: maxUAMForecastGlucose,
+            lastIOBForecastGlucose: lastIOBForecastGlucose,
+            lastCOBForecastGlucose: lastCOBForecastGlucose,
+            lastUAMForecastGlucose: lastUAMForecastGlucose,
+            lastZTForecastGlucose: lastZTForecastGlucose
+        )
+    }
+
+    /// Mirrors the oref0 JS logic for selecting/blending min/avg/guard BGs.
+    static func blendForecasts(
+        selectionResult: ForecastSelectionResult,
+        carbs: Decimal,
+        mealCOB _: Decimal,
+        enableUAM: Bool,
+        carbImpactDuration: Decimal,
+        remainingCarbImpactPeak: Decimal,
+        fractionCarbsLeft: Decimal,
+        threshold: Decimal,
+        targetGlucose: Decimal,
+        currentGlucose: Decimal
+    ) -> ForecastBlendingResult {
+        // 1. Calculate minZTUAMForecastGlucose ("minZTUAMPredBG" in JS)
+        var minZTUAMForecastGlucose = selectionResult.minUAMForecastGlucose
+        if selectionResult.minZTGuardGlucose < threshold {
+            minZTUAMForecastGlucose = ((selectionResult.minUAMForecastGlucose + selectionResult.minZTGuardGlucose) / 2)
+                .rounded()
+        } else if selectionResult.minZTGuardGlucose < targetGlucose {
+            let blendPct = (selectionResult.minZTGuardGlucose - threshold) / (targetGlucose - threshold)
+            let blendedMinZTGuardGlucose = selectionResult.minUAMForecastGlucose * blendPct + selectionResult
+                .minZTGuardGlucose * (1 - blendPct)
+            minZTUAMForecastGlucose = ((selectionResult.minUAMForecastGlucose + blendedMinZTGuardGlucose) / 2).rounded()
+        } else if selectionResult.minZTGuardGlucose > selectionResult.minUAMForecastGlucose {
+            minZTUAMForecastGlucose = ((selectionResult.minUAMForecastGlucose + selectionResult.minZTGuardGlucose) / 2)
+                .rounded()
+        }
+
+        // 2. avgForecastGlucose blending (like avgPredBG)
+        let avgForecastGlucose: Decimal
+        if selectionResult.minUAMForecastGlucose < 999, selectionResult.minCOBForecastGlucose < 999 {
+            avgForecastGlucose = (
+                (1 - fractionCarbsLeft) * selectionResult
+                    .lastUAMForecastGlucose + fractionCarbsLeft * selectionResult.lastCOBForecastGlucose
+            ).rounded()
+        } else if selectionResult.minCOBForecastGlucose < 999 {
+            avgForecastGlucose = ((selectionResult.lastIOBForecastGlucose + selectionResult.lastCOBForecastGlucose) / 2)
+                .rounded()
+        } else if selectionResult.minUAMForecastGlucose < 999 {
+            avgForecastGlucose = ((selectionResult.lastIOBForecastGlucose + selectionResult.lastUAMForecastGlucose) / 2)
+                .rounded()
+        } else {
+            avgForecastGlucose = selectionResult.lastIOBForecastGlucose.rounded()
+        }
+        let adjustedAvgForecastGlucose = max(avgForecastGlucose, selectionResult.minZTGuardGlucose)
+
+        // 3. minGuardGlucose
+        let minGuardGlucose: Decimal
+        if carbImpactDuration > 0 || remainingCarbImpactPeak > 0 {
+            if enableUAM {
+                minGuardGlucose = (
+                    fractionCarbsLeft * selectionResult
+                        .minCOBGuardGlucose + (1 - fractionCarbsLeft) * selectionResult.minUAMGuardGlucose
+                ).rounded()
+            } else {
+                minGuardGlucose = selectionResult.minCOBGuardGlucose.rounded()
+            }
+        } else if enableUAM {
+            minGuardGlucose = selectionResult.minUAMGuardGlucose.rounded()
+        } else {
+            minGuardGlucose = selectionResult.minIOBGuardGlucose.rounded()
+        }
+
+        // 4. minForecastedGlucose ("minPredBG")
+        var minForecastedGlucose: Decimal = selectionResult.minIOBForecastGlucose.rounded()
+        if carbs > 0 {
+            if !enableUAM, selectionResult.minCOBForecastGlucose < 999 {
+                minForecastedGlucose = max(selectionResult.minIOBForecastGlucose, selectionResult.minCOBForecastGlucose)
+            } else if selectionResult.minCOBForecastGlucose < 999 {
+                let blendedMinForecastGlucose = fractionCarbsLeft * selectionResult
+                    .minCOBForecastGlucose + (1 - fractionCarbsLeft) * minZTUAMForecastGlucose
+                minForecastedGlucose = max(
+                    selectionResult.minIOBForecastGlucose,
+                    selectionResult.minCOBForecastGlucose,
+                    blendedMinForecastGlucose
+                ).rounded()
+            } else if enableUAM {
+                minForecastedGlucose = minZTUAMForecastGlucose
+            } else {
+                minForecastedGlucose = minGuardGlucose
+            }
+        } else if enableUAM {
+            minForecastedGlucose = max(selectionResult.minIOBForecastGlucose, minZTUAMForecastGlucose).rounded()
+        }
+
+        // Clamp minForecastedGlucose to not exceed adjustedAvgForecastGlucose
+        minForecastedGlucose = min(minForecastedGlucose, adjustedAvgForecastGlucose)
+
+        // JS: If maxCOBPredBG > bg, don't trust UAM too much
+        if selectionResult.maxCOBForecastGlucose > currentGlucose {
+            minForecastedGlucose = min(minForecastedGlucose, selectionResult.maxCOBForecastGlucose)
+        }
+
+        return ForecastBlendingResult(
+            minForecastedGlucose: minForecastedGlucose,
+            avgForecastedGlucose: adjustedAvgForecastGlucose,
+            minGuardGlucose: minGuardGlucose
+        )
+    }
+
+    /// Trims trailing flat-line points beyond a “lookback” count
+    public static func trimFlatTails(_ series: [Decimal], lookback: Int) -> [Decimal] {
+        var s = series
+        while s.count > lookback, s.suffix(2)[0] == s.suffix(2)[1] {
+            s.removeLast()
+        }
+        return s
+    }
+}

+ 16 - 0
Trio/Sources/APS/OpenAPSSwift/JSONBridge.swift

@@ -24,6 +24,10 @@ enum JSONBridge {
         try JSONBridge.from(string: from.rawJSON)
     }
 
+    static func trioCustomOrefVariables(from: JSON) throws -> TrioCustomOrefVariables {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
     static func insulinSensitivities(from: JSON) throws -> InsulinSensitivities {
         try JSONBridge.from(string: from.rawJSON)
     }
@@ -48,10 +52,18 @@ enum JSONBridge {
         try JSONBridge.from(string: from.rawJSON)
     }
 
+    static func currentTemp(from: JSON) throws -> TempBasal {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
     static func carbs(from: JSON) throws -> [CarbsEntry] {
         try JSONBridge.from(string: from.rawJSON)
     }
 
+    static func iobResult(from: JSON) throws -> [IobResult] {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
     static func pumpHistory(from: JSON) throws -> [PumpHistoryEvent] {
         do {
             return try JSONBridge.from(string: from.rawJSON)
@@ -75,6 +87,10 @@ enum JSONBridge {
         try JSONBridge.from(string: from.rawJSON)
     }
 
+    static func computedCarbs(from: JSON) throws -> ComputedCarbs? {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
     static func autosens(from: JSON) throws -> Autosens? {
         try JSONBridge.from(string: from.rawJSON)
     }

+ 21 - 2
Trio/Sources/APS/OpenAPSSwift/Logging/AlgorithmComparison.swift

@@ -92,6 +92,23 @@ struct AutosensInputs: Codable {
     let clock: Date
 }
 
+/// For tracking inputs to `determineBasal` when there is a mismatch
+struct DetermineBasalInputs: Codable {
+    let glucose: [BloodGlucose]
+    let currentTemp: TempBasal
+    let iob: [IobResult]
+    let profile: Profile
+    let autosens: Autosens?
+    let meal: ComputedCarbs?
+    let microBolusAllowed: Bool
+    let reservoir: Decimal?
+    let pumpHistory: [PumpHistoryEvent]
+    let preferences: Preferences
+    let basalProfile: [BasalProfileEntry]
+    let trioCustomOrefVariables: TrioCustomOrefVariables
+    let clock: Date
+}
+
 /// Represents a complete comparison between JS and Swift implementations
 struct AlgorithmComparison: Codable {
     let id: UUID
@@ -119,6 +136,7 @@ struct AlgorithmComparison: Codable {
     let iobInput: IobInputs?
     let mealInput: MealInputs?
     let autosensInput: AutosensInputs?
+    let determineBasalInput: DetermineBasalInputs?
 
     init(
         function: OrefFunction,
@@ -132,6 +150,7 @@ struct AlgorithmComparison: Codable {
         iobInputs: IobInputs? = nil,
         mealInputs: MealInputs? = nil,
         autosensInputs: AutosensInputs? = nil,
+        determineBasalInputs: DetermineBasalInputs? = nil,
         id: UUID = UUID(),
         createdAt: Date = Date()
     ) {
@@ -148,9 +167,9 @@ struct AlgorithmComparison: Codable {
         iobInput = iobInputs
         mealInput = mealInputs
         autosensInput = autosensInputs
+        determineBasalInput = determineBasalInputs
         timezone = TimeZone.current.identifier
-        version = "3"
-
+        version = "4"
         #if targetEnvironment(simulator)
             isSimulator = true
         #else

+ 12 - 6
Trio/Sources/APS/OpenAPSSwift/Logging/JSONCompare.swift

@@ -86,7 +86,8 @@ enum JSONCompare {
         javascriptDuration: TimeInterval,
         iobInputs: IobInputs? = nil,
         mealInputs: MealInputs? = nil,
-        autosensInputs: AutosensInputs? = nil
+        autosensInputs: AutosensInputs? = nil,
+        determineBasalInputs: DetermineBasalInputs? = nil
     ) {
         let comparison = createComparison(
             function: function,
@@ -96,7 +97,8 @@ enum JSONCompare {
             javascriptDuration: javascriptDuration,
             iobInputs: iobInputs,
             mealInputs: mealInputs,
-            autosensInputs: autosensInputs
+            autosensInputs: autosensInputs,
+            determineBasalInputs: determineBasalInputs
         )
 
         Task {
@@ -116,7 +118,8 @@ enum JSONCompare {
         javascriptDuration: TimeInterval,
         iobInputs: IobInputs?,
         mealInputs: MealInputs?,
-        autosensInputs: AutosensInputs?
+        autosensInputs: AutosensInputs?,
+        determineBasalInputs: DetermineBasalInputs?
     ) -> AlgorithmComparison {
         switch (swift, javascript) {
         case let (.success(swiftJson), .success(javascriptJson)):
@@ -131,7 +134,8 @@ enum JSONCompare {
                     differences: differences.isEmpty ? nil : differences,
                     iobInputs: differences.isEmpty ? nil : iobInputs,
                     mealInputs: differences.isEmpty ? nil : mealInputs,
-                    autosensInputs: differences.isEmpty ? nil : autosensInputs
+                    autosensInputs: differences.isEmpty ? nil : autosensInputs,
+                    determineBasalInputs: differences.isEmpty ? nil : determineBasalInputs
                 )
             } catch {
                 return AlgorithmComparison(
@@ -159,7 +163,8 @@ enum JSONCompare {
                 swiftException: AlgorithmException(error: swiftError),
                 iobInputs: iobInputs,
                 mealInputs: mealInputs,
-                autosensInputs: autosensInputs
+                autosensInputs: autosensInputs,
+                determineBasalInputs: determineBasalInputs
             )
 
         case let (.success, .failure(jsError)):
@@ -170,7 +175,8 @@ enum JSONCompare {
                 jsException: AlgorithmException(error: jsError),
                 iobInputs: iobInputs,
                 mealInputs: mealInputs,
-                autosensInputs: autosensInputs
+                autosensInputs: autosensInputs,
+                determineBasalInputs: determineBasalInputs
             )
         }
     }

+ 26 - 0
Trio/Sources/APS/OpenAPSSwift/Logging/OrefFunction.swift

@@ -25,6 +25,7 @@ enum OrefFunction: String, Codable {
     case iob
     case meal
     case makeProfile
+    case determineBasal
 
     // 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
@@ -42,6 +43,27 @@ enum OrefFunction: String, Codable {
             return Set(["maxDeviation", "minDeviation", "allDeviations", "bwCarbs", "bwFound", "journalCarbs", "nsCarbs"])
         case .autosens:
             return Set(["deviationsUnsorted"])
+        case .determineBasal:
+            // FIXME: Adjust as we go
+            return Set([
+                "id",
+                "units",
+                "insulinReq",
+                "rate",
+                "duration",
+                "deliverAt",
+                "carbsReq",
+                "temp",
+                "reservoir",
+                "ISF",
+                "current_target",
+                "TDD",
+                "insulinForManualBolus",
+                "manualBolusErrorString",
+                "minDelta",
+                "CR",
+                "received"
+            ])
         }
     }
 
@@ -79,6 +101,8 @@ enum OrefFunction: String, Codable {
                 "ratio": 0.011,
                 "newisf": 1.5
             ]
+        case .determineBasal:
+            return [:]
         }
     }
 
@@ -92,6 +116,8 @@ enum OrefFunction: String, Codable {
             return .dictionary
         case .autosens:
             return .dictionary
+        case .determineBasal:
+            return .dictionary
         }
     }
 }

+ 7 - 0
Trio/Sources/APS/OpenAPSSwift/Models/AdjustedGlucoseTargets.swift

@@ -0,0 +1,7 @@
+import Foundation
+
+struct AdjustedGlucoseTargets {
+    var minGlucose: Decimal
+    var maxGlucose: Decimal
+    var targetGlucose: Decimal
+}

+ 34 - 0
Trio/Sources/APS/OpenAPSSwift/Models/ForecastResult.swift

@@ -0,0 +1,34 @@
+import Foundation
+
+struct ForecastResult {
+    public let iob: [Decimal]
+    public let cob: [Decimal]
+    public let uam: [Decimal]
+    public let zt: [Decimal]
+    public let eventualGlucose: Decimal
+    public let minForecastedGlucose: Decimal
+    public let minGuardGlucose: Decimal
+}
+
+struct ForecastSelectionResult {
+    let minIOBForecastGlucose: Decimal
+    let minCOBForecastGlucose: Decimal
+    let minUAMForecastGlucose: Decimal
+    let minIOBGuardGlucose: Decimal
+    let minCOBGuardGlucose: Decimal
+    let minUAMGuardGlucose: Decimal
+    let minZTGuardGlucose: Decimal
+    let maxIOBForecastGlucose: Decimal
+    let maxCOBForecastGlucose: Decimal
+    let maxUAMForecastGlucose: Decimal
+    let lastIOBForecastGlucose: Decimal
+    let lastCOBForecastGlucose: Decimal
+    let lastUAMForecastGlucose: Decimal
+    let lastZTForecastGlucose: Decimal
+}
+
+struct ForecastBlendingResult {
+    let minForecastedGlucose: Decimal
+    let avgForecastedGlucose: Decimal
+    let minGuardGlucose: Decimal
+}

+ 23 - 0
Trio/Sources/APS/OpenAPSSwift/Models/GlucoseStatus.swift

@@ -0,0 +1,23 @@
+import Foundation
+
+/// Represents the computed status of the most recent CGM reading,
+/// including delta‐rates over various time windows for our
+/// swift-based Oref`DeterminationGenerator`.
+public struct GlucoseStatus: Codable {
+    /// Immediate delta (mg/dL per 5 m) over the last ~5 m
+    public let delta: Decimal
+    /// The (“smoothed”) current glucose value (mg/dL)
+    public let glucose: Decimal
+    /// Sensor noise level
+    public let noise: Int
+    /// Average delta (mg/dL per 5 m) over ~5–15 m ago
+    public let shortAvgDelta: Decimal
+    /// Average delta (mg/dL per 5 m) over ~20–40 m ago
+    public let longAvgDelta: Decimal
+    /// Timestamp of the “now” reading
+    public let date: Date
+    /// Index of the last “cal” record (if any)
+    public let lastCalIndex: Int?
+    /// The original device/type string (e.g. “sgv” or “cal”)
+    public let device: String?
+}

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

@@ -41,6 +41,77 @@ struct OpenAPSSwift {
         }
     }
 
+    static func determineBasal(
+        glucose: JSON,
+        currentTemp: JSON,
+        iob: JSON,
+        profile: JSON,
+        autosens: JSON,
+        meal: JSON,
+        microBolusAllowed: Bool,
+        reservoir: JSON,
+        pumpHistory: JSON,
+        preferences: JSON,
+        basalProfile: JSON,
+        trioCustomOrefVariables: JSON,
+        clock: Date
+    ) -> (OrefFunctionResult, DetermineBasalInputs?) {
+        var determineBasalInputs: DetermineBasalInputs?
+
+        print(reservoir)
+
+        do {
+            let glucose = try JSONBridge.glucose(from: glucose)
+            let currentTemp = try JSONBridge.currentTemp(from: currentTemp)
+            let iob = try JSONBridge.iobResult(from: iob)
+            let profile = try JSONBridge.profile(from: profile)
+            let autosens = try JSONBridge.autosens(from: autosens)
+            let meal = try JSONBridge.computedCarbs(from: meal)
+            let microBolusAllowed = microBolusAllowed
+            let reservoir = Decimal(string: reservoir.rawJSON)
+            let pumpHistory = try JSONBridge.pumpHistory(from: pumpHistory)
+            let preferences = try JSONBridge.preferences(from: preferences)
+            let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
+            let trioCustomOrefVariables = try JSONBridge.trioCustomOrefVariables(from: trioCustomOrefVariables)
+
+            determineBasalInputs = DetermineBasalInputs(
+                glucose: glucose,
+                currentTemp: currentTemp,
+                iob: iob,
+                profile: profile,
+                autosens: autosens,
+                meal: meal,
+                microBolusAllowed: microBolusAllowed,
+                reservoir: reservoir,
+                pumpHistory: pumpHistory,
+                preferences: preferences,
+                basalProfile: basalProfile,
+                trioCustomOrefVariables: trioCustomOrefVariables,
+                clock: clock
+            )
+
+            guard let mealData = meal, let autosensData = autosens else {
+                return (.failure(DeterminationError.missingInputs), determineBasalInputs)
+            }
+
+            let rawDetermination = try DeterminationGenerator.generate(
+                profile: profile,
+                currentTemp: currentTemp,
+                iobData: iob,
+                mealData: mealData,
+                autosensData: autosensData,
+                reservoirData: reservoir ?? 100,
+                glucose: glucose,
+                currentTime: clock
+            )
+
+            return (try .success(JSONBridge.to(rawDetermination)), determineBasalInputs)
+
+        } catch {
+            return (.failure(error), determineBasalInputs)
+        }
+    }
+
     static func meal(
         pumphistory: JSON,
         profile: JSON,

+ 123 - 0
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -23,6 +23,7 @@ protocol GlucoseStorage {
     func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose]
     func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample]
     func getManualGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample]
+//    func getGlucoseStatus() async throws -> GlucoseStatus? // FIXME: prepared for later use
     var alarm: GlucoseAlarm? { get }
     func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async
 }
@@ -544,6 +545,128 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         }
     }
 
+    // FIXME: use this after we know oref-swift is good
+//    /// Fetches the most recent glucose readings from Core Data, filters and smooths them,
+//    /// and computes rolling delta statistics (last, short-term, and long-term).
+//    ///
+//    /// Mirrors JavaScript oref `glucose-get-last.js` logic.
+//    ///
+//    /// - Returns: A `GlucoseStatus` containing:
+//    ///   - `glucose`: the most recent glucose value (mg/dL),
+//    ///   - `delta`: the 5-minute delta (mg/dL per 5m),
+//    ///   - `shortAvgDelta`: the average delta over ~5–15 minutes,
+//    ///   - `longAvgDelta`: the average delta over ~20–40 minutes,
+//    ///   - `noise`: the CGM noise level (if any),
+//    ///   - `date`: the timestamp of the “now” reading,
+//    ///   - `lastCalIndex`: index of the last calibration record (always `nil` here),
+//    ///   - `device`: the source device string.
+//    ///
+//    /// - Throws: Any `CoreDataError` or other error encountered during fetch or context work.
+//    /// - Returns: `nil` if no valid glucose readings are found in the past day.
+//    public func getGlucoseStatus() async throws -> GlucoseStatus? {
+//        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+//            ofType: GlucoseStored.self,
+//            onContext: context,
+//            predicate: NSPredicate(
+//                format: "date >= %@ AND isManual == %@",
+//                Date.oneDayAgoInMinutes as NSDate,
+//                false as NSNumber
+//            ),
+//            key: "date",
+//            ascending: false
+//        )
+//
+//        guard let stored = results as? [GlucoseStored], !stored.isEmpty else {
+//            return nil
+//        }
+//
+//        let validReadings: [BloodGlucose] = await context.perform {
+//            stored.compactMap { entry in
+//                BloodGlucose(
+//                    _id: entry.id?.uuidString ?? UUID().uuidString,
+//                    sgv: Int(entry.glucose),
+//                    direction: BloodGlucose.Direction(from: entry.direction ?? ""),
+//                    date: Decimal(entry.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
+//                    dateString: entry.date ?? Date(),
+//                    unfiltered: Decimal(entry.glucose),
+//                    filtered: Decimal(entry.glucose),
+//                    noise: nil,
+//                    glucose: Int(entry.glucose),
+//                    type: "sgv"
+//                )
+//            }
+//        }
+//
+//        guard !validReadings.isEmpty else {
+//            return nil
+//        }
+//
+//        // Sort descending (newest first)
+//        let sorted = validReadings.sorted { $0.date > $1.date }
+//
+//        let mostRecentGlucose = sorted[0]
+//        var mostRecentGlucoseReading: Int = mostRecentGlucose.glucose!
+//        var mostRecentGlucoseDate: Date = mostRecentGlucose.dateString
+//
+//        var lastDeltas: [Decimal] = []
+//        var shortDeltas: [Decimal] = []
+//        var longDeltas: [Decimal] = []
+//
+//        // Walk older entries to compute deltas
+//        for entry in sorted.dropFirst() {
+//            // JS oref has logic here around skipping calibration readings.
+//            // We never calibration record (never happens here, since type=="sgv")
+//            // so we omit this check
+//
+//            // only use readings >38 mg/dL (to skip code values, <39)
+//            guard let glucose = entry.glucose, glucose > 38 else { continue }
+//
+//            let minutesAgo = mostRecentGlucoseDate.timeIntervalSince(entry.dateString) / 60
+//            guard minutesAgo != 0 else { continue }
+//            // compute mg/dL per 5 m as a Decimal:
+//            let change = Decimal(mostRecentGlucoseReading - glucose)
+//            let avgDelta = (change / Decimal(minutesAgo)) * Decimal(5)
+//
+//            // very-recent (<2.5 m) smooths "now"
+//            if minutesAgo > -2, minutesAgo <= 2.5 {
+//                mostRecentGlucoseReading = (mostRecentGlucoseReading + glucose) / 2
+//                mostRecentGlucoseDate = Date(
+//                    timeIntervalSince1970: (
+//                        mostRecentGlucoseDate.timeIntervalSince1970 + entry.dateString
+//                            .timeIntervalSince1970
+//                    ) / 2
+//                )
+//            }
+//            // short window (~5–15 m)
+//            else if minutesAgo > 2.5, minutesAgo <= 17.5 {
+//                shortDeltas.append(avgDelta)
+//                if minutesAgo < 7.5 {
+//                    lastDeltas.append(avgDelta)
+//                }
+//            }
+//            // long window (~20–40 m)
+//            else if minutesAgo > 17.5, minutesAgo < 42.5 {
+//                longDeltas.append(avgDelta)
+//            }
+//        }
+//
+//        // compute means (or zero)
+//        let lastDelta: Decimal = lastDeltas.mean
+//        let shortAvg: Decimal = shortDeltas.mean
+//        let longAvg: Decimal = longDeltas.mean
+//
+//        return GlucoseStatus(
+//            delta: lastDelta.rounded(toPlaces: 2),
+//            glucose: Decimal(mostRecentGlucoseReading),
+//            noise: Int(sorted[0].noise ?? 0),
+//            shortAvgDelta: shortAvg.rounded(toPlaces: 2),
+//            longAvgDelta: longAvg.rounded(toPlaces: 2),
+//            date: mostRecentGlucoseDate,
+//            lastCalIndex: nil,
+//            device: settingsManager.settings.cgm.rawValue
+//        )
+//    }
+
     func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
         // Use injected context if available, otherwise create new task context
         let taskContext = context != CoreDataStack.shared.newTaskContext()

+ 37 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -56803,6 +56803,9 @@
         }
       }
     },
+    "CGM noise level too high: %lld." : {
+
+    },
     "Change CR" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -64696,6 +64699,16 @@
         }
       }
     },
+    "Could not calculate eventual glucose. Sensitivity: %@, Deviation: %@" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Could not calculate eventual glucose. Sensitivity: %1$@, Deviation: %2$@"
+          }
+        }
+      }
+    },
     "Count" : {
       "localizations" : {
         "bg" : {
@@ -105056,6 +105069,9 @@
         }
       }
     },
+    "Glucose data is too old (%lf min ago)." : {
+
+    },
     "Glucose Data used for statistics" : {
       "comment" : "Debug option view Glucose Data used for statistics",
       "extractionState" : "manual",
@@ -106025,6 +106041,9 @@
         }
       }
     },
+    "Glucose out of range: %@." : {
+
+    },
     "Glucose Reading" : {
       "localizations" : {
         "bg" : {
@@ -143087,6 +143106,9 @@
         }
       }
     },
+    "Missing required inputs; cannot determine basal." : {
+
+    },
     "mmol/L" : {
       "comment" : "The short unit display string for millimoles of glucose per liter",
       "localizations" : {
@@ -146764,6 +146786,9 @@
         }
       }
     },
+    "No glucose delta (flat readings); cannot determine trend." : {
+
+    },
     "No Glucose Notifications will be triggered." : {
       "localizations" : {
         "bg" : {
@@ -146976,6 +147001,12 @@
         }
       }
     },
+    "No glucose status; cannot determine basal." : {
+
+    },
+    "No IOB data available; cannot determine basal." : {
+
+    },
     "No Libre Transmitter Selected" : {
       "comment" : "No Libre Transmitter Selected",
       "extractionState" : "manual",
@@ -147627,6 +147658,9 @@
         }
       }
     },
+    "No profile; cannot determine basal." : {
+
+    },
     "No recent oref algorithm determination." : {
       "localizations" : {
         "bg" : {
@@ -225648,6 +225682,9 @@
         }
       }
     },
+    "Unknown determination error." : {
+
+    },
     "Unknown Error" : {
       "localizations" : {
         "bg" : {

+ 41 - 0
TrioTests/CoreDataTests/GlucoseStorageTests.swift

@@ -185,4 +185,45 @@ import Testing
         #expect(storedEntries?.first?.glucose == 100, "Normal glucose value should match")
         #expect(storage.alarm == nil, "Should not trigger any alarm")
     }
+
+    @Test("getGlucoseStatus returns correct deltas for 0/5/15/30m readings") func testGetGlucoseStatusFourPoints() async throws {
+        let now = Date()
+        // Prepare 4 readings: at 0, 5, 15, and 30 minutes ago
+        let specs: [(offset: TimeInterval, value: Int)] = [
+            (0, 100), // now
+            (5 * 60, 110), // 5m ago
+            (15 * 60, 120), // 15m ago
+            (30 * 60, 130) // 30m ago
+        ]
+
+        // Insert them into CoreData so that our fetch predicate picks them up
+        for (offset, value) in specs {
+            await testContext.perform {
+                let glucoseToStore = GlucoseStored(context: testContext)
+                glucoseToStore.id = UUID()
+                glucoseToStore.date = now.addingTimeInterval(-offset)
+                glucoseToStore.glucose = Int16(value)
+            }
+        }
+        try testContext.save()
+
+        // Call the method under test
+        let status = try await storage.getGlucoseStatus()
+        #expect(status != nil, "Expected non‐nil status")
+
+        // “Now” glucose is the 0m reading
+        #expect(status!.glucose == 100)
+
+        // lastDelta: only the 5m point: (100–110)/5*5 = –10
+        #expect(status!.delta == -10)
+
+        // shortAvgDelta: average of 5m and 15m windows:
+        //   5m window:   (100–110)/5*5   = –10
+        //   15m window: (100–120)/15*5 ≈ –6.6667 → –6.67
+        //   avg ≈ (–10 + –6.67)/2 = –8.333… → rounded to –8.33
+        #expect(status!.shortAvgDelta == -8.33)
+
+        // longAvgDelta: only the 30m window: (100–130)/30*5 = –5
+        #expect(status!.longAvgDelta == -5)
+    }
 }

+ 104 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalDeltaCalculationTests.swift

@@ -0,0 +1,104 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Determination: Expected Delta Calculation Tests") struct ExpectedDeltaTests {
+    /// When delta is smaller than one 5-min block, only glucoseImpact is returned.
+    @Test("no change when delta < 24 blocks") func deltaSmallerThanBlock() {
+        let result = DeterminationGenerator.calculateExpectedDelta(
+            targetGlucose: Decimal(120),
+            eventualGlucose: Decimal(100),
+            glucoseImpact: Decimal(2)
+        )
+        // delta = 20; Int(20)/24 = 0 → result = 2 + 0 = 2.0
+        #expect(result == Decimal(2.0))
+    }
+
+    /// When delta spans exactly one block, adds 1 to glucoseImpact.
+    @Test("one block delta") func deltaExactlyOneBlock() {
+        let result = DeterminationGenerator.calculateExpectedDelta(
+            targetGlucose: Decimal(124),
+            eventualGlucose: Decimal(100),
+            glucoseImpact: Decimal(1.5)
+        )
+        // delta = 24; Int(24)/24 = 1 → result = 1.5 + 1 = 2.5
+        #expect(result == Decimal(2.5))
+    }
+
+    /// When delta spans multiple blocks, uses integer division.
+    @Test("multi-block delta") func deltaMultipleBlocks() {
+        let result = DeterminationGenerator.calculateExpectedDelta(
+            targetGlucose: Decimal(140),
+            eventualGlucose: Decimal(100),
+            glucoseImpact: Decimal(0)
+        )
+        // delta = 40; Int(40)/24 = 1 → result = 0 + 1 = 1.0
+        #expect(result == Decimal(1.0))
+    }
+
+    /// Negative delta yields negative adjustment when blocks exceed delta.
+    @Test("negative delta") func negativeDelta() {
+        let result = DeterminationGenerator.calculateExpectedDelta(
+            targetGlucose: Decimal(80),
+            eventualGlucose: Decimal(100),
+            glucoseImpact: Decimal(0)
+        )
+        // delta = -20; Int(-20)/24 = 0 (trunc toward zero) → result = 0 + 0 = 0.0
+        #expect(result == Decimal(0.0))
+    }
+
+    /// Fractional delta is truncated before block division.
+    @Test("fractional delta truncation") func fractionalDelta() {
+        let result = DeterminationGenerator.calculateExpectedDelta(
+            targetGlucose: Decimal(string: "125.5")!,
+            eventualGlucose: Decimal(100),
+            glucoseImpact: Decimal(0)
+        )
+        // delta = 25.5; Int(25.5)=25; 25/24=1 → result = 1.0
+        #expect(result == Decimal(1.0))
+    }
+
+    /// Rounding to one decimal place works when glucoseImpact has two decimals.
+    @Test("rounding one decimal place") func roundingOneDecimal() {
+        let result = DeterminationGenerator.calculateExpectedDelta(
+            targetGlucose: Decimal(124),
+            eventualGlucose: Decimal(100),
+            glucoseImpact: Decimal(string: "1.27")!
+        )
+        // delta=24 → blocks=1; adjustment=1; 1.27+1=2.27 → rounded to 2.3
+        #expect(result == Decimal(string: "2.3")!)
+    }
+
+    /// Extreme high eventual glucose produces a large negative expected delta.
+    @Test("extreme high eventual glucose") func extremeHighEventual() {
+        let result = DeterminationGenerator.calculateExpectedDelta(
+            targetGlucose: Decimal(120),
+            eventualGlucose: Decimal(350),
+            glucoseImpact: Decimal(0)
+        )
+        // delta = 120 - 350 = -230; Int(-230)/24 = -9 → result = 0 + (-9) = -9.0
+        #expect(result == Decimal(string: "-9.0")!)
+    }
+
+    /// Extreme low eventual glucose produces a positive expected delta.
+    @Test("extreme low eventual glucose") func extremeLowEventual() {
+        let result = DeterminationGenerator.calculateExpectedDelta(
+            targetGlucose: Decimal(120),
+            eventualGlucose: Decimal(39),
+            glucoseImpact: Decimal(0)
+        )
+        // delta = 81; Int(81)/24 = 3 → result = 0 + 3 = 3.0
+        #expect(result == Decimal(string: "3.0")!)
+    }
+
+    /// Invalid low‐unit input (<39 mg/dL) falls back to only using glucoseImpact.
+    @Test("invalid low input treated as only impact") func invalidLowInput() {
+        let result = DeterminationGenerator.calculateExpectedDelta(
+            targetGlucose: Decimal(5), // e.g. mmol/L mistakenly passed
+            eventualGlucose: Decimal(3),
+            glucoseImpact: Decimal(string: "1.7")!
+        )
+        // delta = 2; Int(2)/24 = 0 → result = 1.7 + 0 = 1.7
+        #expect(result == Decimal(string: "1.7")!)
+    }
+}

+ 259 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalSMBEnablementTests.swift

@@ -0,0 +1,259 @@
+
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Determination: SMB Enablement Tests") struct SMBEnablementTests {
+    /// Scheduled-off override window should always disable SMB
+    @Test("should disable SMB during scheduled-off window") func disableDuringScheduledOff() async throws {
+        let now = Calendar.current.date(from: DateComponents(hour: 10))!
+        let override = Override(
+            name: "scheduledOff",
+            enabled: true,
+            date: now,
+            duration: 0,
+            indefinite: false,
+            percentage: 1,
+            smbIsOff: false,
+            isPreset: false,
+            id: "",
+            overrideTarget: false,
+            target: 0,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: false,
+            cr: false,
+            smbIsScheduledOff: true,
+            start: 9,
+            end: 17,
+            smbMinutes: 0,
+            uamMinutes: 0
+        )
+        var profile = Profile()
+        profile.enableSMBAlways = true
+        let bg = BloodGlucose(
+            sgv: 120,
+            date: Decimal(now.timeIntervalSince1970 * 1000),
+            dateString: now
+        )
+        let autosens = Autosens(ratio: 1, newisf: nil)
+        #expect(
+            DeterminationGenerator.isSMBEnabled(
+                glucose: bg,
+                profile: profile,
+                autosens: autosens,
+                mealData: nil,
+                override: override,
+                shouldProtectDueToHIGH: false,
+                currentTime: now
+            ) == false
+        )
+    }
+
+    /// A hard-off override should disable SMB immediately
+    @Test("should disable SMB when override.smbIsOff") func disableWhenOverrideOff() async throws {
+        let now = Date()
+        let override = Override(
+            name: "hardOff",
+            enabled: true,
+            date: now,
+            duration: 0,
+            indefinite: false,
+            percentage: 1,
+            smbIsOff: true,
+            isPreset: false,
+            id: "",
+            overrideTarget: false,
+            target: 0,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: false,
+            cr: false,
+            smbIsScheduledOff: false,
+            start: 0,
+            end: 0,
+            smbMinutes: 0,
+            uamMinutes: 0
+        )
+        let profile = Profile()
+        let bg = BloodGlucose(sgv: 100, date: Decimal(now.timeIntervalSince1970 * 1000), dateString: now)
+        let autosens = Autosens(ratio: 1, newisf: nil)
+        #expect(
+            DeterminationGenerator.isSMBEnabled(
+                glucose: bg,
+                profile: profile,
+                autosens: autosens,
+                mealData: nil,
+                override: override,
+                shouldProtectDueToHIGH: false,
+                currentTime: now
+            ) == false
+        )
+    }
+
+    /// Should disable if CGM reports “HIGH” protection
+    @Test("should disable SMB when protectDueToHIGH") func disableWhenProtectDueToHIGH() async throws {
+        let now = Date()
+        let profile = Profile()
+        let bg = BloodGlucose(sgv: 150, date: Decimal(now.timeIntervalSince1970 * 1000), dateString: now)
+        let autosens = Autosens(ratio: 1, newisf: nil)
+        #expect(
+            DeterminationGenerator.isSMBEnabled(
+                glucose: bg,
+                profile: profile,
+                autosens: autosens,
+                mealData: nil,
+                override: nil,
+                shouldProtectDueToHIGH: true,
+                currentTime: now
+            ) == false
+        )
+    }
+
+    /// Always-on preference should enable SMB
+    @Test("should enable SMB when enableSMBAlways") func enableWhenAlwaysEnabled() async throws {
+        let now = Date()
+        var profile = Profile()
+        profile.enableSMBAlways = true
+        let bg = BloodGlucose(sgv: 80, date: Decimal(now.timeIntervalSince1970 * 1000), dateString: now)
+        let autosens = Autosens(ratio: 1, newisf: nil)
+        #expect(
+            DeterminationGenerator.isSMBEnabled(
+                glucose: bg,
+                profile: profile,
+                autosens: autosens,
+                mealData: nil,
+                override: nil,
+                shouldProtectDueToHIGH: false,
+                currentTime: now
+            ) == true
+        )
+    }
+
+    /// Low temp-target below 100 should enable SMB when allowed
+    @Test("should enable SMB with active low temp target") func enableWithActiveLowTempTarget() async throws {
+        let now = Date()
+        var profile = Profile()
+        profile.temptargetSet = true
+        profile.enableSMBWithTemptarget = true
+        profile.targetBg = 90
+        let bg = BloodGlucose(sgv: 95, date: Decimal(now.timeIntervalSince1970 * 1000), dateString: now)
+        let autosens = Autosens(ratio: 1, newisf: nil)
+        #expect(
+            DeterminationGenerator.isSMBEnabled(
+                glucose: bg,
+                profile: profile,
+                autosens: autosens,
+                mealData: nil,
+                override: nil,
+                shouldProtectDueToHIGH: false,
+                currentTime: now
+            ) == true
+        )
+    }
+
+    /// High temp-target above 100 should disable SMB when not allowed
+    @Test("should disable SMB with high temp target not allowed") func disableWhenHighTempTargetNotAllowed() async throws {
+        let now = Date()
+        var profile = Profile()
+        profile.temptargetSet = true
+        profile.allowSMBWithHighTemptarget = false
+        profile.targetBg = 120
+        let bg = BloodGlucose(sgv: 115, date: Decimal(now.timeIntervalSince1970 * 1000), dateString: now)
+        let autosens = Autosens(ratio: 1, newisf: nil)
+        #expect(
+            DeterminationGenerator.isSMBEnabled(
+                glucose: bg,
+                profile: profile,
+                autosens: autosens,
+                mealData: nil,
+                override: nil,
+                shouldProtectDueToHIGH: false,
+                currentTime: now
+            ) == false
+        )
+    }
+
+    /// Carbs-on-board should enable SMB when COB > 0
+    @Test("should enable SMB with COB") func enableWithCOB() async throws {
+        let now = Date()
+        var profile = Profile()
+        profile.enableSMBWithCOB = true
+        let mealData = ComputedCarbs(
+            carbs: 30,
+            mealCOB: 10,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [0],
+            lastCarbTime: now.timeIntervalSince1970
+        )
+        let bg = BloodGlucose(sgv: 100, date: Decimal(now.timeIntervalSince1970 * 1000), dateString: now)
+        let autosens = Autosens(ratio: 1, newisf: nil)
+        #expect(
+            DeterminationGenerator.isSMBEnabled(
+                glucose: bg,
+                profile: profile,
+                autosens: autosens,
+                mealData: mealData,
+                override: nil,
+                shouldProtectDueToHIGH: false,
+                currentTime: now
+            ) == true
+        )
+    }
+
+    /// Any carb entry should enable SMB for the after-carbs window
+    @Test("should enable SMB after carbs") func enableAfterCarbs() async throws {
+        let now = Date()
+        var profile = Profile()
+        profile.enableSMBAfterCarbs = true
+        let mealData = ComputedCarbs(
+            carbs: 15,
+            mealCOB: 0,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [0],
+            lastCarbTime: now.timeIntervalSince1970
+        )
+        let bg = BloodGlucose(sgv: 90, date: Decimal(now.timeIntervalSince1970 * 1000), dateString: now)
+        let autosens = Autosens(ratio: 1, newisf: nil)
+        #expect(
+            DeterminationGenerator.isSMBEnabled(
+                glucose: bg,
+                profile: profile,
+                autosens: autosens,
+                mealData: mealData,
+                override: nil,
+                shouldProtectDueToHIGH: false,
+                currentTime: now
+            ) == true
+        )
+    }
+
+    /// High-BG condition should enable SMB when above threshold
+    @Test("should enable SMB for high BG") func enableWithHighBG() async throws {
+        let now = Date()
+        var profile = Profile()
+        profile.enableSMBHighBg = true
+        profile.enableSMBHighBgTarget = 130
+        let bg = BloodGlucose(sgv: 135, date: Decimal(now.timeIntervalSince1970 * 1000), dateString: now)
+        let autosens = Autosens(ratio: 1, newisf: nil)
+        #expect(
+            DeterminationGenerator.isSMBEnabled(
+                glucose: bg,
+                profile: profile,
+                autosens: autosens,
+                mealData: nil,
+                override: nil,
+                shouldProtectDueToHIGH: false,
+                currentTime: now
+            ) == true
+        )
+    }
+}

+ 4 - 2
TrioTests/OpenAPSSwiftTests/IobJsonTests.swift

@@ -91,7 +91,8 @@ import Testing
             javascriptDuration: 0.1,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         if comparison.resultType == .valueDifference {
@@ -129,7 +130,8 @@ import Testing
             javascriptDuration: 0.1,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         if comparison.resultType != .valueDifference {

+ 2 - 1
TrioTests/OpenAPSSwiftTests/MealJsonTests.swift

@@ -79,7 +79,8 @@ import Testing
             javascriptDuration: 0.1,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         if comparison.resultType == .valueDifference {

+ 2 - 1
TrioTests/OpenAPSSwiftTests/ProfileJavascriptTests.swift

@@ -329,7 +329,8 @@ struct ProfileGeneratorTests {
             javascriptDuration: 1.0,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         if comparison.resultType == .valueDifference {

+ 14 - 7
TrioTests/OpenAPSSwiftTests/ProfileJsNativeCompareTests.swift

@@ -97,7 +97,8 @@ import Testing
             javascriptDuration: 0.1,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         #expect(comparison.resultType == .matching)
@@ -129,7 +130,8 @@ import Testing
             javascriptDuration: 0.2,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         #expect(comparison.resultType == .matching)
@@ -150,7 +152,8 @@ import Testing
             javascriptDuration: 0.2,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         #expect(comparison.resultType == .valueDifference)
@@ -173,7 +176,8 @@ import Testing
             javascriptDuration: 0.2,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         #expect(comparison.resultType == .matchingExceptions)
@@ -193,7 +197,8 @@ import Testing
             javascriptDuration: 0.2,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         #expect(comparison.resultType == .swiftOnlyException)
@@ -215,7 +220,8 @@ import Testing
             javascriptDuration: 0.2,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         #expect(comparison.resultType == .jsOnlyException)
@@ -236,7 +242,8 @@ import Testing
             javascriptDuration: 0.2,
             iobInputs: nil,
             mealInputs: nil,
-            autosensInputs: nil
+            autosensInputs: nil,
+            determineBasalInputs: nil
         )
 
         #expect(comparison.resultType == .comparisonError)