Преглед изворни кода

Merge branch 'feat/dev-oref-swift' of github.com:nightscout/Trio into fix/glucose-clamping

Deniz Cengiz пре 2 дана
родитељ
комит
f47693c483
100 измењених фајлова са 14115 додато и 244 уклоњено
  1. 9 0
      DATA_MAINTAINERS.md
  2. 145 0
      DeveloperDocs/OrefSwift/oref_swift_port_notes.md
  3. 152 0
      DeveloperDocs/OrefSwift/replay.md
  4. 64 0
      DeveloperDocs/OrefSwift/roadmap.md
  5. 0 14
      Model/JSONImporter.swift
  6. 103 0
      PRIVACY.md
  7. 453 56
      Trio.xcodeproj/project.pbxproj
  8. 12 1
      Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme
  9. 27 18
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  10. 8 3
      Trio/Sources/APS/APSManager.swift
  11. 0 1
      Trio/Sources/APS/CGM/PluginSource.swift
  12. 8 0
      Trio/Sources/APS/Extensions/DecimalExtensions.swift
  13. 326 129
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  14. 27 0
      Trio/Sources/APS/OpenAPSSwift/Autosens/AutosensError.swift
  15. 433 0
      Trio/Sources/APS/OpenAPSSwift/Autosens/AutosensGenerator.swift
  16. 46 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DeterminationError.swift
  17. 347 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasal+Helpers.swift
  18. 737 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift
  19. 926 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DosingEngine.swift
  20. 169 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DynamicISF.swift
  21. 114 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/TempBasalFunctions.swift
  22. 11 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedBGTargets+Getter.swift
  23. 24 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedInsulinSensitivities+Getter.swift
  24. 78 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/Date+MinutesFromMidnight.swift
  25. 47 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/Decimal+rounding.swift
  26. 33 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/DoubleApproximateMatching.swift
  27. 9 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/InsulinSensitivities+Convert.swift
  28. 11 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+Autosens.swift
  29. 95 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+TherapySettingGetter.swift
  30. 234 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/PumpHistory+copy.swift
  31. 18 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/PumpHistoryEvent+Duplicates.swift
  32. 27 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/TimeExtensions.swift
  33. 80 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/CarbImpactParams.swift
  34. 244 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator+Forecasts.swift
  35. 433 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift
  36. 45 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastResults.swift
  37. 131 0
      Trio/Sources/APS/OpenAPSSwift/Iob/IobCalculation.swift
  38. 33 0
      Trio/Sources/APS/OpenAPSSwift/Iob/IobError.swift
  39. 50 0
      Trio/Sources/APS/OpenAPSSwift/Iob/IobGenerator.swift
  40. 485 0
      Trio/Sources/APS/OpenAPSSwift/Iob/IobHistory.swift
  41. 123 0
      Trio/Sources/APS/OpenAPSSwift/JSONBridge.swift
  42. 293 0
      Trio/Sources/APS/OpenAPSSwift/Meal/MealCob.swift
  43. 40 0
      Trio/Sources/APS/OpenAPSSwift/Meal/MealGenerator.swift
  44. 81 0
      Trio/Sources/APS/OpenAPSSwift/Meal/MealHistory.swift
  45. 210 0
      Trio/Sources/APS/OpenAPSSwift/Meal/MealTotal.swift
  46. 7 0
      Trio/Sources/APS/OpenAPSSwift/Models/AdjustedGlucoseTargets.swift
  47. 37 0
      Trio/Sources/APS/OpenAPSSwift/Models/ComputedBGTargets.swift
  48. 61 0
      Trio/Sources/APS/OpenAPSSwift/Models/ComputedInsulinSensitivities.swift
  49. 95 0
      Trio/Sources/APS/OpenAPSSwift/Models/ComputedPumpHistoryEvent.swift
  50. 40 0
      Trio/Sources/APS/OpenAPSSwift/Models/ForecastResult.swift
  51. 28 0
      Trio/Sources/APS/OpenAPSSwift/Models/GlucoseStatus.swift
  52. 92 0
      Trio/Sources/APS/OpenAPSSwift/Models/IobResult.swift
  53. 138 0
      Trio/Sources/APS/OpenAPSSwift/Models/Profile.swift
  54. 210 0
      Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift
  55. 13 0
      Trio/Sources/APS/OpenAPSSwift/OrefFunctionResult.swift
  56. 35 0
      Trio/Sources/APS/OpenAPSSwift/Profile/Basal.swift
  57. 35 0
      Trio/Sources/APS/OpenAPSSwift/Profile/Carbs.swift
  58. 67 0
      Trio/Sources/APS/OpenAPSSwift/Profile/Isf.swift
  59. 33 0
      Trio/Sources/APS/OpenAPSSwift/Profile/ProfileError.swift
  60. 222 0
      Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift
  61. 94 0
      Trio/Sources/APS/OpenAPSSwift/Profile/Targets.swift
  62. 29 0
      Trio/Sources/APS/OpenAPSSwift/Utils/JavascriptOptional.swift
  63. 0 2
      Trio/Sources/APS/Storage/DeterminationStorage.swift
  64. 123 0
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  65. 58 0
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  66. 17 0
      Trio/Sources/Models/Autosens.swift
  67. 7 1
      Trio/Sources/Models/BloodGlucose.swift
  68. 8 7
      Trio/Sources/Models/Determination.swift
  69. 0 5
      Trio/Sources/Models/PumpHistoryEvent.swift
  70. 1 1
      Trio/Sources/Models/TrioCustomOrefVariables.swift
  71. 5 0
      Trio/Sources/Models/TrioSettings.swift
  72. 7 1
      Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift
  73. 34 0
      Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift
  74. 5 0
      Trio/Sources/Modules/Home/View/Header/LoopStatusView.swift
  75. 0 2
      Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift
  76. 2 1
      Trio/Sources/Views/TagCloudView.swift
  77. 42 0
      TrioTests/CoreDataTests/GlucoseStorageTests.swift
  78. 8 0
      TrioTests/Info.plist
  79. 0 2
      TrioTests/JSONImporterTests.swift
  80. 175 0
      TrioTests/OpenAPSSwiftTests/AutosensTests.swift
  81. 1174 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalAggressiveDosingTests.swift
  82. 104 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalDeltaCalculationTests.swift
  83. 573 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalEarlyExitTests.swift
  84. 420 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalEnableSmbTests.swift
  85. 131 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift
  86. 130 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalGlucoseFallingFasterThanExpectedTests.swift
  87. 119 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalIobGreaterThanMaxTests.swift
  88. 186 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalLowEventualGlucoseTests.swift
  89. 179 0
      TrioTests/OpenAPSSwiftTests/DetermineBasalSmbMicroBolusTests.swift
  90. 195 0
      TrioTests/OpenAPSSwiftTests/DynamicISFTests.swift
  91. 188 0
      TrioTests/OpenAPSSwiftTests/IobCalculateTests.swift
  92. 167 0
      TrioTests/OpenAPSSwiftTests/IobConsecutiveEventsTests.swift
  93. 37 0
      TrioTests/OpenAPSSwiftTests/IobGenerateTests.swift
  94. 636 0
      TrioTests/OpenAPSSwiftTests/IobHistoryTests.swift
  95. 379 0
      TrioTests/OpenAPSSwiftTests/IobSuspendTests.swift
  96. 200 0
      TrioTests/OpenAPSSwiftTests/IobTotalTests.swift
  97. 252 0
      TrioTests/OpenAPSSwiftTests/MealCobBucketingTests.swift
  98. 227 0
      TrioTests/OpenAPSSwiftTests/MealCobTests.swift
  99. 149 0
      TrioTests/OpenAPSSwiftTests/MealHistoryTests.swift
  100. 0 0
      TrioTests/OpenAPSSwiftTests/MealTotalTests.swift

+ 9 - 0
DATA_MAINTAINERS.md

@@ -0,0 +1,9 @@
+# Data maintainers
+
+Contacts (GitHub handles):
+- @kingst
+- @marv-out
+- @mikeplante1
+- @dsnallfot
+- @bjornoleh
+- @dnzxy

+ 145 - 0
DeveloperDocs/OrefSwift/oref_swift_port_notes.md

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

+ 152 - 0
DeveloperDocs/OrefSwift/replay.md

@@ -0,0 +1,152 @@
+# Replaying inputs for oref
+
+To debug and verify our swift oref implementation, we replay inputs
+caputed from real devices. This document outlines the two main use
+cases for this replay mechanism: verification and daily verification.
+It also shows how to debug when you find an inconsistency.
+
+## Verification
+
+To verify our swift oref implementation, we replay a large number of
+inputs that have caused inconsistencies in the past. If our swift
+implementation is correct, these previously incorrect runs will now be
+consistent with either the JS implementation or our fixed JS
+implementation, which is present only in our testing bundle.
+
+To do a verification run:
+
+```bash
+# In Trio-oref, check out the latest `oref-swift` branch
+$ cd Trio-oref
+$ git checkout oref-swift
+
+# In trio-oref-logs get the latest inputs
+$ cd ../trio-oref-logs
+$ ./update_trio_stats.sh # will take a long time for the first run
+
+# extract all inputs from the logs
+$ python extract_inputs.py
+
+# run the verification script
+$ python run_tests_on_existing_errors.py
+```
+
+This verification script will run through all of the inputs, separated
+by timezone, and either confirm that all inputs produce correct outputs
+or flag any timezones that had incorrect runs.
+
+## Daily verification
+
+Each day as new logs come in, you can run through the logs to see if
+there are any inconsistencies. To do this, you run:
+
+```bash
+# Fetch the latest logs incrementally
+$ ./update_trio_stats.sh
+# run through all of the inputs for a single day
+$ python run_tests_on_errors.py 2025-12-06 > 2025-12-06.txt
+```
+
+Then once it's done running it'll give you a report to let you know if
+there were any inconsistencies found. That report will look something
+like this:
+
+```
+(venv) kingst@Sams-MacBook-Pro-4 trio-oref-logs % tail 2025-12-06.txt 
+
+--- Summary---
+- autosens: 10 errors, Xcode tests: ✅
+- determineBasal: 11 errors, Xcode tests: ❌ Failed for: America/Los_Angeles
+- iob: 521 errors, Xcode tests: ✅
+- profile: 0 errors, Xcode tests: N/A
+- meal: 1178 errors, Xcode tests: ✅
+```
+
+This summary shows that all of the `autosens`, `iob`, and `meal`
+inputs were consistent when run within the unit test, `profile` didn't
+have any inconsistencies, and `determineBasal` had one or more replay
+runs where there was an inconsistency for records in the
+America/Los_Angeles timezone.
+
+## Debugging
+
+If you get an error, you need to step through the code and debug it. I
+haven't found a good way to do this in an automated fashion yet, so
+this is a highly manual process.
+
+From an architecture perspective, there are three key
+components. First, there is a local HTTP server that runs within the
+`trio-oref-logs` repo to serve up inputs for replay. We use a local
+HTTP server to enable us to access a large number of input logs from
+within our iOS app running on a simulator.
+
+Second, there is the iOS unit test. This test will download a list of
+files from the HTTP server, download files one-by-one, and run the
+appropriate function on it (e.g., `determineBasal`) to test against
+the production JS implementation and a [JS
+implementation](https://github.com/kingst/trio-oref/tree/dev-fixes-for-swift-comparison)
+that has the bug fixes we added to Swift. It also formats the inputs
+in a way that is suitable for running with the JS implementation using
+mocha.
+
+Third, the JS implementation includes unit tests for replaying inputs
+created by the iOS test.
+
+With this architecture, you can debug the same input on both the JS
+and Swift implementations.
+
+Here is an example of debugging the `determineBasal` bug from the
+2025-12-06 daily verification run that we list above.
+
+First, extract out the inputs for that particular day and serve them
+using our HTTP server:
+
+```bash
+$ cd trio-oref-logs
+$ rm errors/*
+$ ./extract_errors.sh determineBasal 2025-12-06
+$ python serve_errors.py
+```
+
+Next, open up xcode and set up the ConfigOverride.xcconfig file:
+
+```
+ENABLE_REPLAY_TESTS = YES
+REPLAY_TEST_TIMEZONE = America/Los_Angeles
+HTTP_FILES_OFFSET = 0
+HTTP_FILES_LENGTH = 2500
+```
+
+Run the unit test that will run through all of the errors:
+`DetermineBasalJsonTests.replayErrorInputs`
+
+Search through the console for the string "REPLAY ERROR" -- this will
+show you what was different and will tell you which input file caused
+the error.
+
+Then, update the unit test that runs for a single input, in our case
+`DetermineBasalJsonTests.formatInputs` and copy in the name of the
+input file. It will look something like this:
+`/files/f1d04efa-c39b-4f0a-9955-65ab663ff9fb.0.json`. Confirm that the
+test is still failing. This run will also create the inputs for use
+with JS replay tests.
+
+Search through the console and look for the string "writing" to find
+the location on your local file system for the inputs formatted for
+the JS replay unit test.
+
+From the JS repo that has the fixed JS implementation, copy in the inputs:
+
+```bash
+$ cd trio-oref
+$ git checkout dev-fixes-for-swift-comparison
+$ cp /Users/kingst/Library/Developer/CoreSimulator/Devices/98ED1614-33B5-4F12-906B-D5C092AD0EB5/data/Containers/Data/Application/F9F20EFC-128C-482B-85E3-C59A3242DDEB/tmp/determine_basal_error_inputs.json tests
+$ ./node_modules/.bin/mocha --inspect-brk -c tests/determine-basal-replay.test.js
+```
+
+And the replay test is waiting for you to attach a debugger. I use
+Visual Studio to debug Javascript, but anything that understands JS
+debugging protocols should work.
+
+And at this point you can replay both JS and Swift implementations for
+an input that causes an inconsistency and debug the issue.

+ 64 - 0
DeveloperDocs/OrefSwift/roadmap.md

@@ -0,0 +1,64 @@
+# Roadmap
+
+At this point, we have a complete port of the oref algorithm from
+Javascript to Swift. At a high level, the three steps we want to go
+through are:
+
+  - Small scale testing
+  - Beta testing shadow mode
+  - Beta testing swift algorithm
+  - Release
+
+## Small scale testing
+
+At this stage, the implementation is in the `Trio-dev` repo and there
+are a small number of known testers running the algorithm. The Swift
+implementation runs in shadow mode where we execute it, compare the
+results against JS, and log any inconsistencies for further analysis.
+
+The exit criteria for this stage is:
+
+  - Ensure no inconsistencies for the large database (200k+) of inputs
+    we have.
+
+  - Fix any known bugs in the Swift implementation (all documented via
+    GitHub issues)
+
+  - Do an analysis on the algorithm bugs we fixed in Swift to confirm
+    that the resulting changes to the algorithm are safe and within
+    our expected bounds.
+
+  - Add the ability to test fixed JS in the app before logging
+    inconsistencies to reduce the logging volume.
+
+## Beta testing shadow mode
+
+At this stage, we move the algorithm to the main `Trio` repo on the
+dev branch. The Swift implementation is still running in shadow mode
+while we collect more data.
+
+The exit criteria for this stage is:
+
+  - No inconsistencies in the algorithm for one week of operation
+
+## Beta testing swift algorithm
+
+At this stage, we move to using the Swift implementation for dosing
+decisions, but we keep the JS implementation to check for
+inconsistencies and log inputs for any inconsistent runs.
+
+The exit criteria for this stage is:
+
+  - No inconsistencies in the algorithm for one month of operation
+
+## Release
+
+At this stage, the port is complete. The swift code is running and we
+productionize the implementation.
+
+Productionization includes:
+
+  - Removing the JS implementation from the repo
+
+  - Refactoring the replay mechanism or removing it depending on if we
+    want to use it for other features in the future

+ 0 - 14
Model/JSONImporter.swift

@@ -518,8 +518,6 @@ extension Determination: Codable {
         case isf = "ISF"
         case current_target
         case tdd = "TDD"
-        case insulinForManualBolus
-        case manualBolusErrorString
         case minDelta
         case expectedDelta
         case minGuardBG
@@ -553,8 +551,6 @@ extension Determination: Codable {
         isf = try container.decodeIfPresent(Decimal.self, forKey: .isf)
         current_target = try container.decodeIfPresent(Decimal.self, forKey: .current_target)
         tdd = try container.decodeIfPresent(Decimal.self, forKey: .tdd)
-        insulinForManualBolus = try container.decodeIfPresent(Decimal.self, forKey: .insulinForManualBolus)
-        manualBolusErrorString = try container.decodeIfPresent(Decimal.self, forKey: .manualBolusErrorString)
         minDelta = try container.decodeIfPresent(Decimal.self, forKey: .minDelta)
         expectedDelta = try container.decodeIfPresent(Decimal.self, forKey: .expectedDelta)
         minGuardBG = try container.decodeIfPresent(Decimal.self, forKey: .minGuardBG)
@@ -595,8 +591,6 @@ extension Determination: Codable {
         try container.encodeIfPresent(isf, forKey: .isf)
         try container.encodeIfPresent(current_target, forKey: .current_target)
         try container.encodeIfPresent(tdd, forKey: .tdd)
-        try container.encodeIfPresent(insulinForManualBolus, forKey: .insulinForManualBolus)
-        try container.encodeIfPresent(manualBolusErrorString, forKey: .manualBolusErrorString)
         try container.encodeIfPresent(minDelta, forKey: .minDelta)
         try container.encodeIfPresent(expectedDelta, forKey: .expectedDelta)
         try container.encodeIfPresent(minGuardBG, forKey: .minGuardBG)
@@ -634,12 +628,6 @@ extension Determination: Codable {
         guard let isf = isf else {
             throw JSONImporterError.missingRequiredPropertyInDetermination("ISF")
         }
-        guard let manualBolusErrorString = manualBolusErrorString else {
-            throw JSONImporterError.missingRequiredPropertyInDetermination("manualBolusErrorString")
-        }
-        guard let insulinForManualBolus = insulinForManualBolus else {
-            throw JSONImporterError.missingRequiredPropertyInDetermination("insulinForManualBolus")
-        }
         guard let cob = cob else {
             throw JSONImporterError.missingRequiredPropertyInDetermination("COB")
         }
@@ -676,7 +664,6 @@ extension Determination: Codable {
         newOrefDetermination.deliverAt = deliverAt
         newOrefDetermination.timestamp = timestamp
         newOrefDetermination.enacted = received ?? false
-        newOrefDetermination.insulinForManualBolus = decimalToNSDecimalNumber(insulinForManualBolus)
         newOrefDetermination.carbRatio = decimalToNSDecimalNumber(carbRatio)
         newOrefDetermination.glucose = decimalToNSDecimalNumber(bg)
         newOrefDetermination.reservoir = decimalToNSDecimalNumber(reservoir)
@@ -691,7 +678,6 @@ extension Determination: Codable {
         newOrefDetermination.sensitivityRatio = decimalToNSDecimalNumber(sensitivityRatio)
         newOrefDetermination.expectedDelta = decimalToNSDecimalNumber(expectedDelta)
         newOrefDetermination.cob = Int16(Int(cob ?? 0))
-        newOrefDetermination.manualBolusErrorString = decimalToNSDecimalNumber(manualBolusErrorString)
         newOrefDetermination.smbToDeliver = units.map { NSDecimalNumber(decimal: $0) }
         newOrefDetermination.carbsRequired = Int16(Int(carbsReq ?? 0))
         newOrefDetermination.isUploadedToNS = true

+ 103 - 0
PRIVACY.md

@@ -0,0 +1,103 @@
+# Privacy Policy for Trio Debug Data Collection
+*A Nightscout Foundation Project*
+
+## Purpose and Scope
+This privacy policy outlines the principles and practices for collecting, using, and protecting debug data in Trio, an open-source insulin dosing algorithm project of the Nightscout Foundation. Our primary goal is to ensure algorithm safety and accuracy while maintaining the highest standards of user privacy.
+
+## Data Collection Principles
+
+### 1. Minimal Collection
+- We collect only the mathematical differences between JavaScript and Swift algorithm implementations
+- No personal identifiers, device information, or timestamps are collected
+- No insulin doses, blood glucose values, or other medical data are stored
+- Data collected is limited strictly to algorithm debugging purposes
+
+### 2. Anonymization
+- All data is anonymized at the source before transmission
+- Device identification uses Apple's vendor ID system, which:
+  - Allows users to reset their device identifier at any time
+  - Provides consistent identification only until user reset
+  - Cannot be used to track across different apps
+- No IP addresses are stored
+- No geographic or temporal information is retained
+- No personal user information is collected
+
+### 3. Transparency
+- Data collection code is [open source](https://github.com/kingst/trio-oref-logs) and available for community review
+- Specific data points being collected are documented in the source code
+- Your information will only be used as in this privacy policy -- any changes to data collection must go through public code review
+- Regular reports on data usage will be published to the community
+
+## Data Usage
+
+### Permitted Uses
+- Identifying mathematical discrepancies between implementations
+- Validating algorithm consistency across platforms
+- Debugging edge cases in calculations
+- Improving algorithm accuracy and safety
+
+### Prohibited Uses
+- No commercial use or sharing data with third parties
+- No attempt to re-identify or correlate data points
+- No use for marketing, analytics, or user behavior analysis
+- No combination with other data sources
+
+### Use in research publications
+- We will maintain aggregate statistics, like invocation rates and average timing differences between Javascript and Swift, for use in research publications
+- We will not use individual records
+
+## Data Protection
+
+### Security Measures
+- Data is encrypted in transit and rest using industry-standard protocols
+- Access to collected data is strictly limited to core algorithm developers
+- Data is stored in a secure, isolated environment
+- Regular security audits are performed
+- We will do everything we can to maintain the security of your data, but complete data security cannot be guaranteed
+
+### Data Retention
+- Debug data is retained only for the duration necessary for verification
+- Maximum retention period of 90 days
+- Automatic data deletion after the retention period
+- Option for immediate deletion upon request
+
+## Community Oversight
+
+### Transparency Reports
+- Monthly reports on:
+  - Volume of data collected
+  - How the data was used
+  - Any findings or improvements made
+  - Confirmation of data deletion
+
+### Community Control
+- User can disable data collection at any time
+- Community voting is required for any changes to this policy
+- Annual review of data collection necessity
+- Public issue tracker for privacy-related concerns
+
+## User Rights
+
+### Control and Consent
+- Explicit opt-in required for data collection
+- Right to opt out at any time
+- Right to reset device identifier through iOS settings
+- Right to request verification of data deletion
+
+### Communication
+- 72-hour response time commitment for privacy concerns
+- Regular updates on privacy-related improvements
+- Clear documentation of all privacy features
+
+## Updates to This Policy
+- Changes require a community discussion period
+- Minimum 90-day notice before any changes
+- All historical versions of this policy are maintained in this repository
+- Change log with justifications maintained
+
+## Contact Information
+- Dedicated privacy contacts listed in DATA_MAINTAINERS.md
+- Public discussion in GitHub issues
+- Optional private communication channel for sensitive concerns
+
+This policy is maintained in the Trio project repository at `/PRIVACY.md` and is governed by the same open-source principles as the rest of the project. As a Nightscout Foundation project, Trio adheres to the Foundation's commitment to transparency, security, and patient privacy in diabetes technology.

Разлика између датотеке није приказан због своје велике величине
+ 453 - 56
Trio.xcodeproj/project.pbxproj


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

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

+ 27 - 18
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,5 +1,5 @@
 {
-  "originHash" : "598841ae6fe892058ca678f5672f34299df2d62843330367c207648003263ccd",
+  "originHash" : "1e72c1cdf8ea5ec9fe527ebfab01ea55fca9e8651fe3252338fd3d4ea2cb327a",
   "pins" : [
     {
       "identity" : "abseil-cpp-binary",
@@ -69,8 +69,17 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/firebase/firebase-ios-sdk.git",
       "state" : {
-        "revision" : "d1f7c7e8eaa74d7e44467184dc5f592268247d33",
-        "version" : "11.11.0"
+        "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5",
+        "version" : "11.15.0"
+      }
+    },
+    {
+      "identity" : "google-ads-on-device-conversion-ios-sdk",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
+      "state" : {
+        "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed",
+        "version" : "2.3.0"
       }
     },
     {
@@ -78,8 +87,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/GoogleAppMeasurement.git",
       "state" : {
-        "revision" : "dd89fc79a77183830742a16866d87e4e54785734",
-        "version" : "11.11.0"
+        "revision" : "45ce435e9406d3c674dd249a042b932bee006f60",
+        "version" : "11.15.0"
       }
     },
     {
@@ -96,8 +105,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/GoogleUtilities.git",
       "state" : {
-        "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb",
-        "version" : "8.0.2"
+        "revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
+        "version" : "8.1.0"
       }
     },
     {
@@ -105,8 +114,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/grpc-binary.git",
       "state" : {
-        "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71",
-        "version" : "1.69.0"
+        "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
+        "version" : "1.69.1"
       }
     },
     {
@@ -114,8 +123,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/gtm-session-fetcher.git",
       "state" : {
-        "revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35",
-        "version" : "4.4.0"
+        "revision" : "c756a29784521063b6a1202907e2cc47f41b667c",
+        "version" : "4.5.0"
       }
     },
     {
@@ -213,8 +222,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-log.git",
       "state" : {
-        "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
-        "version" : "1.6.4"
+        "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523",
+        "version" : "1.10.1"
       }
     },
     {
@@ -231,14 +240,14 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-protobuf.git",
       "state" : {
-        "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f",
-        "version" : "1.29.0"
+        "revision" : "a008af1a102ff3dd6cc3764bb69bf63226d0f5f6",
+        "version" : "1.36.1"
       }
     },
     {
       "identity" : "swiftcharts",
       "kind" : "remoteSourceControl",
-      "location" : "https://github.com/ivanschuetz/SwiftCharts.git",
+      "location" : "https://github.com/ivanschuetz/SwiftCharts",
       "state" : {
         "branch" : "master",
         "revision" : "c354c1945bb35a1f01b665b22474f6db28cba4a2"
@@ -267,8 +276,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/Swinject/Swinject",
       "state" : {
-        "revision" : "be9dbcc7b86811bc131539a20c6f9c2d3e56919f",
-        "version" : "2.9.1"
+        "revision" : "b685b549fe4d8ae265fc7a2f27d0789720425d69",
+        "version" : "2.10.0"
       }
     },
     {

+ 8 - 3
Trio/Sources/APS/APSManager.swift

@@ -156,7 +156,7 @@ final class BaseAPSManager: APSManager, Injectable {
             if wasParsed {
                 Task {
                     do {
-                        try await openAPS.createProfiles()
+                        try await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
                     } catch {
                         debug(
                             .apsManager,
@@ -410,7 +410,10 @@ final class BaseAPSManager: APSManager, Injectable {
         guard let autosense = await storage.retrieveAsync(OpenAPS.Settings.autosense, as: Autosens.self),
               (autosense.timestamp ?? .distantPast).addingTimeInterval(30.minutes.timeInterval) > Date()
         else {
-            let result = try await openAPS.autosense(shouldSmoothGlucose: settingsManager.settings.smoothGlucose)
+            let result = try await openAPS.autosense(
+                shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
+                useSwiftOref: settings.useSwiftOref
+            )
             return result != nil
         }
 
@@ -480,10 +483,11 @@ final class BaseAPSManager: APSManager, Injectable {
             async let autosenseResult = autosense()
 
             _ = try await autosenseResult
-            try await openAPS.createProfiles()
+            try await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
             let determination = try await openAPS.determineBasal(
                 currentTemp: await currentTemp,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
+                useSwiftOref: settings.useSwiftOref,
                 clock: now
             )
             iobFileDidUpdate.send(())
@@ -530,6 +534,7 @@ final class BaseAPSManager: APSManager, Injectable {
             return try await openAPS.determineBasal(
                 currentTemp: temp,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
+                useSwiftOref: settings.useSwiftOref,
                 clock: Date(),
                 simulatedCarbsAmount: simulatedCarbsAmount,
                 simulatedBolusAmount: simulatedBolusAmount,

+ 0 - 1
Trio/Sources/APS/CGM/PluginSource.swift

@@ -180,7 +180,6 @@ extension PluginSource: CGMManagerDelegate {
 
     func cgmManager(_: CGMManager, didUpdate status: CGMManagerStatus) {
         debug(.deviceManager, "CGM Manager did update state to \(status)")
-
         processQueue.async { [weak self] in
             guard let self = self else { return }
 

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

+ 326 - 129
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -40,7 +40,6 @@ final class OpenAPS {
             newOrefDetermination.currentTarget = self.decimalToNSDecimalNumber(determination.current_target)
             newOrefDetermination.eventualBG = determination.eventualBG.map(NSDecimalNumber.init)
             newOrefDetermination.deliverAt = determination.deliverAt
-            newOrefDetermination.insulinForManualBolus = self.decimalToNSDecimalNumber(determination.insulinForManualBolus)
             newOrefDetermination.carbRatio = self.decimalToNSDecimalNumber(determination.carbRatio)
             newOrefDetermination.glucose = self.decimalToNSDecimalNumber(determination.bg)
             newOrefDetermination.reservoir = self.decimalToNSDecimalNumber(determination.reservoir)
@@ -55,7 +54,6 @@ final class OpenAPS {
             newOrefDetermination.sensitivityRatio = self.decimalToNSDecimalNumber(determination.sensitivityRatio)
             newOrefDetermination.expectedDelta = self.decimalToNSDecimalNumber(determination.expectedDelta)
             newOrefDetermination.cob = Int16(Int(determination.cob ?? 0))
-            newOrefDetermination.manualBolusErrorString = self.decimalToNSDecimalNumber(determination.manualBolusErrorString)
             newOrefDetermination.smbToDeliver = determination.units.map { NSDecimalNumber(decimal: $0) }
             newOrefDetermination.carbsRequired = Int16(Int(determination.carbsReq ?? 0))
             newOrefDetermination.isUploadedToNS = false
@@ -380,6 +378,7 @@ final class OpenAPS {
     func determineBasal(
         currentTemp: TempBasal,
         shouldSmoothGlucose: Bool,
+        useSwiftOref: Bool,
         clock: Date = Date(),
         simulatedCarbsAmount: Decimal? = nil,
         simulatedBolusAmount: Decimal? = nil,
@@ -433,7 +432,8 @@ final class OpenAPS {
             basalProfile: basalProfile,
             clock: clock,
             carbs: carbsAsJSON,
-            glucose: glucoseAsJSON
+            glucose: glucoseAsJSON,
+            useSwiftOref: useSwiftOref
         )
 
         // IOB calculation
@@ -441,7 +441,8 @@ final class OpenAPS {
             pumphistory: pumpHistoryJSON,
             profile: profile,
             clock: clock,
-            autosens: autosens.isEmpty ? .null : autosens
+            autosens: autosens.isEmpty ? .null : autosens,
+            useSwiftOref: useSwiftOref
         )
 
         // TODO: refactor this to core data
@@ -470,7 +471,8 @@ final class OpenAPS {
             pumpHistory: pumpHistoryJSON,
             preferences: preferences,
             basalProfile: basalProfile,
-            trioCustomOrefVariables: trioCustomOrefVariables
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            useSwiftOref: useSwiftOref
         )
 
         debug(.openAPS, "\(simulation ? "[SIMULATION]" : "") OREF DETERMINATION: \(orefDetermination)")
@@ -563,7 +565,7 @@ final class OpenAPS {
         }
     }
 
-    func autosense(shouldSmoothGlucose: Bool) async throws -> Autosens? {
+    func autosense(shouldSmoothGlucose: Bool, useSwiftOref: Bool) async throws -> Autosens? {
         debug(.openAPS, "Start autosens")
 
         // Perform asynchronous calls in parallel
@@ -591,7 +593,8 @@ final class OpenAPS {
             basalprofile: basalProfile,
             profile: profile,
             carbs: carbsAsJSON,
-            temptargets: tempTargets
+            temptargets: tempTargets,
+            useSwiftOref: useSwiftOref
         )
 
         debug(.openAPS, "AUTOSENS: \(autosenseResult)")
@@ -605,7 +608,7 @@ final class OpenAPS {
         }
     }
 
-    func createProfiles() async throws {
+    func createProfiles(useSwiftOref: Bool) async throws {
         debug(.openAPS, "Start creating pump profile and user profile")
 
         // Load required settings and profiles asynchronously
@@ -669,6 +672,7 @@ final class OpenAPS {
             }
         }
 
+        let clock = Date()
         do {
             let pumpProfile = try await makeProfile(
                 preferences: adjustedPreferences,
@@ -680,7 +684,9 @@ final class OpenAPS {
                 tempTargets: tempTargets,
                 model: model,
                 autotune: RawJSON.null,
-                trioData: trioSettings
+                trioSettings: trioSettings,
+                useSwiftOref: useSwiftOref,
+                clock: clock
             )
 
             let profile = try await makeProfile(
@@ -693,7 +699,9 @@ final class OpenAPS {
                 tempTargets: tempTargets,
                 model: model,
                 autotune: RawJSON.null,
-                trioData: trioSettings
+                trioSettings: trioSettings,
+                useSwiftOref: useSwiftOref,
+                clock: clock
             )
 
             // Save the profiles
@@ -708,22 +716,44 @@ final class OpenAPS {
         }
     }
 
-    private func iob(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON) async throws -> RawJSON {
-        try await withCheckedThrowingContinuation { continuation in
-            jsWorker.inCommonContext { worker in
-                worker.evaluateBatch(scripts: [
-                    Script(name: Prepare.log),
-                    Script(name: Bundle.iob),
-                    Script(name: Prepare.iob)
-                ])
-                let result = worker.call(function: Function.generate, with: [
-                    pumphistory,
-                    profile,
-                    clock,
-                    autosens
-                ])
-                continuation.resume(returning: result)
+    private func iob(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON, useSwiftOref: Bool) async throws -> RawJSON {
+        // FIXME: For now we'll just remove duplicate suspends here (ISSUE-399)
+        var pumphistory = pumphistory
+        if let pumpHistoryArray = try? JSONBridge.pumpHistory(from: pumphistory) {
+            pumphistory = pumpHistoryArray.removingDuplicateSuspendResumeEvents().rawJSON
+        }
+
+        if useSwiftOref {
+            let swiftResult = OpenAPSSwift
+                .iob(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
+            return try swiftResult.returnOrThrow()
+        } else {
+            let jsResult = await iobJavascript(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
+            return try jsResult.returnOrThrow()
+        }
+    }
+
+    func iobJavascript(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON) async -> OrefFunctionResult {
+        do {
+            let result = try await withCheckedThrowingContinuation { continuation in
+                jsWorker.inCommonContext { worker in
+                    worker.evaluateBatch(scripts: [
+                        Script(name: Prepare.log),
+                        Script(name: Bundle.iob),
+                        Script(name: Prepare.iob)
+                    ])
+                    let result = worker.call(function: Function.generate, with: [
+                        pumphistory,
+                        profile,
+                        clock,
+                        autosens
+                    ])
+                    continuation.resume(returning: result)
+                }
             }
+            return .success(result)
+        } catch {
+            return .failure(error)
         }
     }
 
@@ -733,25 +763,63 @@ final class OpenAPS {
         basalProfile: JSON,
         clock: JSON,
         carbs: JSON,
-        glucose: JSON
+        glucose: JSON,
+        useSwiftOref: Bool
     ) async throws -> RawJSON {
-        try await withCheckedThrowingContinuation { continuation in
-            jsWorker.inCommonContext { worker in
-                worker.evaluateBatch(scripts: [
-                    Script(name: Prepare.log),
-                    Script(name: Bundle.meal),
-                    Script(name: Prepare.meal)
-                ])
-                let result = worker.call(function: Function.generate, with: [
-                    pumphistory,
-                    profile,
-                    clock,
-                    glucose,
-                    basalProfile,
-                    carbs
-                ])
-                continuation.resume(returning: result)
+        if useSwiftOref {
+            let swiftResult = OpenAPSSwift
+                .meal(
+                    pumphistory: pumphistory,
+                    profile: profile,
+                    basalProfile: basalProfile,
+                    clock: clock,
+                    carbs: carbs,
+                    glucose: glucose
+                )
+            return try swiftResult.returnOrThrow()
+        } else {
+            let jsResult = await mealJavascript(
+                pumphistory: pumphistory,
+                profile: profile,
+                basalProfile: basalProfile,
+                clock: clock,
+                carbs: carbs,
+                glucose: glucose
+            )
+            return try jsResult.returnOrThrow()
+        }
+    }
+
+    private func mealJavascript(
+        pumphistory: JSON,
+        profile: JSON,
+        basalProfile: JSON,
+        clock: JSON,
+        carbs: JSON,
+        glucose: JSON
+    ) async -> OrefFunctionResult {
+        do {
+            let result = try await withCheckedThrowingContinuation { continuation in
+                jsWorker.inCommonContext { worker in
+                    worker.evaluateBatch(scripts: [
+                        Script(name: Prepare.log),
+                        Script(name: Bundle.meal),
+                        Script(name: Prepare.meal)
+                    ])
+                    let result = worker.call(function: Function.generate, with: [
+                        pumphistory,
+                        profile,
+                        clock,
+                        glucose,
+                        basalProfile,
+                        carbs
+                    ])
+                    continuation.resume(returning: result)
+                }
             }
+            return .success(result)
+        } catch {
+            return .failure(error)
         }
     }
 
@@ -761,25 +829,64 @@ final class OpenAPS {
         basalprofile: JSON,
         profile: JSON,
         carbs: JSON,
-        temptargets: JSON
+        temptargets: JSON,
+        useSwiftOref: Bool
     ) async throws -> RawJSON {
-        try await withCheckedThrowingContinuation { continuation in
-            jsWorker.inCommonContext { worker in
-                worker.evaluateBatch(scripts: [
-                    Script(name: Prepare.log),
-                    Script(name: Bundle.autosens),
-                    Script(name: Prepare.autosens)
-                ])
-                let result = worker.call(function: Function.generate, with: [
-                    glucose,
-                    pumpHistory,
-                    basalprofile,
-                    profile,
-                    carbs,
-                    temptargets
-                ])
-                continuation.resume(returning: result)
+        if useSwiftOref {
+            let swiftResult = OpenAPSSwift
+                .autosense(
+                    glucose: glucose,
+                    pumpHistory: pumpHistory,
+                    basalProfile: basalprofile,
+                    profile: profile,
+                    carbs: carbs,
+                    tempTargets: temptargets,
+                    clock: Date()
+                )
+            return try swiftResult.returnOrThrow()
+        } else {
+            let jsResult = await autosenseJavascript(
+                glucose: glucose,
+                pumpHistory: pumpHistory,
+                basalprofile: basalprofile,
+                profile: profile,
+                carbs: carbs,
+                temptargets: temptargets
+            )
+            return try jsResult.returnOrThrow()
+        }
+    }
+
+    private func autosenseJavascript(
+        glucose: JSON,
+        pumpHistory: JSON,
+        basalprofile: JSON,
+        profile: JSON,
+        carbs: JSON,
+        temptargets: JSON
+    ) async -> OrefFunctionResult {
+        do {
+            let result = try await withCheckedThrowingContinuation { continuation in
+                jsWorker.inCommonContext { worker in
+                    worker.evaluateBatch(scripts: [
+                        Script(name: Prepare.log),
+                        Script(name: Bundle.autosens),
+                        Script(name: Prepare.autosens)
+                    ])
+                    let result = worker.call(function: Function.generate, with: [
+                        glucose,
+                        pumpHistory,
+                        basalprofile,
+                        profile,
+                        carbs,
+                        temptargets
+                    ])
+                    continuation.resume(returning: result)
+                }
             }
+            return .success(result)
+        } catch {
+            return .failure(error)
         }
     }
 
@@ -795,40 +902,96 @@ 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 result = worker.call(function: Function.generate, with: [
-                    iob,
-                    currentTemp,
-                    glucose,
-                    profile,
-                    autosens,
-                    meal,
-                    microBolusAllowed,
-                    reservoir,
-                    Date(),
-                    pumpHistory,
-                    preferences,
-                    basalProfile,
-                    trioCustomOrefVariables
-                ])
+        let clock = Date()
+
+        if useSwiftOref {
+            let swiftResult = 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
+            )
+            return try swiftResult.returnOrThrow()
+        } else {
+            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
+            )
+            return try jsResult.returnOrThrow()
+        }
+    }
 
-                continuation.resume(returning: result)
+    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)
+                    ])
+
+                    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)
         }
     }
 
@@ -844,6 +1007,48 @@ final class OpenAPS {
         }
     }
 
+    // use `internal` protection to expose to unit tests
+    func makeProfileJavascript(
+        preferences: JSON,
+        pumpSettings: JSON,
+        bgTargets: JSON,
+        basalProfile: JSON,
+        isf: JSON,
+        carbRatio: JSON,
+        tempTargets: JSON,
+        model: JSON,
+        autotune: JSON,
+        trioSettings: JSON
+    ) async -> OrefFunctionResult {
+        do {
+            let result = try await withCheckedThrowingContinuation { continuation in
+                jsWorker.inCommonContext { worker in
+                    worker.evaluateBatch(scripts: [
+                        Script(name: Prepare.log),
+                        Script(name: Bundle.profile),
+                        Script(name: Prepare.profile)
+                    ])
+                    let result = worker.call(function: Function.generate, with: [
+                        pumpSettings,
+                        bgTargets,
+                        isf,
+                        basalProfile,
+                        preferences,
+                        carbRatio,
+                        tempTargets,
+                        model,
+                        autotune,
+                        trioSettings
+                    ])
+                    continuation.resume(returning: result)
+                }
+            }
+            return .success(result)
+        } catch {
+            return .failure(error)
+        }
+    }
+
     private func makeProfile(
         preferences: JSON,
         pumpSettings: JSON,
@@ -854,29 +1059,38 @@ final class OpenAPS {
         tempTargets: JSON,
         model: JSON,
         autotune: JSON,
-        trioData: JSON
+        trioSettings: JSON,
+        useSwiftOref: Bool,
+        clock: Date
     ) async throws -> RawJSON {
-        try await withCheckedThrowingContinuation { continuation in
-            jsWorker.inCommonContext { worker in
-                worker.evaluateBatch(scripts: [
-                    Script(name: Prepare.log),
-                    Script(name: Bundle.profile),
-                    Script(name: Prepare.profile)
-                ])
-                let result = worker.call(function: Function.generate, with: [
-                    pumpSettings,
-                    bgTargets,
-                    isf,
-                    basalProfile,
-                    preferences,
-                    carbRatio,
-                    tempTargets,
-                    model,
-                    autotune,
-                    trioData
-                ])
-                continuation.resume(returning: result)
-            }
+        if useSwiftOref {
+            let swiftResult = OpenAPSSwift.makeProfile(
+                preferences: preferences,
+                pumpSettings: pumpSettings,
+                bgTargets: bgTargets,
+                basalProfile: basalProfile,
+                isf: isf,
+                carbRatio: carbRatio,
+                tempTargets: tempTargets,
+                model: model,
+                trioSettings: trioSettings,
+                clock: clock
+            )
+            return try swiftResult.returnOrThrow()
+        } else {
+            let jsResult = await makeProfileJavascript(
+                preferences: preferences,
+                pumpSettings: pumpSettings,
+                bgTargets: bgTargets,
+                basalProfile: basalProfile,
+                isf: isf,
+                carbRatio: carbRatio,
+                tempTargets: tempTargets,
+                model: model,
+                autotune: autotune,
+                trioSettings: trioSettings
+            )
+            return try jsResult.returnOrThrow()
         }
     }
 
@@ -897,23 +1111,6 @@ final class OpenAPS {
         }
     }
 
-    private func middlewareScript(name: String) -> Script? {
-        if let body = storage.retrieveRaw(name) {
-            return Script(name: name, body: body)
-        }
-
-        if let url = Foundation.Bundle.main.url(forResource: "javascript/\(name)", withExtension: "") {
-            do {
-                let body = try String(contentsOf: url)
-                return Script(name: name, body: body)
-            } catch {
-                debug(.openAPS, "Failed to load script \(name): \(error)")
-            }
-        }
-
-        return nil
-    }
-
     static func defaults(for file: String) -> RawJSON {
         let prefix = file.hasSuffix(".json") ? "json/defaults" : "javascript"
         guard let url = Foundation.Bundle.main.url(forResource: "\(prefix)/\(file)", withExtension: "") else {

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

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

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

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

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

@@ -0,0 +1,46 @@
+import Foundation
+
+enum DeterminationError: LocalizedError, Equatable {
+    case missingGlucoseStatus
+    case missingProfile
+    case missingCurrentBasal
+    case invalidProfileTarget
+    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 .missingCurrentBasal:
+            // string copied from JS
+            return String(localized: "Error: could not get current basal rate")
+        case .invalidProfileTarget:
+            // string copied from JS including trailing space
+            return String(localized: "Error: could not determine target_bg. ")
+        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.")
+        }
+    }
+}

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

@@ -0,0 +1,347 @@
+import Foundation
+
+extension DeterminationGenerator {
+    /// helper struct for managing glucose
+    private struct GlucoseReading {
+        let glucose: Int
+        let date: Date
+        let noise: Int?
+    }
+
+    /// 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)
+
+        let glucoseReadings = glucoseReadings.compactMap { reading -> GlucoseReading? in
+            guard let glucose = reading.glucose ?? reading.sgv else { return nil }
+            return GlucoseReading(glucose: glucose, date: reading.dateString, noise: reading.noise)
+        }
+
+        guard glucoseReadings.isNotEmpty else {
+            return nil
+        }
+
+        // Sort descending (newest first)
+        let sorted = glucoseReadings.sorted { $0.date > $1.date }
+
+        guard let mostRecentGlucose = sorted.first else { return nil }
+        var mostRecentGlucoseReading = Decimal(mostRecentGlucose.glucose)
+        var mostRecentGlucoseDate: Date = mostRecentGlucose.date
+
+        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 entry.glucose > 38 else { continue }
+
+            let minutesAgo = (mostRecentGlucoseDate.timeIntervalSince(entry.date) / 60).rounded()
+            // compute mg/dL per 5 m as a Decimal:
+            let change = mostRecentGlucoseReading - Decimal(entry.glucose)
+
+            // very-recent (<2.5 m) smooths "now"
+            if minutesAgo > -2, minutesAgo <= 2.5 {
+                mostRecentGlucoseReading = (mostRecentGlucoseReading + Decimal(entry.glucose)) / 2
+                mostRecentGlucoseDate = Date(
+                    timeIntervalSince1970: (
+                        mostRecentGlucoseDate.timeIntervalSince1970 + entry.date
+                            .timeIntervalSince1970
+                    ) / 2
+                )
+            }
+            // short window (~5–15 m)
+            else if minutesAgo > 2.5, minutesAgo <= 17.5 {
+                let avgDelta = (change / Decimal(minutesAgo)) * Decimal(5)
+                shortDeltas.append(avgDelta)
+                if minutesAgo < 7.5 {
+                    lastDeltas.append(avgDelta)
+                }
+            }
+            // long window (~20–40 m)
+            else if minutesAgo > 17.5, minutesAgo < 42.5 {
+                let avgDelta = (change / Decimal(minutesAgo)) * Decimal(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.jsRounded(scale: 2),
+            glucose: mostRecentGlucoseReading,
+            noise: Int(sorted[0].noise ?? 0),
+            shortAvgDelta: shortAvg.jsRounded(scale: 2),
+            longAvgDelta: longAvg.jsRounded(scale: 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 = Decimal((2 * 60) / 5)
+        let delta = targetGlucose - eventualGlucose
+        return (glucoseImpact + (delta / fiveMinuteBlocks)).jsRounded(scale: 1)
+    }
+
+    static func calculateSensitivityRatio(
+        currentGlucose: Decimal,
+        profile: Profile,
+        autosens: Autosens?,
+        targetGlucose: Decimal,
+        temptargetSet: Bool,
+        dynamicIsfResult: DynamicISFResult?
+    ) -> (Decimal, Bool) {
+        let normalTarget: Decimal = 100
+        let halfBasalTarget = profile.halfBasalExerciseTarget
+        var ratio: Decimal = 1
+        var updateAutosensRatio = false
+
+        // 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).jsRounded(scale: 2)
+        } else if let autosens = autosens {
+            // Use autosens if present
+            ratio = autosens.ratio
+        }
+
+        if let autosens = autosens {
+            // Increase the dynamic ratio when using a low temp target
+            if profile.temptargetSet == true, targetGlucose < normalTarget, let dynamicIsfResult = dynamicIsfResult,
+               currentGlucose >= targetGlucose
+            {
+                if ratio < dynamicIsfResult.ratio {
+                    ratio = dynamicIsfResult.ratio * (normalTarget / targetGlucose)
+                    // Use autosesns.max limit
+                    ratio = min(ratio, profile.autosensMax).jsRounded(scale: 2)
+                    updateAutosensRatio = true
+                }
+            }
+        }
+
+        return (ratio, updateAutosensRatio)
+    }
+
+    static func computeAdjustedBasal(
+        profile: Profile,
+        currentBasalRate: Decimal,
+        sensitivityRatio: Decimal,
+        overrideFactor: Decimal
+    ) -> Decimal {
+        let adjustedBasal = currentBasalRate * sensitivityRatio * overrideFactor
+        return TempBasalFunctions.roundBasal(profile: profile, basalRate: adjustedBasal)
+    }
+
+    static func computeAdjustedSensitivity(
+        sensitivity: Decimal,
+        sensitivityRatio: Decimal,
+        trioCustomOrefVariables: TrioCustomOrefVariables
+    ) -> Decimal {
+        let sensitivity = trioCustomOrefVariables.override(sensitivity: sensitivity)
+        guard sensitivityRatio != 1.0 else { return sensitivity.jsRounded(scale: 1) }
+        return (sensitivity / sensitivityRatio).jsRounded(scale: 1)
+    }
+
+    /// Checks if current temp basal matches last temp from IOB data.
+    /// Returns nil if check passes, or the failure reason string if it fails.
+    static func checkCurrentTempBasalRateSafety(
+        currentTemp: TempBasal,
+        lastTempTarget: IobResult.LastTemp?,
+        currentTime: Date
+    ) -> String? {
+        guard let lastTemp = lastTempTarget, let lastTempDate = lastTemp.timestamp,
+              let lastTempDuration = lastTemp.duration else { return nil }
+        // TODO: throw error for malformed IobResult? Can this be malformed?
+
+        let lastTempAge = Int((currentTime.timeIntervalSince(lastTempDate) / 60).rounded()) // in minutes
+//        let tempModulus = Int(lastTempAge + currentTemp.duration) % 30 // only used in JS as output; will leave it here for now
+
+        if let lastTempRate = lastTemp.rate, currentTemp.rate != lastTempRate, lastTempAge > 10, currentTemp.duration > 0 {
+            // Rates don't match and temp is old: cancel temp
+            return "Warning: currenttemp rate \(currentTemp.rate) != lastTemp rate \(lastTempRate) from pumphistory; canceling temp"
+        }
+        if currentTemp.duration > 0 {
+            let lastTempEnded = Decimal(lastTempAge) - lastTempDuration
+
+            if lastTempEnded > 5, lastTempAge > 10 {
+                // Last temp ended long ago but temp is running: cancel temp
+                return "Warning: currenttemp running but lastTemp from pumphistory ended \(lastTempEnded.jsRounded(scale: 2))m ago; canceling temp"
+            }
+        }
+
+        return nil
+    }
+
+    /// 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?,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        temptargetSet: Bool,
+        targetGlucose: Decimal,
+        minGlucose: Decimal,
+        maxGlucose: Decimal,
+        noise: Int
+    ) -> (targets: AdjustedGlucoseTargets, threshold: Decimal) {
+        var minGlucose = minGlucose
+        var maxGlucose = maxGlucose
+        var targetGlucose = targetGlucose
+
+        // Apply profile override first
+        if let overrideTarget = profile.profileTarget(trioCustomOrefVariables: trioCustomOrefVariables) {
+            targetGlucose = overrideTarget
+            minGlucose = overrideTarget
+            maxGlucose = overrideTarget
+        }
+
+        // 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).jsRounded()
+                maxGlucose = ((maxGlucose - 60) / autosens.ratio + 60).jsRounded()
+                targetGlucose = max(80, ((targetGlucose - 60) / autosens.ratio + 60).jsRounded())
+            }
+        }
+
+        // Raise target for noisy/CGM data
+        if noise >= 2 {
+            let noisyCGMTargetMultiplier = max(1.1, profile.noisyCGMTargetMultiplier)
+            minGlucose = min(200, minGlucose * noisyCGMTargetMultiplier).jsRounded()
+            targetGlucose = min(200, targetGlucose * noisyCGMTargetMultiplier).jsRounded()
+            maxGlucose = min(200, maxGlucose * noisyCGMTargetMultiplier).jsRounded()
+        }
+
+        // Calculate threshold: minGlucose thresholds: 80->60, 90->65, etc.
+        var threshold = minGlucose - 0.5 * (minGlucose - 40)
+        threshold = min(max(profile.thresholdSetting, threshold, 60), 120)
+
+        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 { -$0.iobWithZeroTemp.activity * sensitivity * 5 }
+        } else {
+            return iobDataSeries.map { -$0.activity * sensitivity * 5 }
+        }
+    }
+}
+
+extension Profile {
+    /// This function calculates the `profileTarget` variable from Javascript's determineBasal function
+    /// including the adjustments for overrides
+    func profileTarget(trioCustomOrefVariables: TrioCustomOrefVariables) -> Decimal? {
+        let overrideTarget = trioCustomOrefVariables.overrideTarget
+        if overrideTarget != 0, overrideTarget != 6, trioCustomOrefVariables
+            .useOverride, !(temptargetSet ?? false)
+        {
+            return overrideTarget
+        }
+
+        return minBg
+    }
+
+    /// Calculates the profile ISF at this point in time and applies any overrides to it
+    /// This is `sensitivity` in JS
+    func profileSensitivity(at: Date, trioCustomOrefVaribales: TrioCustomOrefVariables) -> Decimal {
+        let sensitivity = sens ?? sensitivityFor(time: at)
+        return trioCustomOrefVaribales.override(sensitivity: sensitivity)
+    }
+}
+
+extension TrioCustomOrefVariables {
+    func overrideFactor() -> Decimal {
+        guard useOverride else { return 1 }
+        return overridePercentage / 100
+    }
+
+    func override(sensitivity: Decimal) -> Decimal {
+        if useOverride {
+            let overrideFactor = overridePercentage / 100
+            if isfAndCr || isf {
+                return sensitivity / overrideFactor
+            } else {
+                return sensitivity
+            }
+        } else {
+            return sensitivity
+        }
+    }
+}
+
+extension Date {
+    /// Formats date like JavaScript's Date.toString()
+    /// Example: "Sat Nov 22 2025 14:22:58 GMT-0700 (Mountain Standard Time)"
+    func jsDateString() -> String {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "EEE MMM dd yyyy HH:mm:ss"
+        formatter.locale = Locale(identifier: "en_US_POSIX")
+
+        let dateString = formatter.string(from: self)
+
+        // Get GMT offset string like "GMT-0700"
+        let seconds = TimeZone.current.secondsFromGMT(for: self)
+        let hours = abs(seconds) / 3600
+        let minutes = (abs(seconds) % 3600) / 60
+        let sign = seconds >= 0 ? "+" : "-"
+        let gmtOffset = String(format: "GMT%@%02d%02d", sign, hours, minutes)
+
+        // Get timezone name like "Mountain Standard Time" or "Mountain Daylight Time"
+        let style: TimeZone.NameStyle = TimeZone.current.isDaylightSavingTime(for: self) ? .daylightSaving : .standard
+        let timezoneName = TimeZone.current.localizedName(for: style, locale: Locale(identifier: "en_US")) ?? TimeZone.current
+            .identifier
+
+        return "\(dateString) \(gmtOffset) (\(timezoneName))"
+    }
+}

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

@@ -0,0 +1,737 @@
+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 ?
+
+    /// Top-level determination generator, callers should use this function
+    static func generate(
+        profile: Profile,
+        preferences: Preferences,
+        currentTemp: TempBasal,
+        iobData: [IobResult],
+        mealData: ComputedCarbs,
+        autosensData: Autosens,
+        reservoirData: Decimal,
+        glucose: [BloodGlucose],
+        microBolusAllowed: Bool,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        currentTime: Date
+    ) throws -> Determination? {
+        let glucoseStatus = try Self.getGlucoseStatus(glucoseReadings: glucose)
+        guard let glucoseStatus = glucoseStatus else { throw DeterminationError.missingInputs }
+        return try determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            microBolusAllowed: microBolusAllowed,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+    }
+
+    /// Internal function to implement the core determine basal logic. We have a separate function
+    /// from `generate` so that we can pass GlucoseStatus values directly into the function
+    /// for testing.
+    static func determineBasal(
+        profile: Profile,
+        preferences: Preferences,
+        currentTemp: TempBasal,
+        iobData: [IobResult],
+        mealData: ComputedCarbs,
+        autosensData: Autosens,
+        reservoirData _: Decimal,
+        glucoseStatus: GlucoseStatus,
+        microBolusAllowed: Bool,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        currentTime: Date
+    ) throws -> Determination? {
+        var autosensData = autosensData
+
+        try checkDeterminationInputs(
+            glucoseStatus: glucoseStatus,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            profile: profile,
+            trioCustomOrefVariables: trioCustomOrefVariables
+        )
+
+        let currentGlucose: Decimal = glucoseStatus.glucose
+
+        if let errorDetermination = try handleTempBasalCases(
+            glucoseStatus: glucoseStatus,
+            profile: profile,
+            currentTemp: currentTemp,
+            currentTime: currentTime,
+            trioCustomOrefVariables: trioCustomOrefVariables
+        ) {
+            return errorDetermination
+        }
+
+        // Safety check: current temp vs. last temp in iob
+        guard let lastTempTarget = iobData.first?.lastTemp else {
+            throw DeterminationError.missingIob
+        }
+        if let reason = checkCurrentTempBasalRateSafety(
+            currentTemp: currentTemp,
+            lastTempTarget: lastTempTarget,
+            currentTime: currentTime
+        ) {
+            return Determination(
+                id: UUID(),
+                reason: reason,
+                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: nil,
+                reservoir: nil,
+                isf: nil,
+                timestamp: nil,
+                tdd: nil,
+                current_target: nil,
+                minDelta: nil,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: nil,
+                received: false
+            )
+        }
+
+        let dynamicIsfResult = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: currentGlucose,
+            trioCustomOrefVariables: trioCustomOrefVariables
+        )
+
+        if let dynamicIsfResult = dynamicIsfResult {
+            autosensData = Autosens(
+                ratio: dynamicIsfResult.ratio,
+                newisf: autosensData.newisf,
+                deviationsUnsorted: autosensData.deviationsUnsorted,
+                timestamp: autosensData.timestamp
+            )
+        }
+        let (sensitivityRatio, updateAutosensRatio) = calculateSensitivityRatio(
+            currentGlucose: currentGlucose,
+            profile: profile,
+            autosens: autosensData,
+            targetGlucose: profile.profileTarget(trioCustomOrefVariables: trioCustomOrefVariables) ?? 120,
+            temptargetSet: profile.temptargetSet ?? false,
+            dynamicIsfResult: dynamicIsfResult
+        )
+        if updateAutosensRatio {
+            autosensData = Autosens(
+                ratio: sensitivityRatio,
+                newisf: autosensData.newisf,
+                deviationsUnsorted: autosensData.deviationsUnsorted,
+                timestamp: autosensData.timestamp
+            )
+        }
+
+        var basal = profile.currentBasal ?? profile.basalFor(time: currentTime)
+        basal *= trioCustomOrefVariables.overrideFactor()
+        if dynamicIsfResult == nil {
+            basal = computeAdjustedBasal(
+                profile: profile,
+                currentBasalRate: profile.currentBasal ?? profile.basalFor(time: currentTime),
+                sensitivityRatio: sensitivityRatio,
+                overrideFactor: trioCustomOrefVariables.overrideFactor()
+            )
+        } else if let dynamicIsfResult = dynamicIsfResult, profile.tddAdjBasal {
+            basal = computeAdjustedBasal(
+                profile: profile,
+                currentBasalRate: profile.currentBasal ?? profile.basalFor(time: currentTime),
+                sensitivityRatio: dynamicIsfResult.tddRatio,
+                overrideFactor: trioCustomOrefVariables.overrideFactor()
+            )
+        }
+
+        // this is the `sens` variable in JS, it's the adjusted sensitivity
+        let adjustedSensitivity = computeAdjustedSensitivity(
+            sensitivity: profile.sens ?? profile.sensitivityFor(time: currentTime),
+            sensitivityRatio: sensitivityRatio,
+            trioCustomOrefVariables: trioCustomOrefVariables
+        )
+
+        let (adjustedGlucoseTargets, threshold) = adjustGlucoseTargets(
+            profile: profile,
+            autosens: autosensData,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            temptargetSet: profile.temptargetSet ?? false,
+            targetGlucose: profile.minBg ?? 100,
+            minGlucose: profile.minBg ?? 70, // TODO: can we force unwrap?
+            maxGlucose: profile.maxBg ?? 180,
+            noise: 1
+        )
+
+        let glucoseImpactSeries = buildGlucoseImpactSeries(iobDataSeries: iobData, sensitivity: adjustedSensitivity)
+        let glucoseImpactSeriesWithZeroTemp = buildGlucoseImpactSeries(
+            iobDataSeries: iobData,
+            sensitivity: adjustedSensitivity,
+            withZeroTemp: true
+        )
+
+        guard let currentGlucoseImpact = glucoseImpactSeries.first?.jsRounded(scale: 2) else {
+            throw DeterminationError.determinationError
+        }
+
+        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)).jsRounded()
+        if deviation < 0 {
+            deviation = (intervals * (minAvgDelta - currentGlucoseImpact)).jsRounded()
+            if deviation < 0 {
+                deviation = (intervals * (longAvgDelta - currentGlucoseImpact)).jsRounded()
+            }
+        }
+
+        // Calculate what oref calls "naive eventual glucose"
+        guard let currentIob = iobData.first?.iob else {
+            throw DeterminationError.missingIob
+        }
+
+        let naiveEventualGlucose: Decimal
+        if currentIob > 0 {
+            naiveEventualGlucose = (currentGlucose - (currentIob * adjustedSensitivity)).jsRounded()
+        } else {
+            naiveEventualGlucose =
+                (
+                    currentGlucose -
+                        (
+                            currentIob *
+                                min(
+                                    profile.profileSensitivity(at: currentTime, trioCustomOrefVaribales: trioCustomOrefVariables),
+
+                                    adjustedSensitivity
+                                )
+                        )
+                )
+                .jsRounded()
+        }
+
+        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: adjustedSensitivity, deviation: deviation)
+        }
+
+        let forecastResult = ForecastGenerator.generate(
+            glucose: currentGlucose,
+            glucoseStatus: glucoseStatus,
+            currentGlucoseImpact: currentGlucoseImpact,
+            glucoseImpactSeries: glucoseImpactSeries,
+            glucoseImpactSeriesWithZeroTemp: glucoseImpactSeriesWithZeroTemp,
+            iobData: iobData,
+            mealData: mealData,
+            profile: profile,
+            preferences: preferences,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            dynamicIsfResult: dynamicIsfResult,
+            targetGlucose: adjustedGlucoseTargets.targetGlucose,
+            adjustedSensitivity: adjustedSensitivity,
+            sensitivityRatio: sensitivityRatio,
+            naiveEventualGlucose: naiveEventualGlucose,
+            eventualGlucose: eventualGlucose,
+            threshold: threshold,
+            currentTime: currentTime
+        )
+
+        // used for pre dosing decision sanity later on
+        let expectedDelta = calculateExpectedDelta(
+            targetGlucose: adjustedGlucoseTargets.targetGlucose,
+            eventualGlucose: eventualGlucose,
+            glucoseImpact: currentGlucoseImpact
+        )
+
+        // Build isfReason: "Autosens ratio: X, ISF: Y→Z"
+        let originalSensitivity = profile.profileSensitivity(at: currentTime, trioCustomOrefVaribales: trioCustomOrefVariables)
+        let isfReason =
+            "Autosens ratio: \(sensitivityRatio.jsRounded(scale: 2)), ISF: \(originalSensitivity.jsRounded())→\(adjustedSensitivity.jsRounded())"
+
+        // Build targetLog: "X" or "X→Y" or "X→Y→Z" if target was adjusted
+        let profileTarget = profile.profileTarget(trioCustomOrefVariables: trioCustomOrefVariables) ?? 100
+        let overrideTarget = trioCustomOrefVariables.overrideTarget
+        let targetLog: String
+        if adjustedGlucoseTargets.targetGlucose != profileTarget {
+            // Include overrideTarget in the middle if it's set and different from final target
+            if overrideTarget != 0, overrideTarget != 6, overrideTarget != adjustedGlucoseTargets.targetGlucose {
+                targetLog =
+                    "\(profileTarget.jsRounded())→\(overrideTarget.jsRounded())→\(adjustedGlucoseTargets.targetGlucose.jsRounded())"
+            } else {
+                targetLog = "\(profileTarget.jsRounded())→\(adjustedGlucoseTargets.targetGlucose.jsRounded())"
+            }
+        } else {
+            targetLog = "\(adjustedGlucoseTargets.targetGlucose.jsRounded())"
+        }
+
+        // Build tddReason: ", Dynamic ISF: On, Sigmoid function, AF: X, Basal ratio: Y, SMB Ratio: Z"
+        var tddReason = ""
+        if let dynamicIsfResult = dynamicIsfResult {
+            tddReason = ", Dynamic ISF: On"
+            if preferences.sigmoid {
+                tddReason += ", Sigmoid function"
+            } else {
+                tddReason += ", Logarithmic formula"
+            }
+            if let limitValue = dynamicIsfResult.limitValue {
+                tddReason +=
+                    ", Autosens/Dynamic Limit: \(limitValue) (\(dynamicIsfResult.uncappedRatio.jsRounded(scale: 2)))"
+            }
+            let af = preferences.sigmoid ? preferences.adjustmentFactorSigmoid : preferences.adjustmentFactor
+            tddReason += ", AF: \(af)"
+            if profile.tddAdjBasal {
+                tddReason += ", Basal ratio: \(dynamicIsfResult.tddRatio)"
+            }
+        }
+        // SMB Ratio is added if not default (0.5)
+        if profile.smbDeliveryRatio != 0.5 {
+            tddReason += ", SMB Ratio: \(min(profile.smbDeliveryRatio, 1))"
+        }
+
+        let dosingInputs = DosingEngine.prepareDosingInputs(
+            profile: profile,
+            mealData: mealData,
+            forecast: forecastResult,
+            naiveEventualGlucose: naiveEventualGlucose,
+            threshold: threshold,
+            glucoseImpact: currentGlucoseImpact,
+            deviation: deviation,
+            currentBasal: profile.currentBasal ?? profile.basalFor(time: currentTime),
+            overrideFactor: trioCustomOrefVariables.overrideFactor(),
+            adjustedSensitivity: adjustedSensitivity,
+            isfReason: isfReason,
+            tddReason: tddReason,
+            targetLog: targetLog
+        )
+
+        let smbDecision = try DosingEngine.makeSMBDosingDecision(
+            profile: profile,
+            meal: mealData,
+            currentGlucose: currentGlucose,
+            adjustedTargetGlucose: adjustedGlucoseTargets.targetGlucose,
+            minGuardGlucose: forecastResult.minGuardGlucose,
+            threshold: threshold,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            clock: currentTime
+        )
+
+        let smbIsEnabled = smbDecision.isEnabled
+        var reason = dosingInputs.reason
+        if let smbReason = smbDecision.reason {
+            reason += smbReason
+        }
+        // Add carbs message after smbReason to match JS order
+        if let carbsReq = dosingInputs.carbsRequired {
+            reason += "\(carbsReq.carbs) add'l carbs req w/in \(carbsReq.minutes)m; "
+        }
+
+        var determination = Determination(
+            id: UUID(),
+            reason: reason,
+            units: nil,
+            insulinReq: 0,
+            eventualBG: Int(forecastResult.eventualGlucose.jsRounded()),
+            sensitivityRatio: sensitivityRatio, // this would only the AS-adjusted one for now
+            rate: nil,
+            duration: nil,
+            iob: iobData.first?.iob,
+            cob: mealData.mealCOB.jsRounded(),
+            predictions: Predictions(
+                iob: forecastResult.iob.map { Int($0.jsRounded()) },
+                zt: forecastResult.zt.map { Int($0.jsRounded()) },
+                cob: forecastResult.cob?.map { Int($0.jsRounded()) },
+                uam: forecastResult.uam?.map { Int($0.jsRounded()) }
+            ),
+            deliverAt: currentTime,
+            carbsReq: dosingInputs.carbsRequired?.carbs,
+            temp: nil,
+            bg: currentGlucose,
+            reservoir: nil,
+            isf: nil,
+            timestamp: currentTime,
+            tdd: nil,
+            current_target: adjustedGlucoseTargets.targetGlucose,
+            minDelta: nil,
+            expectedDelta: expectedDelta,
+            minGuardBG: smbDecision.minGuardGlucose ?? forecastResult.minGuardGlucose,
+            minPredBG: forecastResult.minForecastedGlucose,
+            threshold: threshold.jsRounded(),
+            carbRatio: forecastResult.adjustedCarbRatio.jsRounded(scale: 1),
+            received: false
+        )
+
+        // MARK: - Core dosing logic
+
+        let (shouldSetTempBasalForLowGlucoseSuspend, lowGlucoseSuspendDetermination) = try DosingEngine.lowGlucoseSuspend(
+            currentGlucose: currentGlucose,
+            minGuardGlucose: forecastResult.minGuardGlucose,
+            iob: currentIob,
+            minDelta: minDelta,
+            expectedDelta: expectedDelta,
+            threshold: threshold,
+            overrideFactor: trioCustomOrefVariables.overrideFactor(),
+            profile: profile,
+            adjustedSensitivity: adjustedSensitivity,
+            targetGlucose: adjustedGlucoseTargets.targetGlucose,
+            currentTemp: currentTemp,
+            determination: determination
+        )
+        determination = lowGlucoseSuspendDetermination
+        if shouldSetTempBasalForLowGlucoseSuspend {
+            return determination
+        }
+
+        let (shouldSetTempBasalForSkipNeutralTemp, skipNeutralTempDetermination) = try DosingEngine.skipNeutralTempBasal(
+            smbIsEnabled: smbIsEnabled,
+            profile: profile,
+            clock: currentTime,
+            currentTemp: currentTemp,
+            determination: determination
+        )
+        determination = skipNeutralTempDetermination
+        if shouldSetTempBasalForSkipNeutralTemp {
+            return determination
+        }
+
+        let (shouldSetTempBasalForLowEventualGlucose, lowEventualGlucoseDetermination) = try DosingEngine
+            .handleLowEventualGlucose(
+                eventualGlucose: forecastResult.eventualGlucose,
+                minGlucose: adjustedGlucoseTargets.minGlucose,
+                targetGlucose: adjustedGlucoseTargets.targetGlucose,
+                minDelta: minDelta,
+                expectedDelta: expectedDelta,
+                carbsRequired: dosingInputs.rawCarbsRequired,
+                naiveEventualGlucose: naiveEventualGlucose,
+                glucoseStatus: glucoseStatus,
+                currentTemp: currentTemp,
+                basal: basal,
+                profile: profile,
+                determination: determination,
+                adjustedSensitivity: adjustedSensitivity,
+                overrideFactor: trioCustomOrefVariables.overrideFactor()
+            )
+        determination = lowEventualGlucoseDetermination
+        if shouldSetTempBasalForLowEventualGlucose {
+            return determination
+        }
+
+        let (
+            shouldSetTempBasalForGlucoseFallingFasterThanExpected,
+            glucoseFallingFasterThanExpectedDetermination
+        ) = try DosingEngine.glucoseFallingFasterThanExpected(
+            eventualGlucose: forecastResult.eventualGlucose,
+            minGlucose: adjustedGlucoseTargets.minGlucose,
+            minDelta: minDelta,
+            expectedDelta: expectedDelta,
+            glucoseStatus: glucoseStatus,
+            currentTemp: currentTemp,
+            basal: basal,
+            smbIsEnabled: smbIsEnabled,
+            profile: profile,
+            determination: determination
+        )
+        determination = glucoseFallingFasterThanExpectedDetermination
+        if shouldSetTempBasalForGlucoseFallingFasterThanExpected {
+            return determination
+        }
+
+        let (
+            shouldSetTempBasalEventualOrForecastGlucoseLessThanMax,
+            eventualOrForecastGlucoseLessThanMaxDetermination
+        ) = try DosingEngine.eventualOrForecastGlucoseLessThanMax(
+            eventualGlucose: forecastResult.eventualGlucose,
+            maxGlucose: adjustedGlucoseTargets.maxGlucose,
+            minForecastGlucose: forecastResult.minForecastedGlucose,
+            currentTemp: currentTemp,
+            basal: basal,
+            smbIsEnabled: smbIsEnabled,
+            profile: profile,
+            determination: determination
+        )
+        determination = eventualOrForecastGlucoseLessThanMaxDetermination
+        if shouldSetTempBasalEventualOrForecastGlucoseLessThanMax {
+            return determination
+        }
+
+        if forecastResult.eventualGlucose >= adjustedGlucoseTargets.maxGlucose {
+            determination
+                .reason +=
+                "Eventual BG \(DosingEngine.convertGlucose(profile: profile, glucose: forecastResult.eventualGlucose)) >= \(DosingEngine.convertGlucose(profile: profile, glucose: adjustedGlucoseTargets.maxGlucose)), "
+        }
+
+        let (shouldSetTempBasalForIobGreaterThanMax, iobGreaterThanMaxDetermination) = try DosingEngine.iobGreaterThanMax(
+            iob: currentIob,
+            maxIob: profile.maxIob,
+            currentTemp: currentTemp,
+            basal: basal,
+            profile: profile,
+            determination: determination
+        )
+        determination = iobGreaterThanMaxDetermination
+        if shouldSetTempBasalForIobGreaterThanMax {
+            return determination
+        }
+
+        // MARK: - Aggressive dosing logic (SMB, High Temps)
+
+        // Calculate Insulin Required
+        let (insulinRequired, insulinReqDetermination) = DosingEngine.calculateInsulinRequired(
+            minForecastGlucose: forecastResult.minForecastedGlucose,
+            eventualGlucose: forecastResult.eventualGlucose,
+            targetGlucose: adjustedGlucoseTargets.targetGlucose,
+            adjustedSensitivity: adjustedSensitivity,
+            maxIob: profile.maxIob,
+            currentIob: currentIob,
+            determination: determination
+        )
+        determination = insulinReqDetermination
+
+        // SMB Delivery
+        let (shouldSetTempBasalForSMB, smbDetermination) = try DosingEngine.determineSMBDelivery(
+            insulinRequired: insulinRequired,
+            microBolusAllowed: microBolusAllowed,
+            smbIsEnabled: smbIsEnabled,
+            currentGlucose: currentGlucose,
+            threshold: threshold,
+            profile: profile,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            mealData: mealData,
+            iobData: iobData,
+            currentTime: currentTime,
+            targetGlucose: adjustedGlucoseTargets.targetGlucose,
+            naiveEventualGlucose: naiveEventualGlucose,
+            minIOBForecastedGlucose: forecastResult.minIOBForecastedGlucose,
+            adjustedSensitivity: adjustedSensitivity,
+            adjustedCarbRatio: forecastResult.adjustedCarbRatio,
+            basal: basal,
+            determination: determination
+        )
+        determination = smbDetermination
+        if shouldSetTempBasalForSMB {
+            return determination
+        }
+
+        // High Temp Basal (Fallback)
+        return try DosingEngine.determineHighTempBasal(
+            insulinRequired: insulinRequired,
+            basal: basal,
+            profile: profile,
+            currentTemp: currentTemp,
+            determination: determination
+        )
+    }
+
+    static func checkDeterminationInputs(
+        glucoseStatus: GlucoseStatus?,
+        currentTemp _: TempBasal?,
+        iobData: [IobResult]?,
+        profile: Profile?,
+        trioCustomOrefVariables: TrioCustomOrefVariables
+    ) throws {
+        guard let glucoseStatus = glucoseStatus else {
+            throw DeterminationError.missingGlucoseStatus
+        }
+        guard let profile = profile else {
+            throw DeterminationError.missingProfile
+        }
+        guard profile.profileTarget(trioCustomOrefVariables: trioCustomOrefVariables) != nil else {
+            throw DeterminationError.invalidProfileTarget
+        }
+        // we have to allow 38 values so that we can cancel high temps
+        if glucoseStatus.glucose < 38 || glucoseStatus.glucose > 600 {
+            throw DeterminationError.glucoseOutOfRange(glucose: glucoseStatus.glucose)
+        }
+        guard let _ = iobData else {
+            throw DeterminationError.missingIob
+        }
+    }
+
+    static func handleTempBasalCases(
+        glucoseStatus: GlucoseStatus,
+        profile: Profile,
+        currentTemp: TempBasal?,
+        currentTime: Date,
+        trioCustomOrefVariables: TrioCustomOrefVariables
+    ) throws -> 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
+        guard let profileBasal = profile.currentBasal else {
+            throw DeterminationError.missingCurrentBasal
+        }
+        let basal = profileBasal * trioCustomOrefVariables.overrideFactor()
+
+        // 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 - can overwrite calibration reason (matches JS)
+        if minAgo > 12 || minAgo < -5 {
+            reason =
+                "If current system time \(currentTime.jsDateString()) is correct, then BG data is too old. The last BG data was read \(minAgo.jsRounded(scale: 1))m ago at \(bgTime.jsDateString())"
+        } else if shortAvgDelta == 0 && longAvgDelta == 0 {
+            // CGM data unchanged (flat) - only checked if BG is not too old
+            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
+
+        // === IF ERROR, CANCEL/SHORTEN TEMPS ===
+        guard errorDetected, let currentTemp = currentTemp else { return nil }
+
+        if currentTemp.rate >= basal { // high temp is running
+            // Replace high temp with neutral temp at scheduled basal rate for 30min
+            let reasonWithAction = reason +
+                ". Replacing high temp basal of \(currentTemp.rate) with neutral temp of \(basal)"
+            return Determination(
+                id: UUID(),
+                reason: reasonWithAction,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: basal,
+                duration: 30,
+                iob: nil,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: .absolute,
+                bg: nil,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: nil,
+                minDelta: minDelta,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: nil,
+                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: nil,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: nil,
+                minDelta: minDelta,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: nil,
+                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: nil,
+                duration: nil,
+                iob: nil,
+                cob: nil,
+                predictions: nil,
+                deliverAt: nil,
+                carbsReq: nil,
+                temp: currentTemp.temp,
+                bg: nil,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: nil,
+                minDelta: minDelta,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: nil,
+                received: false
+            )
+        }
+    }
+}

+ 926 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DosingEngine.swift

@@ -0,0 +1,926 @@
+import Foundation
+
+enum DosingEngine {
+    struct DosingInputs {
+        let reason: String
+        let carbsRequired: (carbs: Decimal, minutes: Decimal)?
+        let rawCarbsRequired: Decimal
+    }
+
+    /// struct to keep the relevant state needed for the output of the SMB decision logic
+    struct SMBDecision {
+        let isEnabled: Bool
+        let minGuardGlucose: Decimal?
+        let reason: String?
+    }
+
+    /// checks to see if SMB are enabled via the profile
+    private static func isProfileSmbEnabled(
+        currentGlucose: Decimal,
+        adjustedTargetGlucose: Decimal,
+        profile: Profile,
+        meal: ComputedCarbs,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        clock: Date
+    ) throws -> Bool {
+        if trioCustomOrefVariables.smbIsOff {
+            return false
+        }
+
+        if try isSmbScheduledOff(trioCustomOrefVariables: trioCustomOrefVariables, clock: clock) {
+            return false
+        }
+
+        if !profile.allowSMBWithHighTemptarget, profile.temptargetSet == true, adjustedTargetGlucose > 100 {
+            return false
+        }
+
+        if profile.enableSMBAlways {
+            return true
+        }
+
+        if profile.enableSMBWithCOB, meal.mealCOB > 0 {
+            return true
+        }
+
+        if profile.enableSMBAfterCarbs, meal.carbs > 0 {
+            return true
+        }
+
+        if profile.enableSMBWithTemptarget, profile.temptargetSet == true, adjustedTargetGlucose < 100 {
+            return true
+        }
+
+        if profile.enableSMBHighBg, currentGlucose >= profile.enableSMBHighBgTarget {
+            return true
+        }
+
+        return false
+    }
+
+    /// helper function to check if SMB is scheduled off given the current timezone
+    private static func isSmbScheduledOff(trioCustomOrefVariables: TrioCustomOrefVariables, clock: Date) throws -> Bool {
+        guard trioCustomOrefVariables.smbIsScheduledOff else {
+            return false
+        }
+
+        guard let currentHour = clock.hourInLocalTime.map({ Decimal($0) }) else {
+            throw CalendarError.invalidCalendarHourOnly
+        }
+        let startHour = trioCustomOrefVariables.start
+        let endHour = trioCustomOrefVariables.end
+
+        // SMBs will be disabled from [start, end) local time
+        if startHour < endHour, currentHour >= startHour && currentHour < endHour {
+            // disable when the schedule does not wrap around midnight
+            return true
+        } else if startHour > endHour, currentHour >= startHour || currentHour < endHour {
+            // disable when the schedule does wrap around midnight
+            return true
+        } else if startHour == 0, endHour == 0 {
+            // schedule specifies the entire day
+            return true
+        } else if startHour == endHour, currentHour == startHour {
+            // one hour of scheduled off SMB
+            return true
+        }
+
+        return false
+    }
+
+    /// helper function for reason string glucose output
+    static func convertGlucose(profile: Profile, glucose: Decimal) -> Decimal {
+        let units = profile.outUnits ?? .mgdL
+        switch units {
+        case .mgdL: return glucose.jsRounded()
+        case .mmolL: return glucose.asMmolL
+        }
+    }
+
+    /// Top level smb enabling logic
+    ///
+    /// This function includes both the profile / customOrefVariable checks from JS `enable_smb` as
+    /// well as some of the later checks from `determineBasal` that can disable SMB
+    static func makeSMBDosingDecision(
+        profile: Profile,
+        meal: ComputedCarbs,
+        currentGlucose: Decimal,
+        adjustedTargetGlucose: Decimal,
+        minGuardGlucose: Decimal,
+        threshold: Decimal,
+        glucoseStatus: GlucoseStatus,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        clock: Date
+    ) throws -> SMBDecision {
+        var smbIsEnabled = try isProfileSmbEnabled(
+            currentGlucose: currentGlucose,
+            adjustedTargetGlucose: adjustedTargetGlucose,
+            profile: profile,
+            meal: meal,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            clock: clock
+        )
+
+        // these last two checks are implemented outside of the core enable_smb
+        // function in JS but we should keep all of the smb enabling logic
+        // in one place. Note: We can't shortcut the return value because
+        // the determineBasal logic always evaluates this logic
+        var minGuardGlucoseDecision: Decimal?
+        var reason: String?
+        if smbIsEnabled, minGuardGlucose < threshold {
+            minGuardGlucoseDecision = minGuardGlucose
+            smbIsEnabled = false
+        }
+
+        let maxDeltaGlucoseThreshold = min(profile.maxDeltaBgThreshold, 0.4)
+        if glucoseStatus.maxDelta > maxDeltaGlucoseThreshold * currentGlucose {
+            reason =
+                "maxDelta \(convertGlucose(profile: profile, glucose: glucoseStatus.maxDelta)) > \(100 * maxDeltaGlucoseThreshold)% of BG \(convertGlucose(profile: profile, glucose: currentGlucose)) - SMB disabled!, "
+            smbIsEnabled = false
+        }
+
+        return SMBDecision(
+            isEnabled: smbIsEnabled,
+            minGuardGlucose: minGuardGlucoseDecision,
+            reason: reason
+        )
+    }
+
+    static func prepareDosingInputs(
+        profile: Profile,
+        mealData: ComputedCarbs,
+        forecast: ForecastResult,
+        naiveEventualGlucose: Decimal,
+        threshold: Decimal,
+        glucoseImpact: Decimal,
+        deviation: Decimal,
+        currentBasal: Decimal,
+        overrideFactor: Decimal,
+        adjustedSensitivity: Decimal,
+        isfReason: String,
+        tddReason: String,
+        targetLog: String // This is a pre-formatted string from the JS
+    ) -> DosingInputs {
+        let lastIOBpredBG = (forecast.iob.last ?? 0).jsRounded()
+        let lastCOBpredBG = forecast.cob?.last?.jsRounded()
+        let lastUAMpredBG = forecast.uam?.last?.jsRounded()
+
+        var reason =
+            "\(isfReason), COB: \(mealData.mealCOB.jsRounded()), Dev: \(deviation.jsRounded()), BGI: \(glucoseImpact.jsRounded()), CR: \(forecast.adjustedCarbRatio.jsRounded(scale: 1)), Target: \(targetLog), minPredBG \(forecast.minForecastedGlucose.jsRounded()), minGuardBG \(forecast.minGuardGlucose.jsRounded()), IOBpredBG \(lastIOBpredBG)"
+
+        if let lastCOB = lastCOBpredBG {
+            reason += ", COBpredBG \(lastCOB)"
+        }
+        if let lastUAM = lastUAMpredBG {
+            reason += ", UAMpredBG \(lastUAM)"
+        }
+        reason += tddReason
+        reason += "; " // Start of conclusion
+
+        let carbsRequiredResult = calculateCarbsRequired(
+            mealData: mealData,
+            naiveEventualGlucose: naiveEventualGlucose,
+            minGuardGlucose: forecast.minGuardGlucose,
+            threshold: threshold,
+            iobForecast: forecast.iob,
+            cobForecast: forecast.internalCob,
+            carbImpact: forecast.carbImpact,
+            remainingCarbImpactPeak: forecast.remainingCarbImpactPeak,
+            currentBasal: currentBasal,
+            overrideFactor: overrideFactor,
+            adjustedSensitivity: adjustedSensitivity,
+            adjustedCarbRatio: forecast.adjustedCarbRatio
+        )
+
+        var carbsRequired: (carbs: Decimal, minutes: Decimal)?
+        if carbsRequiredResult.carbs >= profile.carbsReqThreshold, carbsRequiredResult.minutes <= 45 {
+            // Note: carbs message is added in DetermineBasalGenerator after smbReason to match JS order
+            carbsRequired = carbsRequiredResult
+        }
+
+        return DosingInputs(reason: reason, carbsRequired: carbsRequired, rawCarbsRequired: carbsRequiredResult.carbs)
+    }
+
+    /// Calculates the carbohydrates required to avoid a potential hypoglycemic event.
+    ///
+    /// - Returns: A tuple containing the required carbs and minutes until glucose is below threshold.
+    static func calculateCarbsRequired(
+        mealData: ComputedCarbs,
+        naiveEventualGlucose: Decimal,
+        minGuardGlucose: Decimal,
+        threshold: Decimal,
+        iobForecast: [Decimal],
+        cobForecast: [Decimal],
+        carbImpact: Decimal,
+        remainingCarbImpactPeak: Decimal,
+        currentBasal: Decimal,
+        overrideFactor: Decimal,
+        adjustedSensitivity: Decimal,
+        adjustedCarbRatio: Decimal
+    ) -> (carbs: Decimal, minutes: Decimal) {
+        var carbsRequiredGlucose = naiveEventualGlucose
+        if naiveEventualGlucose < 40 {
+            carbsRequiredGlucose = min(minGuardGlucose, naiveEventualGlucose)
+        }
+
+        let glucoseUndershoot = threshold - carbsRequiredGlucose
+
+        var minutesAboveThreshold = Decimal(240)
+
+        let useCOBForecast = mealData.mealCOB > 0 && (carbImpact > 0 || remainingCarbImpactPeak > 0)
+        let forecast = useCOBForecast ? cobForecast : iobForecast
+
+        // At this point in the JS the forecasts have already been rounded
+        for (index, glucose) in forecast.map({ $0.jsRounded() }).enumerated() {
+            if glucose < threshold {
+                minutesAboveThreshold = Decimal(5) * Decimal(index)
+                break
+            }
+        }
+
+        let zeroTempDuration = minutesAboveThreshold
+        let zeroTempEffect = currentBasal * adjustedSensitivity * overrideFactor * zeroTempDuration / 60
+
+        let mealCarbs = mealData.carbs
+        let cobForCarbsRequired = max(0, mealData.mealCOB - (Decimal(0.25) * mealCarbs))
+
+        guard adjustedCarbRatio > 0 else { return (carbs: 0, minutes: minutesAboveThreshold) }
+        let carbSensitivityFactor = adjustedSensitivity / adjustedCarbRatio
+        guard carbSensitivityFactor > 0 else { return (carbs: 0, minutes: minutesAboveThreshold) }
+
+        var carbsRequired = (glucoseUndershoot - zeroTempEffect) / carbSensitivityFactor - cobForCarbsRequired
+        carbsRequired = carbsRequired.jsRounded()
+
+        return (carbs: carbsRequired, minutes: minutesAboveThreshold)
+    }
+
+    /// Determines if a low glucose suspend is warranted.
+    ///
+    /// This function checks for low glucose conditions and may modify the determination object
+    /// with a suspend recommendation and an updated reason string.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `setTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func lowGlucoseSuspend(
+        currentGlucose: Decimal,
+        minGuardGlucose: Decimal,
+        iob: Decimal,
+        minDelta: Decimal,
+        expectedDelta: Decimal,
+        threshold: Decimal,
+        overrideFactor: Decimal,
+        profile: Profile,
+        adjustedSensitivity: Decimal,
+        targetGlucose: Decimal,
+        currentTemp: TempBasal,
+        determination: Determination
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        var newDetermination = determination
+
+        guard let currentBasal = profile.currentBasal else {
+            // Should have been checked earlier
+            throw TempBasalFunctionError.invalidBasalRateOnProfile
+        }
+
+        let suspendThreshold = -currentBasal * overrideFactor * 20 / 60
+        if currentGlucose < threshold, iob < suspendThreshold, minDelta > 0, minDelta > expectedDelta {
+            let iobString = String(describing: iob)
+            let suspendString = String(describing: suspendThreshold.jsRounded(scale: 2))
+            let minDeltaString = String(describing: convertGlucose(profile: profile, glucose: minDelta))
+            let expectedDeltaString = String(describing: convertGlucose(profile: profile, glucose: expectedDelta))
+
+            newDetermination
+                .reason +=
+                "IOB \(iobString) < \(suspendString) and minDelta \(minDeltaString) > expectedDelta \(expectedDeltaString); "
+            return (shouldSetTempBasal: false, determination: newDetermination)
+        } else if currentGlucose < threshold || minGuardGlucose < threshold {
+            let minGuardGlucoseString = String(describing: convertGlucose(profile: profile, glucose: minGuardGlucose))
+            let thresholdString = String(describing: convertGlucose(profile: profile, glucose: threshold))
+            newDetermination.reason += "minGuardBG \(minGuardGlucoseString)<\(thresholdString)"
+
+            let glucoseUndershoot = targetGlucose - minGuardGlucose
+            if minGuardGlucose < threshold {
+                newDetermination.minGuardBG = minGuardGlucose
+            }
+
+            let worstCaseInsulinRequired = glucoseUndershoot / adjustedSensitivity
+            var durationRequired = (60 * worstCaseInsulinRequired / currentBasal * overrideFactor).jsRounded()
+            durationRequired = (durationRequired / 30).jsRounded() * 30
+            durationRequired = max(30, min(120, durationRequired))
+
+            let finalDetermination = try TempBasalFunctions.setTempBasal(
+                rate: 0,
+                duration: durationRequired,
+                profile: profile,
+                determination: newDetermination,
+                currentTemp: currentTemp
+            )
+            return (shouldSetTempBasal: true, determination: finalDetermination)
+        }
+
+        return (shouldSetTempBasal: false, determination: determination)
+    }
+
+    /// Determines if a neutral temp basal should be skipped to avoid pump alerts.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func skipNeutralTempBasal(
+        smbIsEnabled: Bool,
+        profile: Profile,
+        clock: Date,
+        currentTemp: TempBasal,
+        determination: Determination
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        guard profile.skipNeutralTemps else {
+            return (shouldSetTempBasal: false, determination: determination)
+        }
+        guard let totalMinutes = clock.minutesSinceMidnight else {
+            throw CalendarError.invalidCalendar
+        }
+
+        let minute = totalMinutes % 60
+        guard minute >= 55 else {
+            return (shouldSetTempBasal: false, determination: determination)
+        }
+
+        if !smbIsEnabled {
+            var newDetermination = determination
+            let minutesLeft = 60 - minute
+            newDetermination
+                .reason +=
+                "; Canceling temp at \(minutesLeft)min before turn of the hour to avoid beeping of MDT. SMB are disabled anyways."
+
+            let finalDetermination = try TempBasalFunctions.setTempBasal(
+                rate: 0,
+                duration: 0,
+                profile: profile,
+                determination: newDetermination,
+                currentTemp: currentTemp
+            )
+            return (shouldSetTempBasal: true, determination: finalDetermination)
+        } else {
+            // In the JS, this path logs to the console but does not modify determination.
+            // We will do nothing here to match that behavior.
+            return (shouldSetTempBasal: false, determination: determination)
+        }
+    }
+
+    /// Handles the case where eventual glucose is predicted to be low.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func handleLowEventualGlucose(
+        eventualGlucose: Decimal,
+        minGlucose: Decimal,
+        targetGlucose: Decimal,
+        minDelta: Decimal,
+        expectedDelta: Decimal,
+        carbsRequired: Decimal,
+        naiveEventualGlucose: Decimal,
+        glucoseStatus: GlucoseStatus,
+        currentTemp: TempBasal,
+        basal: Decimal,
+        profile: Profile,
+        determination: Determination,
+        adjustedSensitivity: Decimal,
+        overrideFactor: Decimal
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        guard eventualGlucose < minGlucose else {
+            return (shouldSetTempBasal: false, determination: determination)
+        }
+
+        var newDetermination = determination
+        newDetermination
+            .reason +=
+            "Eventual BG \(convertGlucose(profile: profile, glucose: eventualGlucose)) < \(convertGlucose(profile: profile, glucose: minGlucose))"
+
+        // if 5m or 30m avg glucose is rising faster than expected delta
+        // BUG: in JS it's doing a "truthiness" check for carbs required
+        //      but if you get a negative carbsRequired it will evaluate
+        //      to true when it should be false (negative carbs required
+        //      means no carbs required)
+        if minDelta > expectedDelta, minDelta > 0, carbsRequired == 0 {
+            if naiveEventualGlucose < 40 {
+                newDetermination.reason += ", naive_eventualBG < 40. "
+                let finalDetermination = try TempBasalFunctions.setTempBasal(
+                    rate: 0,
+                    duration: 30,
+                    profile: profile,
+                    determination: newDetermination,
+                    currentTemp: currentTemp
+                )
+                return (shouldSetTempBasal: true, determination: finalDetermination)
+            }
+
+            if glucoseStatus.delta > minDelta {
+                newDetermination
+                    .reason +=
+                    ", but Delta \(convertGlucose(profile: profile, glucose: glucoseStatus.delta)) > expectedDelta \(convertGlucose(profile: profile, glucose: expectedDelta))"
+            } else {
+                let minDeltaFormatted = String(format: "%.2f", Double(truncating: minDelta.jsRounded(scale: 2) as NSNumber))
+                newDetermination
+                    .reason +=
+                    ", but Min. Delta \(minDeltaFormatted) > Exp. Delta \(convertGlucose(profile: profile, glucose: expectedDelta))"
+            }
+
+            let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
+            let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
+
+            if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
+                newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
+                return (shouldSetTempBasal: true, determination: newDetermination)
+            } else {
+                newDetermination.reason += "; setting current basal of \(basal) as temp. "
+                let finalDetermination = try TempBasalFunctions.setTempBasal(
+                    rate: basal,
+                    duration: 30,
+                    profile: profile,
+                    determination: newDetermination,
+                    currentTemp: currentTemp
+                )
+                return (shouldSetTempBasal: true, determination: finalDetermination)
+            }
+        }
+
+        // calculate 30m low-temp required to get projected glucose up to target
+        var insulinRequired = 2 * min(0, (eventualGlucose - targetGlucose) / adjustedSensitivity)
+        insulinRequired = insulinRequired.jsRounded(scale: 2)
+
+        let naiveInsulinRequired = min(0, (naiveEventualGlucose - targetGlucose) / adjustedSensitivity).jsRounded(scale: 2)
+
+        if minDelta < 0, minDelta > expectedDelta {
+            let newInsulinRequired = (insulinRequired * (minDelta / expectedDelta)).jsRounded(scale: 2)
+            insulinRequired = newInsulinRequired
+        }
+
+        var rate = basal + (2 * insulinRequired)
+        rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
+
+        let insulinScheduled = Decimal(currentTemp.duration) * (currentTemp.rate - basal) / 60
+        let minInsulinRequired = min(insulinRequired, naiveInsulinRequired)
+
+        if insulinScheduled < minInsulinRequired - basal * 0.3 {
+            let rateFormatted = String(format: "%.2f", Double(truncating: currentTemp.rate.jsRounded(scale: 2) as NSNumber))
+            newDetermination
+                .reason += ", \(currentTemp.duration)m@\(rateFormatted) is a lot less than needed. "
+            let finalDetermination = try TempBasalFunctions.setTempBasal(
+                rate: rate,
+                duration: 30,
+                profile: profile,
+                determination: newDetermination,
+                currentTemp: currentTemp
+            )
+            return (shouldSetTempBasal: true, determination: finalDetermination)
+        }
+
+        if currentTemp.duration > 5, rate >= currentTemp.rate * 0.8 {
+            newDetermination.reason += ", temp \(currentTemp.rate) ~< req \(rate)U/hr. "
+            return (shouldSetTempBasal: true, determination: newDetermination)
+        } else {
+            if rate <= 0 {
+                guard let currentBasal = profile.currentBasal else {
+                    throw TempBasalFunctionError.invalidBasalRateOnProfile
+                }
+                let glucoseUndershoot = targetGlucose - naiveEventualGlucose
+                let worstCaseInsulinRequired = glucoseUndershoot / adjustedSensitivity
+                var durationRequired = (60 * worstCaseInsulinRequired / currentBasal * overrideFactor).jsRounded()
+
+                if durationRequired < 0 {
+                    durationRequired = 0
+                } else {
+                    durationRequired = (durationRequired / 30).jsRounded() * 30
+                    durationRequired = min(120, max(0, durationRequired))
+                }
+
+                if durationRequired > 0 {
+                    newDetermination.reason += ", setting \(durationRequired)m zero temp. "
+                    let finalDetermination = try TempBasalFunctions.setTempBasal(
+                        rate: rate,
+                        duration: durationRequired,
+                        profile: profile,
+                        determination: newDetermination,
+                        currentTemp: currentTemp
+                    )
+                    return (shouldSetTempBasal: true, determination: finalDetermination)
+                }
+            } else {
+                newDetermination.reason += ", setting \(rate)U/hr. "
+            }
+
+            let finalDetermination = try TempBasalFunctions.setTempBasal(
+                rate: rate,
+                duration: 30,
+                profile: profile,
+                determination: newDetermination,
+                currentTemp: currentTemp
+            )
+            return (shouldSetTempBasal: true, determination: finalDetermination)
+        }
+    }
+
+    /// Handles the case where glucose is falling faster than expected.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func glucoseFallingFasterThanExpected(
+        eventualGlucose: Decimal,
+        minGlucose: Decimal,
+        minDelta: Decimal,
+        expectedDelta: Decimal,
+        glucoseStatus: GlucoseStatus,
+        currentTemp: TempBasal,
+        basal: Decimal,
+        smbIsEnabled: Bool,
+        profile: Profile,
+        determination: Determination
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        guard minDelta < expectedDelta else {
+            return (shouldSetTempBasal: false, determination: determination)
+        }
+
+        var newDetermination = determination
+
+        if !smbIsEnabled {
+            if glucoseStatus.delta < minDelta {
+                newDetermination
+                    .reason +=
+                    "Eventual BG \(convertGlucose(profile: profile, glucose: eventualGlucose)) > \(convertGlucose(profile: profile, glucose: minGlucose)) but Delta \(convertGlucose(profile: profile, glucose: glucoseStatus.delta)) < Exp. Delta \(convertGlucose(profile: profile, glucose: expectedDelta))"
+            } else {
+                let minDeltaFormatted = String(format: "%.2f", Double(truncating: minDelta.jsRounded(scale: 2) as NSNumber))
+                newDetermination
+                    .reason +=
+                    "Eventual BG \(convertGlucose(profile: profile, glucose: eventualGlucose)) > \(convertGlucose(profile: profile, glucose: minGlucose)) but Min. Delta \(minDeltaFormatted) < Exp. Delta \(convertGlucose(profile: profile, glucose: expectedDelta))"
+            }
+
+            let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
+            let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
+
+            if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
+                newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
+                return (shouldSetTempBasal: true, determination: newDetermination)
+            } else {
+                newDetermination.reason += "; setting current basal of \(basal) as temp. "
+                let finalDetermination = try TempBasalFunctions.setTempBasal(
+                    rate: basal,
+                    duration: 30,
+                    profile: profile,
+                    determination: newDetermination,
+                    currentTemp: currentTemp
+                )
+                return (shouldSetTempBasal: true, determination: finalDetermination)
+            }
+        }
+
+        return (shouldSetTempBasal: false, determination: determination)
+    }
+
+    /// Handles the case where the eventual or forecasted glucose is less than the max glucose.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func eventualOrForecastGlucoseLessThanMax(
+        eventualGlucose: Decimal,
+        maxGlucose: Decimal,
+        minForecastGlucose: Decimal,
+        currentTemp: TempBasal,
+        basal: Decimal,
+        smbIsEnabled: Bool,
+        profile: Profile,
+        determination: Determination
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        guard min(eventualGlucose, minForecastGlucose) < maxGlucose else {
+            return (shouldSetTempBasal: false, determination: determination)
+        }
+
+        var newDetermination = determination
+        newDetermination.minPredBG = minForecastGlucose
+
+        if !smbIsEnabled {
+            newDetermination
+                .reason +=
+                "\(convertGlucose(profile: profile, glucose: eventualGlucose))-\(convertGlucose(profile: profile, glucose: minForecastGlucose)) in range: no temp required"
+
+            let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
+            let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
+
+            if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
+                newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
+                return (shouldSetTempBasal: true, determination: newDetermination)
+            } else {
+                newDetermination.reason += "; setting current basal of \(basal) as temp. "
+                let finalDetermination = try TempBasalFunctions.setTempBasal(
+                    rate: basal,
+                    duration: 30,
+                    profile: profile,
+                    determination: newDetermination,
+                    currentTemp: currentTemp
+                )
+                return (shouldSetTempBasal: true, determination: finalDetermination)
+            }
+        }
+
+        return (shouldSetTempBasal: false, determination: determination)
+    }
+
+    /// Handles the case where IOB is greater than the max IOB.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func iobGreaterThanMax(
+        iob: Decimal,
+        maxIob: Decimal,
+        currentTemp: TempBasal,
+        basal: Decimal,
+        profile: Profile,
+        determination: Determination
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        guard iob > maxIob else {
+            return (shouldSetTempBasal: false, determination: determination)
+        }
+
+        var newDetermination = determination
+        newDetermination.reason += "IOB \(iob.jsRounded(scale: 2)) > max_iob \(maxIob)"
+
+        let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
+        let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
+
+        if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
+            newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
+            return (shouldSetTempBasal: true, determination: newDetermination)
+        } else {
+            newDetermination.reason += "; setting current basal of \(basal) as temp. "
+            let finalDetermination = try TempBasalFunctions.setTempBasal(
+                rate: basal,
+                duration: 30,
+                profile: profile,
+                determination: newDetermination,
+                currentTemp: currentTemp
+            )
+            return (shouldSetTempBasal: true, determination: finalDetermination)
+        }
+    }
+
+    /// Calculates the insulin required to bring the projected glucose down to the target.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `insulinRequired`: The calculated amount of insulin needed.
+    ///   - `determination`: The (potentially modified) determination object with the reason updated.
+    static func calculateInsulinRequired(
+        minForecastGlucose: Decimal,
+        eventualGlucose: Decimal,
+        targetGlucose: Decimal,
+        adjustedSensitivity: Decimal,
+        maxIob: Decimal,
+        currentIob: Decimal,
+        determination: Determination
+    ) -> (insulinRequired: Decimal, determination: Determination) {
+        var newDetermination = determination
+        var insulinRequired = (
+            (min(minForecastGlucose, eventualGlucose) - targetGlucose) / adjustedSensitivity
+        ).jsRounded(scale: 2)
+
+        if insulinRequired > maxIob - currentIob {
+            newDetermination.reason += "max_iob \(maxIob), "
+            // Important: on this path insulinRequired gets rounded
+            // to three decimal places, not 2 like on the default path
+            insulinRequired = (maxIob - currentIob).jsRounded(scale: 3)
+        }
+        newDetermination.insulinReq = insulinRequired
+        return (insulinRequired, newDetermination)
+    }
+
+    /// Determines the maxBolus possible for a Super Micro Bolus (SMB)
+    static func determineMaxBolus(
+        currentBasal: Decimal,
+        currentIob: Decimal,
+        adjustedCarbRatio: Decimal,
+        mealData: ComputedCarbs,
+        profile: Profile,
+        trioCustomOrefVariables: TrioCustomOrefVariables
+    ) -> Decimal {
+        let mealInsulinRequired = (mealData.mealCOB / adjustedCarbRatio).jsRounded(scale: 3)
+        let overrideFactor = trioCustomOrefVariables.overrideFactor()
+
+        var smbMinutesSetting = profile.maxSMBBasalMinutes
+        if trioCustomOrefVariables.useOverride, trioCustomOrefVariables.advancedSettings {
+            smbMinutesSetting = trioCustomOrefVariables.smbMinutes
+        }
+
+        var uamMinutesSetting = profile.maxUAMSMBBasalMinutes
+        if trioCustomOrefVariables.useOverride, trioCustomOrefVariables.advancedSettings {
+            uamMinutesSetting = trioCustomOrefVariables.uamMinutes
+        }
+
+        if currentIob > mealInsulinRequired, currentIob > 0 {
+            if uamMinutesSetting > 0 {
+                return (currentBasal * overrideFactor * uamMinutesSetting / 60).jsRounded(scale: 1)
+            } else {
+                // Note: It should be impossible to have uamMinutesSetting of 0 so this shouldn't execute
+                return (currentBasal * overrideFactor * 30 / 60).jsRounded(scale: 1)
+            }
+        } else {
+            return (currentBasal * overrideFactor * smbMinutesSetting / 60).jsRounded(scale: 1)
+        }
+    }
+
+    /// Determines if a Super Micro Bolus (SMB) should be delivered and calculates its size and associated temp basal.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `shouldSetTempBasal`: `true` if an SMB (or associated low temp) was enacted and the process should exit.
+    ///   - `determination`: The (potentially modified) determination object containing the decision.
+    static func determineSMBDelivery(
+        insulinRequired: Decimal,
+        microBolusAllowed: Bool,
+        smbIsEnabled: Bool,
+        currentGlucose: Decimal,
+        threshold: Decimal,
+        profile: Profile,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        mealData: ComputedCarbs,
+        iobData: [IobResult],
+        currentTime: Date,
+        targetGlucose: Decimal,
+        naiveEventualGlucose: Decimal,
+        minIOBForecastedGlucose: Decimal,
+        adjustedSensitivity: Decimal,
+        adjustedCarbRatio: Decimal,
+        basal: Decimal,
+        determination: Determination
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        var newDetermination = determination
+        guard microBolusAllowed, smbIsEnabled, currentGlucose > threshold else {
+            return (false, newDetermination)
+        }
+
+        guard let currentBasal = profile.currentBasal else {
+            // Should be impossible if we got this far
+            throw TempBasalFunctionError.invalidBasalRateOnProfile
+        }
+
+        guard let currentIob = iobData.first?.iob else {
+            return (false, newDetermination)
+        }
+
+        let maxBolus = determineMaxBolus(
+            currentBasal: currentBasal,
+            currentIob: currentIob,
+            adjustedCarbRatio: adjustedCarbRatio,
+            mealData: mealData,
+            profile: profile,
+            trioCustomOrefVariables: trioCustomOrefVariables
+        )
+
+        let smbDeliveryRatio = min(profile.smbDeliveryRatio, 1)
+        let roundSmbTo = 1 / profile.bolusIncrement
+        let microBolusWithoutRounding = min(insulinRequired * smbDeliveryRatio, maxBolus)
+        let microBolus = (microBolusWithoutRounding * roundSmbTo).floor() / roundSmbTo
+
+        let worstCaseInsulinRequired = (targetGlucose - (naiveEventualGlucose + minIOBForecastedGlucose) / 2) /
+            adjustedSensitivity
+        var durationRequired = (60 * worstCaseInsulinRequired / currentBasal * trioCustomOrefVariables.overrideFactor())
+            .jsRounded()
+
+        // if insulinRequired > 0 but not enough for a microBolus, don't set an SMB zero temp
+        if insulinRequired > 0, microBolus < profile.bolusIncrement {
+            durationRequired = 0
+        }
+
+        var smbLowTempRequired: Decimal = 0
+        if durationRequired <= 0 {
+            durationRequired = 0
+        } else if durationRequired >= 30 {
+            durationRequired = (durationRequired / 30).jsRounded() * 30
+            durationRequired = min(60, max(0, durationRequired))
+        } else {
+            // Note: we're using the fully adjusted basal here
+            smbLowTempRequired = (basal * durationRequired / 30).jsRounded(scale: 2)
+            durationRequired = 30
+        }
+
+        newDetermination.reason += " insulinReq \(insulinRequired)"
+        if microBolus >= maxBolus {
+            newDetermination.reason += "; maxBolus \(maxBolus)"
+        }
+        if durationRequired > 0 {
+            newDetermination.reason += "; setting \(durationRequired)m low temp of \(smbLowTempRequired)U/h"
+        }
+        newDetermination.reason += ". "
+
+        var smbInterval: Decimal = 3
+        if !profile.smbInterval.isNaN {
+            smbInterval = min(10, max(1, profile.smbInterval))
+        }
+
+        // minutes since last bolus
+        let lastBolusAge: Decimal?
+        if let lastBolusTime = iobData.first?.lastBolusTime {
+            let millisecondsSince1970 = Decimal(currentTime.timeIntervalSince1970 * 1000)
+            lastBolusAge = ((millisecondsSince1970 - Decimal(lastBolusTime)) / 60000).jsRounded(scale: 1)
+        } else {
+            lastBolusAge = nil
+        }
+
+        if let lastBolusAge {
+            // BUG: JS rounds minutes independently from seconds, causing double-counting when
+            // minutes rounds up. E.g., 0.6 min = 36 sec, but JS outputs "1m 36s" (96 sec).
+            // Correct logic would be:
+            //   let totalSeconds = Int(((smbInterval - lastBolusAge) * 60).jsRounded())
+            //   let nextBolusMinutes = totalSeconds / 60
+            //   let nextBolusSeconds = totalSeconds % 60
+            // Keeping JS behavior for now to match outputs.
+            let nextBolusMinutes = (smbInterval - lastBolusAge).jsRounded()
+            let nextBolusSeconds = Int(((smbInterval - lastBolusAge) * 60).jsRounded()) % 60
+
+            if lastBolusAge > smbInterval {
+                if microBolus > 0 {
+                    newDetermination.units = microBolus
+                    newDetermination.reason += "Microbolusing \(microBolus)U. "
+                }
+            } else {
+                newDetermination.reason += "Waiting \(nextBolusMinutes)m \(nextBolusSeconds)s to microbolus again. "
+            }
+        }
+
+        if durationRequired > 0 {
+            newDetermination.rate = smbLowTempRequired
+            newDetermination.duration = durationRequired
+            return (true, newDetermination)
+        }
+
+        return (false, newDetermination)
+    }
+
+    /// Determines and sets a high temp basal if required to bring glucose down.
+    ///
+    /// - Returns: The final determination object with the high temp set (if applicable).
+    static func determineHighTempBasal(
+        insulinRequired: Decimal,
+        basal: Decimal,
+        profile: Profile,
+        currentTemp: TempBasal,
+        determination: Determination
+    ) throws -> Determination {
+        var newDetermination = determination
+        var rate = basal + (2 * insulinRequired)
+        rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
+
+        let maxSafeBasal = try TempBasalFunctions.getMaxSafeBasalRate(profile: profile)
+
+        if rate > maxSafeBasal {
+            newDetermination.reason += "adj. req. rate: \(rate) to maxSafeBasal: \(maxSafeBasal.jsRounded(scale: 2)), "
+            rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: maxSafeBasal)
+        }
+
+        let insulinScheduled = Decimal(currentTemp.duration) * (currentTemp.rate - basal) / 60
+        if insulinScheduled >= insulinRequired * 2 {
+            let rateFormatted = String(format: "%.2f", Double(truncating: currentTemp.rate.jsRounded(scale: 2) as NSNumber))
+            newDetermination.reason +=
+                "\(currentTemp.duration)m@\(rateFormatted) > 2 * insulinReq. Setting temp basal of \(rate)U/hr. "
+            let finalDetermination = try TempBasalFunctions.setTempBasal(
+                rate: rate,
+                duration: 30,
+                profile: profile,
+                determination: newDetermination,
+                currentTemp: currentTemp
+            )
+            return finalDetermination
+        }
+
+        if currentTemp.duration == 0 {
+            newDetermination.reason += "no temp, setting \(rate)U/hr. "
+            let finalDetermination = try TempBasalFunctions.setTempBasal(
+                rate: rate,
+                duration: 30,
+                profile: profile,
+                determination: newDetermination,
+                currentTemp: currentTemp
+            )
+            return finalDetermination
+        }
+
+        let roundedRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
+        let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
+
+        if currentTemp.duration > 5, roundedRate <= roundedCurrentRate {
+            newDetermination.reason += "temp \(currentTemp.rate) >~ req \(rate)U/hr. "
+            return newDetermination
+        }
+
+        newDetermination.reason += "temp \(currentTemp.rate)<\(rate)U/hr. "
+        let finalDetermination = try TempBasalFunctions.setTempBasal(
+            rate: rate,
+            duration: 30,
+            profile: profile,
+            determination: newDetermination,
+            currentTemp: currentTemp
+        )
+        return finalDetermination
+    }
+}

+ 169 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DynamicISF.swift

@@ -0,0 +1,169 @@
+import Foundation
+
+/// Represents the successful output of a dynamic ISF calculation.
+struct DynamicISFResult {
+    /// The final sensitivity ratio, after all calculations and clamping.
+    let ratio: Decimal
+    /// The ratio of 24h TDD to the 14-day average TDD, clamped by autosens limits.
+    let tddRatio: Decimal
+    /// The calculated insulin factor (120 - peak time), used in the logarithmic formula.
+    let insulinFactor: Decimal
+    /// The ratio before clamping was applied.
+    let uncappedRatio: Decimal
+    /// The limit value if the ratio was clamped, nil otherwise.
+    let limitValue: Decimal?
+}
+
+enum DynamicISF {
+    /// Calculates the dynamic ISF ratio and related values.
+    ///
+    /// This function ports the core logic from `determine-basal.js` for dynamic ISF.
+    /// - Parameters:
+    ///   - profile: The user's profile, containing settings like autosens limits and insulin curve type.
+    ///   - preferences: The user's preferences, containing feature flags like `useNewFormula` and `sigmoid`.
+    ///   - currentGlucose: The most recent glucose reading.
+    ///   - tdd: The total daily dose of insulin, used as a key input for the logarithmic formula.
+    ///   - profileTarget: The effective, override-adjusted blood glucose target. Used in the sigmoid formula.
+    ///   - sensitivity: The effective, override-adjusted insulin sensitivity (ISF). Used in the logarithmic formula.
+    ///   - trioCustomOrefVariables: Custom variables containing TDD averages needed for the TDD ratio calculation.
+    /// - Returns: A `DynamicISFResult` struct on success, or `nil` if the feature is disabled or preconditions are not met.
+    static func calculate(
+        profile: Profile,
+        preferences: Preferences,
+        currentGlucose: Decimal,
+        trioCustomOrefVariables: TrioCustomOrefVariables
+    ) -> DynamicISFResult? {
+        let tdd = trioCustomOrefVariables.tdd(profile: profile)
+
+        guard preferences.useNewFormula, tdd > 0, var sensitivity = profile.sens,
+              let profileTarget = profile.profileTarget(trioCustomOrefVariables: trioCustomOrefVariables)
+        else {
+            return nil
+        }
+
+        sensitivity = trioCustomOrefVariables.override(sensitivity: sensitivity)
+
+        let minLimit = min(profile.autosensMin, profile.autosensMax)
+        let maxLimit = max(profile.autosensMin, profile.autosensMax)
+
+        // If the limits are invalid, disable dynamicISF
+        guard maxLimit > minLimit, maxLimit >= 1, minLimit <= 1 else {
+            return nil
+        }
+
+        guard preferences.dynamicIsfState(profile: profile, trioCustomOrefVariables: trioCustomOrefVariables) != .off else {
+            return nil
+        }
+
+        let bg = currentGlucose
+
+        var tdd24h_14d_Ratio: Decimal
+        if trioCustomOrefVariables.average_total_data > 0 {
+            tdd24h_14d_Ratio = trioCustomOrefVariables.weightedAverage / trioCustomOrefVariables.average_total_data
+        } else {
+            tdd24h_14d_Ratio = 1
+        }
+
+        let clampedTddRatio = tdd24h_14d_Ratio.clamp(lowerBound: minLimit, upperBound: maxLimit).rounded(scale: 2)
+
+        let insulinFactor: Decimal
+        if preferences.useCustomPeakTime {
+            insulinFactor = 120 - profile.insulinPeakTime
+        } else {
+            switch profile.curve {
+            case .rapidActing: insulinFactor = 120 - 65
+            case .ultraRapid: insulinFactor = 120 - 50
+            default: insulinFactor = 120 - 65
+            }
+        }
+
+        var newRatio: Decimal
+        if preferences.sigmoid {
+            let autosensInterval = maxLimit - minLimit
+            let bgDev = (bg - profileTarget) * 0.0555
+            let tddFactor = clampedTddRatio
+            var maxMinusOne = maxLimit - 1
+            // BUG: Note this fudge factor is to avoid a divide by zero but produces
+            // unintuitive (and incorrect) results. See the unit tests for an example
+            if maxLimit == 1 { maxMinusOne = maxLimit + 0.01 - 1 }
+            let fixOffset = Decimal.log10(1 / maxMinusOne - minLimit / maxMinusOne) / Decimal(Foundation.log10(M_E))
+            let exponent = bgDev * preferences.adjustmentFactorSigmoid * tddFactor + fixOffset
+            newRatio = autosensInterval / (1 + Decimal.exp(-exponent)) + minLimit
+        } else {
+            newRatio = sensitivity * preferences.adjustmentFactor * tdd * (Decimal.log((bg / insulinFactor) + 1) / 1800)
+        }
+
+        let clampedRatio = newRatio.clamp(lowerBound: minLimit, upperBound: maxLimit)
+        let limitValue: Decimal? = if newRatio > maxLimit {
+            maxLimit
+        } else if newRatio < minLimit {
+            minLimit
+        } else {
+            nil
+        }
+
+        return DynamicISFResult(
+            ratio: clampedRatio,
+            tddRatio: clampedTddRatio,
+            insulinFactor: insulinFactor,
+            uncappedRatio: newRatio,
+            limitValue: limitValue
+        )
+    }
+}
+
+extension Decimal {
+    static func exp(_ x: Decimal) -> Decimal {
+        Decimal(Foundation.exp(Double(x)))
+    }
+
+    static func log10(_ x: Decimal) -> Decimal {
+        Decimal(Foundation.log10(Double(x)))
+    }
+
+    static func log(_ x: Decimal) -> Decimal {
+        Decimal(Foundation.log(Double(x)))
+    }
+}
+
+extension TrioCustomOrefVariables {
+    func tdd(profile: Profile) -> Decimal {
+        if profile.weightPercentage < 1, weightedAverage > 1 {
+            return weightedAverage
+        } else {
+            return currentTDD
+        }
+    }
+}
+
+enum DynamicIsfState {
+    case off
+    case sigmoid
+    case logrithmic
+}
+
+extension Preferences {
+    func dynamicIsfState(profile: Profile, trioCustomOrefVariables: TrioCustomOrefVariables) -> DynamicIsfState {
+        guard useNewFormula else { return .off }
+
+        // Turn off when autosens.min = autosens.max
+        // BUG: This check matches the JS logic but there should
+        // be a check for max > min. It's impossible in the UI to have
+        // min > max so I'll leave it out (and we do a proper check
+        // elsewhere in DynamicISF)
+        let minLimit = min(profile.autosensMax, profile.autosensMin)
+        let maxLimit = max(profile.autosensMax, profile.autosensMin)
+        if maxLimit == minLimit || minLimit > 1 || maxLimit < 1 {
+            return .off
+        }
+
+        // checks for 'exercise mode' like conditions
+        if profile.highTemptargetRaisesSensitivity,
+           let profileTarget = profile.profileTarget(trioCustomOrefVariables: trioCustomOrefVariables), profileTarget >= 118
+        {
+            return .off
+        }
+
+        return sigmoid ? .sigmoid : .logrithmic
+    }
+}

+ 114 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/TempBasalFunctions.swift

@@ -0,0 +1,114 @@
+import Foundation
+
+enum TempBasalFunctionError: LocalizedError, Equatable {
+    case invalidBasalRateOnProfile
+
+    var errorDescription: String? {
+        switch self {
+        case .invalidBasalRateOnProfile:
+            return "The currentBasal, maxBasal, or maxDailyBasal wasn't set on Profile"
+        }
+    }
+}
+
+enum TempBasalFunctions {
+    /// Rounds basal rates to match the basal increment for the pump as the basal rate increases
+    static func roundBasal(profile: Profile, basalRate: Decimal) -> Decimal {
+        // FIXME: Should we just call the pumpManager here?
+
+        let lowestRateScale: Decimal
+        if let model = profile.model, model.hasSuffix("54") || model.hasSuffix("23") {
+            lowestRateScale = 40
+        } else {
+            lowestRateScale = 20
+        }
+
+        let roundedBasal: Decimal
+        if basalRate < 1 {
+            roundedBasal = (basalRate * lowestRateScale).jsRounded() / lowestRateScale
+        } else if basalRate < 10 {
+            roundedBasal = (basalRate * 20).jsRounded() / 20
+        } else {
+            roundedBasal = (basalRate * 10).jsRounded() / 10
+        }
+
+        return roundedBasal
+    }
+
+    /// defines the max safe basal rate given a profile
+    static func getMaxSafeBasalRate(profile: Profile) throws -> Decimal {
+        // use default values if either of these are NaN
+        let maxDailySafetyMultiplier = profile.maxDailySafetyMultiplier.isNaN ? 3 : profile.maxDailySafetyMultiplier
+        let currentBasalSafetyMultiplier = profile.currentBasalSafetyMultiplier.isNaN ? 4 : profile.currentBasalSafetyMultiplier
+
+        guard let currentBasal = profile.currentBasal, let maxDailyBasal = profile.maxDailyBasal,
+              let maxBasal = profile.maxBasal
+        else {
+            throw TempBasalFunctionError.invalidBasalRateOnProfile
+        }
+
+        return min(
+            maxBasal,
+            maxDailySafetyMultiplier * maxDailyBasal,
+            currentBasalSafetyMultiplier * currentBasal
+        )
+    }
+
+    static func setTempBasal(
+        rate: Decimal,
+        duration: Decimal,
+        profile: Profile,
+        determination: Determination,
+        currentTemp: TempBasal
+    ) throws -> Determination {
+        var determination = determination
+        let maxSafeBasal = try getMaxSafeBasalRate(profile: profile)
+
+        var rate = rate
+        if rate < 0 {
+            rate = 0
+        } else if rate > maxSafeBasal {
+            rate = maxSafeBasal
+        }
+
+        let suggestedRate = roundBasal(profile: profile, basalRate: rate)
+
+        if Decimal(currentTemp.duration) > (duration - 10),
+           currentTemp.duration <= 120,
+           suggestedRate <= currentTemp.rate * 1.2,
+           suggestedRate >= currentTemp.rate * 0.8,
+           duration > 0
+        {
+            determination
+                .reason += " \(currentTemp.duration)m left and \(currentTemp.rate) ~ req \(suggestedRate)U/hr: no temp required"
+            return determination
+        }
+
+        if suggestedRate == profile.currentBasal {
+            if profile.skipNeutralTemps {
+                if currentTemp.duration > 0 {
+                    determination
+                        .reason = determination.reason +
+                        ". Suggested rate is same as profile rate, a temp basal is active, canceling current temp"
+                    determination.duration = 0
+                    determination.rate = 0
+                    return determination
+                } else {
+                    determination
+                        .reason = determination.reason +
+                        ". Suggested rate is same as profile rate, no temp basal is active, doing nothing"
+                    return determination
+                }
+            } else {
+                determination.reason = determination.reason + ". Setting neutral temp basal of \(profile.currentBasal ?? 0)U/hr"
+                determination.duration = duration
+                determination.rate = suggestedRate
+                return determination
+            }
+        } else {
+            determination.duration = duration
+            determination.rate = suggestedRate
+            return determination
+        }
+    }
+}

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

+ 78 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/Date+MinutesFromMidnight.swift

@@ -0,0 +1,78 @@
+import Foundation
+
+enum CalendarError: LocalizedError, Equatable {
+    case invalidCalendar
+    case invalidCalendarHourOnly
+
+    var errorDescription: String? {
+        switch self {
+        case .invalidCalendar:
+            return "Unable to extract hours and minutes from the current calendar"
+        case .invalidCalendarHourOnly:
+            return "Unable to extract hours from the current calendar"
+        }
+    }
+}
+
+extension Date {
+    /// Returns the hour component for the date using the current timezone
+    var hourInLocalTime: Int? {
+        let calendar = Calendar.current
+        let components = calendar.dateComponents([.hour], from: self)
+        return components.hour
+    }
+
+    /// Returns the total minutes elapsed since midnight for the current date
+    var minutesSinceMidnight: Int? {
+        let calendar = Calendar.current
+        let components = calendar.dateComponents([.hour, .minute], from: self)
+        guard let hour = components.hour, let minute = components.minute else {
+            return nil
+        }
+        return hour * 60 + minute
+    }
+
+    var minutesSinceMidnightWithPrecision: Decimal? {
+        let calendar = Calendar.current
+        let components = calendar.dateComponents([.hour, .minute, .second, .nanosecond], from: self)
+
+        guard let hour = components.hour,
+              let minute = components.minute,
+              let second = components.second,
+              let nanosecond = components.nanosecond
+        else {
+            return nil
+        }
+
+        // Convert nanoseconds to milliseconds and round
+        let milliseconds = (Decimal(nanosecond) / 1_000_000).rounded()
+
+        let baseMinutes = Decimal(hour * 60 + minute)
+        let secondsAsMinutes = Decimal(second) / Decimal(60)
+        let millisecondsAsMinutes = milliseconds / Decimal(60000)
+
+        return baseMinutes + secondsAsMinutes + millisecondsAsMinutes
+    }
+
+    /// Checks if the current time falls within the specified range of minutes
+    /// - Parameters:
+    ///   - lowerBound: The lower bound in minutes since midnight (inclusive)
+    ///   - upperBound: The upper bound in minutes since midnight (exclusive)
+    /// - Returns: Boolean indicating if the current time is within the specified range
+    func isMinutesFromMidnightWithinRange(lowerBound: Int, upperBound: Int) throws -> Bool {
+        guard let currentMinutes = minutesSinceMidnight else {
+            throw CalendarError.invalidCalendar
+        }
+        return currentMinutes >= lowerBound && currentMinutes < upperBound
+    }
+}
+
+extension Date {
+    /// Rounds the date to the nearest minute boundary by rounding the Unix timestamp
+    /// - Returns: A new Date with seconds rounded to the nearest minute
+    func roundedToNearestMinute() -> Date {
+        let timestampInMinutes = timeIntervalSince1970.secondsToMinutes
+        let timestampRounded = timestampInMinutes.rounded()
+        return Date(timeIntervalSince1970: timestampRounded.minutesToSeconds)
+    }
+}

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

@@ -0,0 +1,47 @@
+import Foundation
+
+extension Decimal {
+    func rounded(scale: Int, roundingMode: NSDecimalNumber.RoundingMode = .plain) -> Decimal {
+        let handler = NSDecimalNumberHandler(
+            roundingMode: roundingMode,
+            scale: Int16(scale),
+            raiseOnExactness: false,
+            raiseOnOverflow: false,
+            raiseOnUnderflow: false,
+            raiseOnDivideByZero: false
+        )
+        return NSDecimalNumber(decimal: self).rounding(accordingToBehavior: handler).decimalValue
+    }
+
+    func rounded() -> Decimal {
+        rounded(scale: 0)
+    }
+
+    /// Implement Math.round from JS on Decimals. The JS implementation will add 0.5
+    /// and do a floor operation, which is what we're doing here. This ends up mattering
+    /// for values that are negative and end with .5 exactly
+    func jsRounded(scale: Int) -> Decimal {
+        var multiplier = (0 ..< scale).reduce(Decimal(1)) { result, _ in result * 10 }
+        return (self * multiplier + 0.5).rounded(scale: 0, roundingMode: .down) / multiplier
+    }
+
+    // Implement Math.floor from JS on Decimals
+    func floor() -> Decimal {
+        rounded(scale: 0, roundingMode: .down)
+    }
+
+    func jsRounded() -> Decimal {
+        // double rounding to help with imprecision in calculations
+        jsRounded(scale: 6).jsRounded(scale: 0)
+    }
+
+    func clamp(lowerBound: Decimal, upperBound: Decimal) -> Decimal {
+        if self < lowerBound {
+            return lowerBound
+        } else if self > upperBound {
+            return upperBound
+        } else {
+            return self
+        }
+    }
+}

+ 33 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/DoubleApproximateMatching.swift

@@ -0,0 +1,33 @@
+extension Double {
+    /// Approximate matching to check if it is within +/- epsilon
+    func isApproximatelyEqual(to other: Double, epsilon: Double?) -> Bool {
+        // If no epsilon provided, require exact match
+        guard let epsilon = epsilon else {
+            return self == other
+        }
+
+        // Handle exact equality
+        if self == other {
+            return true
+        }
+
+        // Handle infinity and NaN
+        if isInfinite || other.isInfinite || isNaN || other.isNaN {
+            return self == other
+        }
+
+        // For IOB values, use simple absolute difference
+        return abs(self - other) <= epsilon
+    }
+
+    /// Applies a simple clamp to Doubles
+    func clamp(lowerBound: Double, upperBound: Double) -> Double {
+        if self < lowerBound {
+            return lowerBound
+        } else if self > upperBound {
+            return upperBound
+        } else {
+            return self
+        }
+    }
+}

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

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

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

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

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

+ 18 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/PumpHistoryEvent+Duplicates.swift

@@ -0,0 +1,18 @@
+import Foundation
+
+extension Array where Element == PumpHistoryEvent {
+    /// Removes duplicate PumpSuspend events from the array
+    /// - Returns: A new array with duplicate suspend events removed
+    func removingDuplicateSuspendResumeEvents() -> [PumpHistoryEvent] {
+        var seenSuspendResume = Set<Date>()
+
+        return filter { event in
+            if event.type != .pumpSuspend, event.type != .pumpResume {
+                return true
+            }
+
+            // Make suspend/resume events unique by timestamp
+            return seenSuspendResume.insert(event.timestamp).inserted
+        }
+    }
+}

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

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

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

@@ -0,0 +1,80 @@
+import Foundation
+
+struct CarbImpactParams {
+    let cappedCarbImpact: Decimal
+    let carbImpactDuration: Decimal
+    let maxAbsorptionIntervals: Int
+    let triangleIntervals: Int
+    let remainingCarbImpactPeak: Decimal
+    let remainingCarbAbsorptionTime: Decimal
+
+    static func calculate(
+        carbSensitivityFactor: Decimal,
+        profile: Profile,
+        mealData: ComputedCarbs,
+        carbImpact: Decimal,
+        sensitivityRatio: Decimal,
+        currentTime: Date
+    ) -> CarbImpactParams {
+        let maxCarbAbsorptionRate: Decimal = 30 // g/h
+        let maxCarbImpact = (maxCarbAbsorptionRate * carbSensitivityFactor * 5 / 60).jsRounded(scale: 1)
+        let cappedCarbImpact = min(carbImpact, maxCarbImpact)
+
+        let remainingCarbAbsorptionTime = ForecastGenerator.calculateRemainingCarbAbsorptionTime(
+            sensitivityRatio: sensitivityRatio,
+            maxMealAbsorptionTime: profile.maxMealAbsorptionTime,
+            mealCOB: mealData.mealCOB,
+            lastCarbTime: Date(timeIntervalSince1970: mealData.lastCarbTime / 1000),
+            currentTime: currentTime
+        )
+
+        let carbImpactDuration: Decimal
+        if carbImpact == 0 {
+            carbImpactDuration = 0
+        } else {
+            // cid = Math.min(remainingCATime*60/5/2,Math.max(0, meal_data.mealCOB * csf / ci ));
+            carbImpactDuration = min(
+                remainingCarbAbsorptionTime * 60 / 5 / 2,
+                max(0, mealData.mealCOB * carbSensitivityFactor / carbImpact)
+            )
+        }
+
+        // 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 / carbSensitivityFactor
+
+        // 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 * carbSensitivityFactor * 5 / 60) / (remainingCarbAbsorptionTime / 2)
+        } else {
+            remainingCarbImpactPeak = 0
+        }
+
+        return CarbImpactParams(
+            cappedCarbImpact: cappedCarbImpact,
+            carbImpactDuration: carbImpactDuration,
+            maxAbsorptionIntervals: maxAbsorptionIntervals,
+            triangleIntervals: triangleIntervals,
+            remainingCarbImpactPeak: remainingCarbImpactPeak,
+            remainingCarbAbsorptionTime: remainingCarbAbsorptionTime
+        )
+    }
+}

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

@@ -0,0 +1,244 @@
+import Foundation
+
+extension ForecastGenerator {
+    static func forecastIOB(
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
+        iobData: [IobResult],
+        carbImpact: Decimal,
+        dynamicIsfState: DynamicIsfState,
+        insulinFactor: Decimal?,
+        tdd: Decimal,
+        adjustmentFactorLogrithmic: Decimal
+    ) -> IndividualForecast {
+        var result = [startingGlucose]
+        var rawResult = [startingGlucose]
+        var minGuardGlucose = Decimal(999)
+        for (glucoseImpact, iob) in zip(glucoseImpactSeries, iobData) {
+            let forecastedDeviation = carbImpact * (1 - min(1, Decimal(result.count) / (60 / 5)))
+            let lastForecast = result.last!
+            let next: Decimal
+            if let insulinFactor = insulinFactor, dynamicIsfState == .logrithmic {
+                let adjustedGlucoseImpact = adjustedGlucoseImpactForLogrithmicDynamicIsf(
+                    lastForecast: lastForecast,
+                    insulinFactor: insulinFactor,
+                    tdd: tdd,
+                    adjustmentFactorLogrithmic: adjustmentFactorLogrithmic,
+                    iobActivity: iob.activity
+                )
+                next = lastForecast + adjustedGlucoseImpact + forecastedDeviation
+            } else {
+                next = lastForecast + glucoseImpact.jsRounded(scale: 2) + forecastedDeviation
+            }
+            if result.count < 48 { result.append(next) }
+            if next < minGuardGlucose { minGuardGlucose = next.jsRounded() }
+            rawResult.append(next)
+        }
+        let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 401) }
+
+        return IndividualForecast(
+            forecasts: ForecastGenerator.trimFlatTails(clampedResult, lookback: 13),
+            minGuardGlucose: minGuardGlucose,
+            rawForecasts: rawResult,
+            duration: nil
+        )
+    }
+
+    static func forecastCOB(
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
+        carbImpact: Decimal,
+        carbImpactParams: CarbImpactParams
+    ) -> IndividualForecast {
+        // Start with the current BG
+        var result = [startingGlucose]
+        var rawResult = [startingGlucose]
+
+        var minGuardGlucose = Decimal(999)
+        // Build forecast out to glucoseImpactSeries.count (usually 48)
+        for glucoseImpact in glucoseImpactSeries {
+            let forecastedDeviation = carbImpact * (1 - min(1, Decimal(result.count) / (60 / 5)))
+
+            // Linearly decay the *observed* carb impact from initialCI → 0
+            // var predCI = Math.max(0, Math.max(0,ci) * ( 1 - COBpredBGs.length/Math.max(cid*2,1) ) );
+            let decayFactor = max(0, 1 - Decimal(result.count) / max(carbImpactParams.carbImpactDuration * 2, Decimal(1)))
+            let forecastedCarbImpact = max(0, max(0, carbImpact) * 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.
+
+            // var intervals = Math.min( COBpredBGs.length, (remainingCATime*12)-COBpredBGs.length );
+            // var remainingCI = Math.max(0, intervals / (remainingCATime/2*12) * remainingCIpeak );
+            let intervals = min(Decimal(result.count), carbImpactParams.remainingCarbAbsorptionTime * 12 - Decimal(result.count))
+            let triangle = max(
+                0,
+                intervals / (carbImpactParams.remainingCarbAbsorptionTime / 2 * 12) * carbImpactParams.remainingCarbImpactPeak
+            )
+
+            let next = result.last!
+                + glucoseImpact.jsRounded(scale: 2)
+                + min(0, forecastedDeviation)
+                + forecastedCarbImpact
+                + triangle
+
+            if result.count < 48 { result.append(next) }
+            if next < minGuardGlucose { minGuardGlucose = next.jsRounded() }
+            rawResult.append(next)
+        }
+
+        let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 1500) }
+
+        return IndividualForecast(
+            forecasts: ForecastGenerator.trimFlatTails(clampedResult, lookback: 13),
+            minGuardGlucose: minGuardGlucose,
+            rawForecasts: rawResult,
+            duration: nil
+        )
+    }
+
+    static func forecastUAM(
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
+        mealData: ComputedCarbs,
+        uamCarbImpact: Decimal,
+        carbImpact: Decimal,
+        iobData: [IobResult],
+        dynamicIsfState: DynamicIsfState,
+        insulinFactor: Decimal?,
+        tdd: Decimal,
+        adjustmentFactorLogrithmic: Decimal
+    ) -> IndividualForecast {
+        var result = [startingGlucose]
+        var rawResult = [startingGlucose]
+        var uamDuration: Decimal = 0
+
+        let slopeFromDeviations = min(
+            mealData.slopeFromMaxDeviation.jsRounded(scale: 2),
+            -mealData.slopeFromMinDeviation.jsRounded(scale: 2) / 3
+        )
+        let ticksInThreeHours: Decimal = 36 // 3 * 60 / 5
+
+        let unannouncedCarbImpact = uamCarbImpact
+        var minGuardGlucose = Decimal(999)
+
+        for (glucoseImpact, iob) in zip(glucoseImpactSeries, iobData) {
+            let forecastedDeviation = carbImpact * (1 - min(1, Decimal(result.count) / (60 / 5)))
+
+            // In JS: predUCIslope = max(0, uci + (tick * slopeFromDeviations))
+            let forecastedUnannouncedCarbImpactSlope = max(
+                0,
+                unannouncedCarbImpact + Decimal(result.count) * slopeFromDeviations
+            )
+
+            // In JS: predUCImax = max(0, uci * (1 - tick / ticksInThreeHours))
+            let maxForecastedUnannouncedCarbImpact = max(
+                0,
+                unannouncedCarbImpact * (1 - Decimal(result.count) / ticksInThreeHours)
+            )
+            let forecastedUnannouncedCarbImpact = min(
+                forecastedUnannouncedCarbImpactSlope,
+                maxForecastedUnannouncedCarbImpact
+            )
+
+            if forecastedUnannouncedCarbImpact > 0 {
+                uamDuration = (Decimal(result.count) + 1) * 5 / 60
+            }
+
+            let lastForecast = result.last!
+            let next: Decimal
+            if let insulinFactor = insulinFactor, dynamicIsfState == .logrithmic {
+                let adjustedGlucoseImpact = adjustedGlucoseImpactForLogrithmicDynamicIsf(
+                    lastForecast: lastForecast,
+                    insulinFactor: insulinFactor,
+                    tdd: tdd,
+                    adjustmentFactorLogrithmic: adjustmentFactorLogrithmic,
+                    iobActivity: iob.activity
+                )
+                next = lastForecast + adjustedGlucoseImpact + min(0, forecastedDeviation) + forecastedUnannouncedCarbImpact
+            } else {
+                next = lastForecast + glucoseImpact
+                    .jsRounded(scale: 2) + min(0, forecastedDeviation) + forecastedUnannouncedCarbImpact
+            }
+
+            if result.count < 48 { result.append(next) }
+            if next < minGuardGlucose { minGuardGlucose = next.jsRounded() }
+            rawResult.append(next)
+        }
+
+        let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 401) }
+
+        return IndividualForecast(
+            forecasts: ForecastGenerator.trimFlatTails(clampedResult, lookback: 13),
+            minGuardGlucose: minGuardGlucose,
+            rawForecasts: rawResult,
+            duration: uamDuration.jsRounded(scale: 1)
+        )
+    }
+
+    static func forecastZT(
+        startingGlucose: Decimal,
+        glucoseImpactSeriesWithZeroTemp: [Decimal],
+        targetBG: Decimal,
+        iobData: [IobResult],
+        dynamicIsfState: DynamicIsfState,
+        insulinFactor: Decimal?,
+        tdd: Decimal,
+        adjustmentFactorLogrithmic: Decimal
+    ) -> IndividualForecast {
+        var result = [startingGlucose]
+        var rawResult = [startingGlucose]
+
+        var minGuardGlucose = Decimal(999)
+        // Potential bug: ZT doesn't use forecastedDeviation like IoB does
+        for (glucoseImpact, iob) in zip(glucoseImpactSeriesWithZeroTemp, iobData) {
+            let lastForecast = result.last!
+            let next: Decimal
+            if let insulinFactor = insulinFactor, dynamicIsfState == .logrithmic {
+                let adjustedGlucoseImpact = adjustedGlucoseImpactForLogrithmicDynamicIsf(
+                    lastForecast: lastForecast,
+                    insulinFactor: insulinFactor,
+                    tdd: tdd,
+                    adjustmentFactorLogrithmic: adjustmentFactorLogrithmic,
+                    iobActivity: iob.iobWithZeroTemp.activity
+                )
+                next = lastForecast + adjustedGlucoseImpact
+            } else {
+                next = lastForecast + glucoseImpact.jsRounded(scale: 2)
+            }
+
+            if result.count < 48 { result.append(next) }
+            if next < minGuardGlucose { minGuardGlucose = next.jsRounded() }
+            rawResult.append(next)
+        }
+        let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 401) }
+        return IndividualForecast(
+            forecasts: ForecastGenerator.trimZTTails(series: clampedResult, targetBG: targetBG),
+            minGuardGlucose: minGuardGlucose,
+            rawForecasts: rawResult,
+            duration: nil
+        )
+    }
+
+    static func adjustedGlucoseImpactForLogrithmicDynamicIsf(
+        lastForecast: Decimal,
+        insulinFactor: Decimal,
+        tdd: Decimal,
+        adjustmentFactorLogrithmic: Decimal,
+        iobActivity: Decimal
+    ) -> Decimal {
+        // The JS code is extremely difficult to understand, so I tried
+        // to break down the components with the JS snippet listed above
+
+        // (Math.max( ZTpredBGs[ZTpredBGs.length-1],39) / insulinFactor )
+        let adjustedLastForecast = max(lastForecast, 39) / insulinFactor
+        // ( tdd * adjustmentFactor * (Math.log(adjustedLastForecast + 1 ) ) )
+        let adjustedTdd = tdd * adjustmentFactorLogrithmic * Decimal.log(adjustedLastForecast + 1)
+        // For ZT:
+        // round(( -iobTick.iobWithZeroTemp.activity * (1800 / adjustedTdd) * 5 ),2)
+        // For UAM and IOB:
+        // round(( -iobTick.activity * (1800 / adjustedTdd) * 5 ),2)
+        return (-iobActivity * (1800 / adjustedTdd) * 5).jsRounded(scale: 2)
+    }
+}

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

@@ -0,0 +1,433 @@
+import Foundation
+
+/// The top-level orchestrator
+enum ForecastGenerator {
+    public static func generate(
+        glucose: Decimal,
+        glucoseStatus: GlucoseStatus,
+        currentGlucoseImpact: Decimal,
+        glucoseImpactSeries: [Decimal],
+        glucoseImpactSeriesWithZeroTemp: [Decimal],
+        iobData: [IobResult],
+        mealData: ComputedCarbs,
+        profile: Profile,
+        preferences: Preferences,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        dynamicIsfResult: DynamicISFResult?,
+        targetGlucose: Decimal,
+        adjustedSensitivity: Decimal,
+        sensitivityRatio: Decimal,
+        naiveEventualGlucose _: Decimal,
+        eventualGlucose: Decimal,
+        threshold: Decimal,
+        currentTime: Date
+    ) -> ForecastResult {
+        let profileCarbRatio = profile.carbRatio ?? profile.carbRatioFor(time: currentTime)
+        let adjustedCarbRatio: Decimal
+        if trioCustomOrefVariables.useOverride, trioCustomOrefVariables.cr || trioCustomOrefVariables.isfAndCr {
+            let overrideFactor = trioCustomOrefVariables.overridePercentage / 100
+            adjustedCarbRatio = profileCarbRatio / overrideFactor
+        } else {
+            adjustedCarbRatio = profileCarbRatio
+        }
+
+        let carbSensitivityFactor = adjustedSensitivity / adjustedCarbRatio
+        let minDelta = min(glucoseStatus.delta, glucoseStatus.shortAvgDelta)
+        // this carbImpact is `ci` in JS
+        var carbImpact = (minDelta - currentGlucoseImpact).jsRounded(scale: 1)
+        let maxCarbAbsorptionRate = Decimal(30)
+        let maxCI = (maxCarbAbsorptionRate * carbSensitivityFactor * Decimal(5) / Decimal(60)).jsRounded(scale: 1)
+        if carbImpact > maxCI {
+            carbImpact = maxCI
+        }
+
+        let carbImpactParams = CarbImpactParams.calculate(
+            carbSensitivityFactor: carbSensitivityFactor,
+            profile: profile,
+            mealData: mealData,
+            carbImpact: carbImpact,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
+        )
+
+        // this is `uci` in JS, it isn't limited by maxCI
+        let uamCarbImpact = (minDelta - currentGlucoseImpact).jsRounded(scale: 1)
+
+        // JS oref initializes all xxxPredBGs array with current glucose, we do the same, then generate
+        let iobResult = forecastIOB(
+            startingGlucose: glucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            iobData: iobData,
+            carbImpact: carbImpact,
+            dynamicIsfState: preferences.dynamicIsfState(profile: profile, trioCustomOrefVariables: trioCustomOrefVariables),
+            insulinFactor: dynamicIsfResult?.insulinFactor,
+            tdd: trioCustomOrefVariables.tdd(profile: profile),
+            adjustmentFactorLogrithmic: profile.adjustmentFactor
+        )
+
+        let cobResult = forecastCOB(
+            startingGlucose: glucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            carbImpact: carbImpact,
+            carbImpactParams: carbImpactParams
+        )
+
+        let uamResult = forecastUAM(
+            startingGlucose: glucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            mealData: mealData,
+            uamCarbImpact: uamCarbImpact,
+            carbImpact: carbImpact,
+            iobData: iobData,
+            dynamicIsfState: preferences.dynamicIsfState(profile: profile, trioCustomOrefVariables: trioCustomOrefVariables),
+            insulinFactor: dynamicIsfResult?.insulinFactor,
+            tdd: trioCustomOrefVariables.tdd(profile: profile),
+            adjustmentFactorLogrithmic: profile.adjustmentFactor
+        )
+
+        let ztResult = forecastZT(
+            startingGlucose: glucose,
+            glucoseImpactSeriesWithZeroTemp: glucoseImpactSeriesWithZeroTemp,
+            targetBG: targetGlucose,
+            iobData: iobData,
+            dynamicIsfState: preferences.dynamicIsfState(profile: profile, trioCustomOrefVariables: trioCustomOrefVariables),
+            insulinFactor: dynamicIsfResult?.insulinFactor,
+            tdd: trioCustomOrefVariables.tdd(profile: profile),
+            adjustmentFactorLogrithmic: profile.adjustmentFactor
+        )
+
+        let initialForecasts = calculateMinMaxForecastedGlucose(
+            currentGlucose: glucose,
+            iobForecast: iobResult,
+            cobForecast: cobResult,
+            uamForecast: uamResult,
+            ztForecast: ztResult,
+            carbImpactDuration: carbImpactParams.carbImpactDuration,
+            remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
+            uamEnabled: profile.enableUAM
+        )
+
+        let blendedForecasts = Self.blendForecasts(
+            iobResult: initialForecasts.iob,
+            cobResult: initialForecasts.cob,
+            uamResult: initialForecasts.uam,
+            ztResult: initialForecasts.zt,
+            carbs: mealData.carbs,
+            mealCOB: mealData.mealCOB,
+            enableUAM: profile.enableUAM,
+            carbImpactDuration: carbImpactParams.carbImpactDuration,
+            remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
+            fractionCarbsLeft: mealData.carbs > 0 ? mealData.mealCOB / mealData.carbs : Decimal(0),
+            threshold: threshold,
+            targetGlucose: targetGlucose,
+            currentGlucose: glucose
+        )
+
+        var eventualGlucose = eventualGlucose
+        var finalCobForecast: [Decimal]?
+        if mealData.mealCOB > 0, carbImpact > 0 || carbImpactParams.remainingCarbImpactPeak > 0 {
+            finalCobForecast = cobResult.forecasts
+            if let lastCobGlucose = cobResult.forecasts.last {
+                eventualGlucose = max(eventualGlucose, lastCobGlucose.jsRounded())
+            }
+        }
+
+        var finalUamForecast: [Decimal]?
+        if profile.enableUAM, carbImpact > 0 || carbImpactParams.remainingCarbImpactPeak > 0 {
+            finalUamForecast = uamResult.forecasts
+            if let lastUamGlucose = uamResult.forecasts.last {
+                eventualGlucose = max(eventualGlucose, lastUamGlucose.jsRounded())
+            }
+        }
+
+        return ForecastResult(
+            iob: iobResult.forecasts,
+            cob: finalCobForecast,
+            uam: finalUamForecast,
+            zt: ztResult.forecasts,
+            internalCob: cobResult.forecasts,
+            internalUam: uamResult.forecasts,
+            eventualGlucose: eventualGlucose,
+            minForecastedGlucose: blendedForecasts.minForecastedGlucose,
+            minIOBForecastedGlucose: initialForecasts.iob.minForecastGlucose,
+            minGuardGlucose: blendedForecasts.minGuardGlucose,
+            carbImpact: carbImpact,
+            remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
+            adjustedCarbRatio: adjustedCarbRatio
+        )
+    }
+
+    /// This function does the min/max glucose forecasts at the end of the main forecast loop
+    /// in JS. It operates on raw forecasts and there is a cross dependency between IOB
+    /// predictions and the UAM predictions, so we need to pull out this logic here
+    static func calculateMinMaxForecastedGlucose(
+        currentGlucose: Decimal,
+        iobForecast: IndividualForecast,
+        cobForecast: IndividualForecast,
+        uamForecast: IndividualForecast,
+        ztForecast: IndividualForecast,
+        carbImpactDuration: Decimal,
+        remainingCarbImpactPeak: Decimal,
+        uamEnabled: Bool
+    ) -> AllForecasts {
+        // FIXME: we need to make sure that these will all be the same length
+        // but since they're running their loops on the same data they should be
+        let minCount = min(
+            iobForecast.rawForecasts.count,
+            cobForecast.rawForecasts.count,
+            uamForecast.rawForecasts.count
+        )
+
+        var maxIobForecastGlucose = currentGlucose
+        var maxCobForecastGlucose = currentGlucose
+        var maxUamForecastGlucose = currentGlucose
+        var minIobForecastGlucose = Decimal(999)
+        var minCobForecastGlucose = Decimal(999)
+        var minUamForecastGlucose = Decimal(999)
+
+        let insulinPeak5m = 18
+
+        // start at 1 because the first entry is currentGlucose
+        for index in 1 ..< minCount {
+            let length = index + 1
+            let currentIobForecastGlucose = iobForecast.rawForecasts[index]
+            let currentCobForecastGlucose = cobForecast.rawForecasts[index]
+            let currentUamForecastGlucose = uamForecast.rawForecasts[index]
+
+            // the max calculations don't get rounded in JS
+            if length > insulinPeak5m, currentIobForecastGlucose < minIobForecastGlucose {
+                minIobForecastGlucose = currentIobForecastGlucose.jsRounded()
+            }
+            if currentIobForecastGlucose > maxIobForecastGlucose {
+                maxIobForecastGlucose = currentIobForecastGlucose
+            }
+            if carbImpactDuration != 0 || remainingCarbImpactPeak > 0, length > insulinPeak5m,
+               currentCobForecastGlucose < minCobForecastGlucose
+            {
+                minCobForecastGlucose = currentCobForecastGlucose.jsRounded()
+            }
+            // BUG: I can't tell if the comparison against maxIobForecastGlucose is
+            // intentional or not, but this is what is in JS
+            if carbImpactDuration != 0 || remainingCarbImpactPeak > 0, currentCobForecastGlucose > maxIobForecastGlucose {
+                maxCobForecastGlucose = currentCobForecastGlucose
+            }
+            if uamEnabled, length > 12, currentUamForecastGlucose < minUamForecastGlucose {
+                minUamForecastGlucose = currentUamForecastGlucose.jsRounded()
+            }
+            // BUG: I can't tell if the comparison against maxIobForecastGlucose is
+            // intentional or not, but this is what is in JS
+            if uamEnabled, currentUamForecastGlucose > maxIobForecastGlucose {
+                maxUamForecastGlucose = currentUamForecastGlucose
+            }
+        }
+
+        minIobForecastGlucose = max(39, minIobForecastGlucose)
+        minCobForecastGlucose = max(39, minCobForecastGlucose)
+        minUamForecastGlucose = max(39, minUamForecastGlucose)
+
+        return AllForecasts(
+            iob: IOBForecast(
+                forecasts: iobForecast.forecasts,
+                minGuardGlucose: iobForecast.minGuardGlucose,
+                minForecastGlucose: minIobForecastGlucose,
+                maxForecastGlucose: maxIobForecastGlucose,
+                lastForecastGlucose: iobForecast.rawForecasts.last ?? currentGlucose
+            ),
+            zt: ZTForecast(
+                forecasts: ztForecast.forecasts,
+                minGuardGlucose: ztForecast.minGuardGlucose
+            ),
+            cob: COBForecast(
+                forecasts: cobForecast.forecasts,
+                minGuardGlucose: cobForecast.minGuardGlucose,
+                minForecastGlucose: minCobForecastGlucose,
+                maxForecastGlucose: maxCobForecastGlucose,
+                lastForecastGlucose: cobForecast.rawForecasts.last ?? currentGlucose
+            ),
+            uam: UAMForecast(
+                forecasts: uamForecast.forecasts,
+                minGuardGlucose: uamForecast.minGuardGlucose,
+                minForecastGlucose: minUamForecastGlucose,
+                maxForecastGlucose: maxUamForecastGlucose,
+                duration: uamForecast.duration!,
+                lastForecastGlucose: uamForecast.rawForecasts.last ?? currentGlucose
+            ) // I don't love the force unwrap here but it should always be set
+        )
+    }
+
+    /// 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 = 3 // hours
+        if sensitivityRatio > 0 {
+            minRemainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime / sensitivityRatio
+        }
+
+        var remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime
+        if mealCOB > 0 {
+            let assumedCarbAbsorptionRate: Decimal = 20 // g/h
+            minRemainingCarbAbsorptionTime = max(minRemainingCarbAbsorptionTime, mealCOB / assumedCarbAbsorptionRate)
+            if let lastCarbTime = lastCarbTime {
+                let lastCarbAgeMin = Decimal(currentTime.timeIntervalSince(lastCarbTime) / 60).jsRounded()
+                remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime + (1.5 * lastCarbAgeMin) / 60
+                remainingCarbAbsorptionTime = remainingCarbAbsorptionTime.jsRounded(scale: 1)
+            }
+        }
+
+        return remainingCarbAbsorptionTime
+    }
+
+    /// Mirrors the oref0 JS logic for selecting/blending min/avg/guard BGs.
+    static func blendForecasts(
+        iobResult: IOBForecast,
+        cobResult: COBForecast,
+        uamResult: UAMForecast,
+        ztResult: ZTForecast,
+        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 = uamResult.minForecastGlucose
+        if ztResult.minGuardGlucose < threshold {
+            minZTUAMForecastGlucose = (uamResult.minForecastGlucose + ztResult.minGuardGlucose) / 2
+        } else if ztResult.minGuardGlucose < targetGlucose {
+            let blendPct = (ztResult.minGuardGlucose - threshold) / (targetGlucose - threshold)
+            let blendedMinZTGuardGlucose = uamResult.minForecastGlucose * blendPct + ztResult.minGuardGlucose * (1 - blendPct)
+            minZTUAMForecastGlucose = (uamResult.minForecastGlucose + blendedMinZTGuardGlucose) / 2
+        } else if ztResult.minGuardGlucose > uamResult.minForecastGlucose {
+            minZTUAMForecastGlucose = (uamResult.minForecastGlucose + ztResult.minGuardGlucose) / 2
+        }
+        minZTUAMForecastGlucose = minZTUAMForecastGlucose.jsRounded()
+
+        // 2. avgForecastGlucose blending (like avgPredBG)
+        let avgerageForecastGlucose: Decimal
+        if uamResult.minForecastGlucose < 999, cobResult.minForecastGlucose < 999 {
+            avgerageForecastGlucose = (
+                (1 - fractionCarbsLeft) * uamResult.lastForecastGlucose + fractionCarbsLeft * cobResult.lastForecastGlucose
+            ).jsRounded()
+        } else if cobResult.minForecastGlucose < 999 {
+            avgerageForecastGlucose =
+                ((iobResult.lastForecastGlucose + cobResult.lastForecastGlucose) / 2)
+                    .jsRounded()
+        } else if uamResult.minForecastGlucose < 999 {
+            avgerageForecastGlucose =
+                ((iobResult.lastForecastGlucose + uamResult.lastForecastGlucose) / 2)
+                    .jsRounded()
+        } else {
+            avgerageForecastGlucose = iobResult.lastForecastGlucose.jsRounded()
+        }
+        let adjustedAverageForecastGlucose = max(avgerageForecastGlucose, ztResult.minGuardGlucose)
+
+        // 3. minGuardGlucose
+        let minGuardGlucose: Decimal
+        if carbImpactDuration > 0 || remainingCarbImpactPeak > 0 {
+            if enableUAM {
+                minGuardGlucose = (
+                    fractionCarbsLeft * cobResult.minGuardGlucose + (1 - fractionCarbsLeft) * uamResult.minGuardGlucose
+                ).jsRounded()
+            } else {
+                minGuardGlucose = cobResult.minGuardGlucose.jsRounded()
+            }
+        } else if enableUAM {
+            minGuardGlucose = uamResult.minGuardGlucose.jsRounded()
+        } else {
+            minGuardGlucose = iobResult.minGuardGlucose.jsRounded()
+        }
+
+        // 4. minForecastedGlucose ("minPredBG")
+        var minForecastedGlucose: Decimal = iobResult.minForecastGlucose.jsRounded()
+        if carbs > 0 {
+            if !enableUAM, cobResult.minForecastGlucose < 999 {
+                minForecastedGlucose = max(iobResult.minForecastGlucose, cobResult.minForecastGlucose)
+            } else if cobResult.minForecastGlucose < 999 {
+                let blendedMinForecastGlucose = fractionCarbsLeft * cobResult
+                    .minForecastGlucose + (1 - fractionCarbsLeft) * minZTUAMForecastGlucose
+                minForecastedGlucose = max(
+                    iobResult.minForecastGlucose,
+                    cobResult.minForecastGlucose,
+                    blendedMinForecastGlucose
+                ).jsRounded()
+            } else if enableUAM {
+                minForecastedGlucose = minZTUAMForecastGlucose
+            } else {
+                minForecastedGlucose = minGuardGlucose
+            }
+        } else if enableUAM {
+            minForecastedGlucose = max(iobResult.minForecastGlucose, minZTUAMForecastGlucose).jsRounded()
+        }
+
+        // Clamp minForecastedGlucose to not exceed adjustedAvgForecastGlucose
+        minForecastedGlucose = min(minForecastedGlucose, adjustedAverageForecastGlucose)
+
+        // JS: If maxCOBPredBG > bg, don't trust UAM too much
+        if cobResult.maxForecastGlucose > currentGlucose {
+            minForecastedGlucose = min(minForecastedGlucose, cobResult.maxForecastGlucose)
+        }
+
+        return ForecastBlendingResult(
+            minForecastedGlucose: minForecastedGlucose,
+            avgForecastedGlucose: adjustedAverageForecastGlucose,
+            minGuardGlucose: minGuardGlucose
+        )
+    }
+
+    /// Trims trailing flat-line points beyond a “lookback” count
+    public static func trimFlatTails(_ series: [Decimal], lookback: Int) -> [Decimal] {
+        guard series.count > lookback, lookback >= 0 else {
+            return series
+        }
+        let maxToRemove = series.count - lookback
+        let reversedSeries = series.map({ $0.jsRounded() }).reversed()
+        var removeCount = 0
+        for (curr, next) in zip(reversedSeries, reversedSeries.dropFirst()) {
+            guard curr == next else {
+                break
+            }
+            removeCount += 1
+        }
+
+        removeCount = min(maxToRemove, removeCount)
+
+        return Array(series.dropLast(removeCount))
+    }
+
+    /// Trims trailing ZT points once they are rising and above target
+    public static func trimZTTails(series: [Decimal], targetBG: Decimal) -> [Decimal] {
+        let lookback = 7 // i > 6 in JS
+
+        guard series.count > lookback else {
+            return series
+        }
+        let maxToRemove = series.count - lookback
+        let reversedSeries = series.map({ $0.jsRounded() }).reversed()
+        var removeCount = 0
+        for (curr, next) in zip(reversedSeries, reversedSeries.dropFirst()) {
+            if next >= curr || curr <= targetBG {
+                break
+            }
+            removeCount += 1
+        }
+
+        removeCount = min(maxToRemove, removeCount)
+
+        return Array(series.dropLast(removeCount))
+    }
+}

+ 45 - 0
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastResults.swift

@@ -0,0 +1,45 @@
+import Foundation
+
+struct IOBForecast {
+    let forecasts: [Decimal] // The final, trimmed array for output
+    let minGuardGlucose: Decimal // The absolute min of the untrimmed array
+    let minForecastGlucose: Decimal // The min after the initial 90-min peak
+    let maxForecastGlucose: Decimal // The absolute max of the untrimmed array
+    let lastForecastGlucose: Decimal // The last forecast (IOBPredBG in JS)
+}
+
+struct COBForecast {
+    let forecasts: [Decimal] // The final, trimmed array for output
+    let minGuardGlucose: Decimal // The absolute min of the untrimmed array
+    let minForecastGlucose: Decimal // The min after the initial 90-min peak
+    let maxForecastGlucose: Decimal // The absolute max of the untrimmed array
+    let lastForecastGlucose: Decimal // The last forecast (COBPredBG in JS)
+}
+
+struct UAMForecast {
+    let forecasts: [Decimal] // The final, trimmed array for output
+    let minGuardGlucose: Decimal // The absolute min of the untrimmed array
+    let minForecastGlucose: Decimal // The min after the initial 60-min peak
+    let maxForecastGlucose: Decimal // The absolute max of the untrimmed array
+    let duration: Decimal // The calculated UAM duration in hours
+    let lastForecastGlucose: Decimal // The last forecast (UAMPredBG in JS)
+}
+
+struct ZTForecast {
+    let forecasts: [Decimal] // The final, trimmed array for output
+    let minGuardGlucose: Decimal // The absolute min of the untrimmed array
+}
+
+struct IndividualForecast {
+    let forecasts: [Decimal]
+    let minGuardGlucose: Decimal
+    let rawForecasts: [Decimal]
+    let duration: Decimal? // only set by UAM
+}
+
+struct AllForecasts {
+    let iob: IOBForecast
+    let zt: ZTForecast
+    let cob: COBForecast
+    let uam: UAMForecast
+}

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

@@ -0,0 +1,131 @@
+import Foundation
+
+struct IobTotal: Codable {
+    let iob: Decimal
+    let activity: Decimal
+    let basaliob: Decimal
+    let bolusiob: Decimal
+    let netbasalinsulin: Decimal
+    let bolusinsulin: Decimal
+    let time: Date
+}
+
+enum IobCalculation {
+    struct IobCalculationResult {
+        let activityContrib: Double
+        let iobContrib: Double
+    }
+
+    /// logic to look up insulinPeakTime, taking into account `useCustomPeakTime`
+    private static func lookupPeak(from profile: Profile) throws -> Double {
+        switch (profile.curve, profile.useCustomPeakTime, profile.insulinPeakTime) {
+        case (.rapidActing, true, let insulinPeakTime):
+            let peakTime = Double(insulinPeakTime)
+            return peakTime.clamp(lowerBound: 50, upperBound: 120)
+        case (.rapidActing, false, _):
+            return 75
+        case (.ultraRapid, true, let insulinPeakTime):
+            let peakTime = Double(insulinPeakTime)
+            return peakTime.clamp(lowerBound: 35, upperBound: 100)
+        case (.ultraRapid, false, _):
+            return 55
+        case (.bilinear, _, _):
+            throw IobError.bilinearCurveNotSupported
+        }
+    }
+
+    /// Runs through the IoB calculation for a treatment.
+    ///
+    /// **IMPORTANT** this calculation uses Doubles internally for performance
+    static func iobCalc(
+        treatment: ComputedPumpHistoryEvent,
+        time: Date,
+        dia: Decimal,
+        profile: Profile
+    ) throws -> IobCalculationResult? {
+        guard let insulin = treatment.insulin.map({ Double($0) }) else {
+            return nil
+        }
+
+        let bolusTime = treatment.timestamp
+        let minsAgo = (time.timeIntervalSince(bolusTime) / 60.0).rounded()
+        let peak = try lookupPeak(from: profile)
+        let end = Double(dia) * 60
+
+        guard minsAgo < end else {
+            return IobCalculationResult(activityContrib: 0, iobContrib: 0)
+        }
+
+        // Calculate the constants exactly as in JavaScript
+        let tau = peak * (1 - peak / end) / (1 - 2 * peak / end)
+        let a = 2 * tau / end
+        let S = 1 / (1 - a + (1 + a) * exp(-end / tau))
+
+        let activityContrib = insulin * (S / pow(tau, 2)) * minsAgo * (1 - minsAgo / end) * exp(-minsAgo / tau)
+        let iobContrib = insulin *
+            (1 - S * (1 - a) * ((pow(minsAgo, 2) / (tau * end * (1 - a)) - minsAgo / tau - 1) * exp(-minsAgo / tau) + 1))
+
+        guard activityContrib.isFinite, iobContrib.isFinite else {
+            return IobCalculationResult(activityContrib: 0, iobContrib: 0)
+        }
+
+        return IobCalculationResult(activityContrib: activityContrib, iobContrib: iobContrib)
+    }
+
+    /// Round a Double using the same logic as Decimal.jsRounded(scale:):
+    /// floor(value * 10^scale + 0.5) / 10^scale
+    private static func jsRound(_ value: Double, scale: Int) -> Decimal {
+        guard value.isFinite else { return 0 }
+        let multiplier = pow(10.0, Double(scale))
+        return Decimal((value * multiplier + 0.5).rounded(.down) / multiplier)
+    }
+
+    static func iobTotal(treatments: [ComputedPumpHistoryEvent], profile: Profile, time now: Date) throws -> IobTotal {
+        guard var dia = profile.dia else {
+            throw IobError.diaNotSet
+        }
+
+        var iob = 0.0
+        var basaliob = 0.0
+        var bolusiob = 0.0
+        var netbasalinsulin = 0.0
+        var bolusinsulin = 0.0
+        var activity = 0.0
+
+        if dia < 5 {
+            dia = 5
+        }
+
+        let diaAgo = now - Double(dia * 60 * 60) // convert to seconds
+        let treatments = treatments.filter({ $0.timestamp <= now && $0.timestamp > diaAgo })
+        for treatment in treatments {
+            guard let tIOB = try iobCalc(treatment: treatment, time: now, dia: dia, profile: profile),
+                  let insulin = treatment.insulin.map({ Double($0) })
+            else {
+                continue
+            }
+            iob += tIOB.iobContrib
+            activity += tIOB.activityContrib
+            if tIOB.iobContrib != 0 {
+                if insulin < 0.1 {
+                    // bolus to represent temp basal, which can only be 0.05 or -0.05
+                    basaliob += tIOB.iobContrib
+                    netbasalinsulin += insulin
+                } else {
+                    bolusiob += tIOB.iobContrib
+                    bolusinsulin += insulin
+                }
+            }
+        }
+
+        return IobTotal(
+            iob: jsRound(iob, scale: 3),
+            activity: jsRound(activity, scale: 4),
+            basaliob: jsRound(basaliob, scale: 3),
+            bolusiob: jsRound(bolusiob, scale: 3),
+            netbasalinsulin: jsRound(netbasalinsulin, scale: 3),
+            bolusinsulin: jsRound(bolusinsulin, scale: 3),
+            time: now
+        )
+    }
+}

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

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

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

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

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

@@ -0,0 +1,485 @@
+import Foundation
+
+/// The Javascript implementation was too complex to port directly, so this is a clean implementation
+/// of the original logic. There are a few differences:
+///  - We are more strict in error checking
+///  - We ignore event types that Trio won't send us
+///  - We exclude some redundant events (shouldn't impact the IoB calculation)
+///
+///  There is one area where we changed the implementation that could impact IoB calculations
+///  - We don't split temp basals that cross suspends -- after a suspend resumes we assume that
+///     it goes back to the profile basal rate
+///
+///  From looking at the implementat, the `suspendZerosIob` should just be on by default to
+///  handle pump suspensions correctly
+///
+///  The current Javascript implementation is an approximation of IoB, but we have an issue
+///  open to update to more accurate pump events: https://github.com/nightscout/Trio-dev/issues/325
+///  And to fix the suspend logic: https://github.com/nightscout/Trio-dev/issues/357
+///
+///  Also, the current Javascript implementation implements the approximate algorithm incorrectly in
+///  a few corner cases:
+///  - If a tempBasal is longer than 30 minutes and has a profile basal rate change in the middle, it will
+///   miss this split resulting in incorrect insulin calculations.
+///  - When splitting events, it uses minutes instead of seconds or milliseconds to calculate durations,
+///   which can lead to incorrect durations.
+///
+/// These seem like small issues, and they are, but I have seen both in my data over a few days of running.
+
+struct IobHistory {
+    /// Used for calculating the beginning of a 0 temp when the pump history begins suspended
+    static let MAX_PUMP_HISTORY_HOURS: Double = 36
+
+    struct PumpSuspended {
+        let timestamp: Date
+        let durationInMinutes: Decimal
+
+        // these two properties are used to mark the first resume
+        // and last suspend if the pump is suspended when the history
+        // begins or currently suspended respectively
+        let isSuspendedPrior: Bool
+        let isCurrentlySuspended: Bool
+
+        init(timestamp: Date, durationInMinutes: Decimal, isSuspendedPrior: Bool = false, isCurrentlySuspended: Bool = false) {
+            self.timestamp = timestamp
+            self.durationInMinutes = durationInMinutes
+            self.isSuspendedPrior = isSuspendedPrior
+            self.isCurrentlySuspended = isCurrentlySuspended
+        }
+
+        var end: Date {
+            timestamp + durationInMinutes.minutesToSeconds
+        }
+
+        func doesOverlap(with event: ComputedPumpHistoryEvent) -> Bool {
+            guard let eventDuration = event.duration else {
+                return event.timestamp >= timestamp && event.timestamp < end
+            }
+            let eventEnd = event.timestamp + eventDuration.minutesToSeconds
+
+            return event.timestamp < end && timestamp < eventEnd
+        }
+    }
+
+    /// Processes and extract temp basals from a pumpHistory.
+    ///
+    /// The core algorithm here is to combine `TempBasal` and `TempBasalDuration`
+    /// events into a single TempBasal event with a duration. It also adds a zeroTempBasal at the end
+    /// and makes sure that none of the temp basals overlap.
+    private static func getTempBasals(
+        pumpHistory: [ComputedPumpHistoryEvent],
+        clock: Date,
+        zeroTempDuration: Decimal?
+    ) throws -> [ComputedPumpHistoryEvent] {
+        let tempBasals = pumpHistory.filter { $0.type == .tempBasal }
+        let durations = pumpHistory.filter { $0.type == .tempBasalDuration }
+
+        guard tempBasals.count == durations.count else {
+            throw IobError.tempBasalDurationMismatch
+        }
+
+        // this stops the most recent temp basal, the 1m comes from Javascript
+        let zeroTempBasal = ComputedPumpHistoryEvent.zeroTempBasal(
+            timestamp: clock + 1.minutesToSeconds,
+            duration: zeroTempDuration ?? 0,
+            omitFromTempHistory: false
+        )
+
+        // match temp basal entries to their duration entry
+        let unifiedTempBasals = try zip(tempBasals, durations).map { tempBasal, duration in
+            guard tempBasal.timestamp == duration.timestamp else {
+                throw IobError.tempBasalDurationMismatch
+            }
+
+            guard let duration = duration.durationMin else {
+                throw IobError.tempBasalDurationMissingDuration(timestamp: duration.timestamp)
+            }
+
+            return tempBasal.copyWith(duration: Decimal(duration))
+        } + [zeroTempBasal]
+
+        // if any of our temp basals overlap, truncate
+        let alignedTempBasals = zip(unifiedTempBasals, unifiedTempBasals.dropFirst()).map { curr, next in
+
+            let currEnd = curr.timestamp + (curr.duration?.minutesToSeconds ?? 0)
+            if currEnd > next.timestamp {
+                let newDuration = next.timestamp.timeIntervalSince(curr.timestamp).secondsToMinutes
+                return curr.copyWith(duration: newDuration)
+            } else {
+                return curr
+            }
+        }
+
+        return alignedTempBasals + (unifiedTempBasals.last.map { [$0] } ?? [])
+    }
+
+    /// Calculates periods of pump suspension using `PumpSuspend` and `PumpResume` events.
+    ///
+    /// The algorithm just looks at time intervals from suspend events to resume events to calculate
+    /// periods of suspension.
+    private static func getSuspends(
+        pumpHistory: [ComputedPumpHistoryEvent],
+        clock: Date
+    ) throws -> [PumpSuspended] {
+        let pumpSuspendResumeFull = pumpHistory.filter { $0.type == .pumpSuspend || $0.type == .pumpResume }
+
+        // drop all repeated suspend / resume events to match JS
+        let pumpSuspendResume = pumpSuspendResumeFull.reduce(into: [ComputedPumpHistoryEvent]()) { result, event in
+            if result.last?.type != event.type {
+                result.append(event)
+            }
+        }
+
+        for (curr, next) in zip(pumpSuspendResume, pumpSuspendResume.dropFirst()) {
+            guard curr.type != next.type, curr.timestamp != next.timestamp else {
+                throw IobError.pumpSuspendResumeMismatch
+            }
+        }
+
+        var suspends = zip(pumpSuspendResume, pumpSuspendResume.dropFirst()).compactMap { curr, next -> PumpSuspended? in
+            if curr.type == .pumpResume {
+                return nil
+            } else {
+                let duration = next.timestamp.timeIntervalSince(curr.timestamp).secondsToMinutes
+                return PumpSuspended(timestamp: curr.timestamp, durationInMinutes: duration)
+            }
+        }
+
+        // If our first suspend/resume event is a resume, the pump is suspended
+        // when our history begins
+
+        let maxPumpHistoryAgo = clock - TimeInterval(hours: MAX_PUMP_HISTORY_HOURS)
+        if let first = pumpSuspendResume.first, first.type == .pumpResume, maxPumpHistoryAgo < first.timestamp {
+            let start = maxPumpHistoryAgo
+            let duration = first.timestamp.timeIntervalSince(start).secondsToMinutes
+            suspends.append(PumpSuspended(timestamp: start, durationInMinutes: duration, isSuspendedPrior: true))
+        }
+
+        // if our last suspend/resume is a suspend, the pump is currently suspended
+        if let last = pumpSuspendResume.last, last.type == .pumpSuspend {
+            let duration = clock.timeIntervalSince(last.timestamp).secondsToMinutes
+            suspends.append(PumpSuspended(timestamp: last.timestamp, durationInMinutes: duration, isCurrentlySuspended: true))
+        }
+
+        return suspends.sorted { $0.timestamp < $1.timestamp }
+    }
+
+    /// Modifies or removes tempBasals that overlap with suspension periods
+    ///
+    /// Truncate, move, or remove temp basal commands that overlap with suspension periods.
+    ///
+    /// This implementation matches the Javascript, which has some bugs. See this issue for details:
+    /// https://github.com/nightscout/Trio-dev/issues/357
+    private static func modifyTempBasalDuringSuspend(
+        tempBasal: ComputedPumpHistoryEvent,
+        suspends: [PumpSuspended]
+    ) -> [ComputedPumpHistoryEvent] {
+        guard let tempBasalDuration = tempBasal.duration, tempBasalDuration != 0 else {
+            return [tempBasal]
+        }
+
+        for (index, suspend) in suspends.enumerated() {
+            if suspend.doesOverlap(with: tempBasal) {
+                let tempBasalStartsBeforeSuspend = tempBasal.timestamp < suspend.timestamp
+                let tempBasalEnd = tempBasal.timestamp + tempBasalDuration.minutesToSeconds
+                let tempBasalEndsAfterSuspend = tempBasalEnd > suspend.end
+
+                switch (tempBasalStartsBeforeSuspend, tempBasalEndsAfterSuspend) {
+                case (false, false):
+                    // the temp basal is completely within the suspend
+                    // just remove it, I think JS will give a negative duration
+                    return []
+                case (true, false):
+                    // the temp basal starts first but ends during the suspend, truncate it
+                    let newDuration = suspend.timestamp.timeIntervalSince(tempBasal.timestamp).secondsToMinutes
+                    return [tempBasal.copyWith(duration: newDuration)]
+                case (false, true):
+                    // the temp basal starts during the suspend but goes on
+                    // past, adjust the start date
+                    let newDuration = tempBasalEnd.timeIntervalSince(suspend.end).secondsToMinutes
+                    let newTempBasal = tempBasal.copyWith(
+                        duration: newDuration,
+                        timestamp: suspend.end
+                    )
+                    return modifyTempBasalDuringSuspend(tempBasal: newTempBasal, suspends: Array(suspends.dropFirst(index + 1)))
+                case (true, true):
+                    // the suspend is completely within the temp basal
+                    // so we need to split the temp basal
+                    let firstDuration = suspend.timestamp.timeIntervalSince(tempBasal.timestamp).secondsToMinutes
+                    let firstTempBasal = tempBasal.copyWith(duration: firstDuration)
+                    let secondDuration = tempBasalEnd.timeIntervalSince(suspend.end).secondsToMinutes
+                    let secondTempBasal = tempBasal.copyWith(
+                        duration: secondDuration,
+                        timestamp: suspend.end,
+                        omitFromTempHistory: true
+                    )
+                    return [firstTempBasal] +
+                        modifyTempBasalDuringSuspend(tempBasal: secondTempBasal, suspends: Array(suspends.dropFirst(index + 1)))
+                }
+            }
+        }
+
+        return [tempBasal]
+    }
+
+    private static func adjustForCurrentlySuspended(
+        tempBasals: [ComputedPumpHistoryEvent],
+        suspends: [PumpSuspended]
+    ) -> [ComputedPumpHistoryEvent] {
+        guard let lastSuspend = suspends.last, lastSuspend.isCurrentlySuspended else {
+            return tempBasals
+        }
+
+        return tempBasals
+        // This logic in Javascript never runs because it's in an `if`
+        // statement that compares a date (number) with a timestamp (string)
+        // which will always evaluate to false.
+        //
+        // Although I think this logic is what the algorithm is trying
+        // to do, this will get rid of zero duration temp, so I don't
+        // think we should use it
+        /*
+         let lastSuspendTime = lastSuspend.timestamp
+         return tempBasals.map { event in
+             guard event.end > lastSuspendTime else {
+                 return event
+             }
+
+             if event.timestamp > lastSuspendTime {
+                 return event.copyWith(duration: 0)
+             } else {
+                 let newDuration = lastSuspendTime.timeIntervalSince(event.timestamp).secondsToMinutes
+                 return event.copyWith(duration: newDuration)
+             }
+         }
+         */
+    }
+
+    private static func adjustForSuspendedPrior(
+        tempBasals: [ComputedPumpHistoryEvent],
+        suspends: [PumpSuspended]
+    ) -> [ComputedPumpHistoryEvent] {
+        guard let firstSuspend = suspends.first, firstSuspend.isSuspendedPrior else {
+            return tempBasals
+        }
+
+        let firstResumeDate = firstSuspend.end
+        return tempBasals.map { event in
+            let eventStartsBeforeResume = event.timestamp < firstResumeDate
+            guard eventStartsBeforeResume else {
+                return event
+            }
+
+            if event.end < firstResumeDate {
+                return event.copyWith(duration: 0)
+            } else {
+                let newDuration = event.end.timeIntervalSince(firstResumeDate).secondsToMinutes
+                return event.copyWith(duration: newDuration, timestamp: firstResumeDate)
+            }
+        }
+    }
+
+    /// Split up temp basals that overlap with suspends
+    ///
+    /// In Javascript, the algorithm mutates the original tempBasal and includes the mutated
+    /// entry in the tempHistory that it returns. But, it omits any zero temp basals it injects
+    /// or for temp basals that it splits into multiple parts it only includes the original temp basal
+    /// in the temp history even though it accounts for these with the IoB calculation. To signify
+    /// these entries that are just for accounting, we mark them as
+    /// `omitFromTempHistory == true`.
+    private static func splitAroundSuspends(
+        tempBasals: [ComputedPumpHistoryEvent],
+        suspends: [PumpSuspended]
+    ) -> [ComputedPumpHistoryEvent] {
+        var tempBasals = adjustForSuspendedPrior(tempBasals: tempBasals, suspends: suspends)
+        tempBasals = adjustForCurrentlySuspended(tempBasals: tempBasals, suspends: suspends)
+        tempBasals = tempBasals.flatMap { modifyTempBasalDuringSuspend(tempBasal: $0, suspends: suspends) }
+        let zeroTempBasals = suspends
+            .map {
+                ComputedPumpHistoryEvent
+                    .zeroTempBasal(timestamp: $0.timestamp, duration: $0.durationInMinutes, omitFromTempHistory: true) }
+
+        return (tempBasals + zeroTempBasals).sorted { $0.timestamp < $1.timestamp
+        }
+    }
+
+    private static func splitAtMinutesSinceMidnight(
+        tempBasal: ComputedPumpHistoryEvent,
+        splitPoint: Decimal
+    ) throws -> [ComputedPumpHistoryEvent] {
+        // FIXME: bug in JS where they only use minute precision for startMinutes
+        // The net effect is that it truncates the startMinutes. The differences should
+        // be small but at least it matches
+        // the fix it to use minutesSinceMidnightWithPrecision
+        guard let startMinutes = tempBasal.timestamp.minutesSinceMidnight.map({ Decimal($0) }) else {
+            throw CalendarError.invalidCalendar
+        }
+
+        guard let duration = tempBasal.duration else {
+            throw IobError.tempBasalDurationMissingDuration(timestamp: tempBasal.timestamp)
+        }
+
+        let event1Duration = splitPoint - startMinutes
+        let event2Duration = duration - event1Duration
+        let event2Start = tempBasal.timestamp + event1Duration.minutesToSeconds
+
+        return [
+            tempBasal.copyWith(duration: event1Duration),
+            tempBasal.copyWith(duration: event2Duration, timestamp: event2Start)
+        ]
+    }
+
+    private static func splitAtProfileBreak(
+        tempBasal: ComputedPumpHistoryEvent,
+        profileBreaks: [Decimal]
+    ) throws -> [ComputedPumpHistoryEvent] {
+        guard let duration = tempBasal.duration else {
+            throw IobError.tempBasalMissingDuration(timestamp: tempBasal.timestamp)
+        }
+
+        guard let startMinutes = tempBasal.timestamp.minutesSinceMidnightWithPrecision else {
+            throw CalendarError.invalidCalendar
+        }
+
+        let endMinutes = startMinutes + duration
+        for profileBreak in profileBreaks {
+            if profileBreak > startMinutes, profileBreak < endMinutes {
+                return try splitAtMinutesSinceMidnight(tempBasal: tempBasal, splitPoint: profileBreak)
+            }
+        }
+
+        return [tempBasal]
+    }
+
+    // we know that these are all at most 30 minutes since we split by 30m first
+    private static func splitAtMidnight(tempBasal: ComputedPumpHistoryEvent) throws -> [ComputedPumpHistoryEvent] {
+        let minutesPerDay = Decimal(24 * 60)
+        guard let startMinutes = tempBasal.timestamp.minutesSinceMidnightWithPrecision else {
+            throw CalendarError.invalidCalendar
+        }
+
+        guard let duration = tempBasal.duration else {
+            throw IobError.tempBasalMissingDuration(timestamp: tempBasal.timestamp)
+        }
+
+        let endMinutes = startMinutes + duration
+        if endMinutes > minutesPerDay {
+            return try splitAtMinutesSinceMidnight(tempBasal: tempBasal, splitPoint: minutesPerDay)
+        } else {
+            return [tempBasal]
+        }
+    }
+
+    private static func splitBy30mDuration(tempBasal: ComputedPumpHistoryEvent) throws -> [ComputedPumpHistoryEvent] {
+        guard let duration = tempBasal.duration else {
+            throw IobError.tempBasalMissingDuration(timestamp: tempBasal.timestamp)
+        }
+
+        return stride(from: tempBasal.timestamp, to: tempBasal.timestamp + duration.minutesToSeconds, by: 30.minutesToSeconds)
+            .map { start in
+
+                // Calculate the duration for this chunk
+                let endOfChunk = start + 30.minutesToSeconds
+                let endOfTempBasal = tempBasal.timestamp + duration.minutesToSeconds
+                let end = min(endOfChunk, endOfTempBasal)
+                let durationInSeconds = end.timeIntervalSince(start)
+
+                return tempBasal.copyWith(duration: durationInSeconds.secondsToMinutes, timestamp: start)
+            }
+    }
+
+    /// Splits any temp basal commands that cross profile break points to simplify the IoB calculation
+    private static func splitTempBasal(
+        tempBasal: ComputedPumpHistoryEvent,
+        profileBreaks: [Decimal]
+    ) throws -> [ComputedPumpHistoryEvent] {
+        try splitBy30mDuration(tempBasal: tempBasal)
+            .flatMap({ try splitAtMidnight(tempBasal: $0) })
+            .flatMap({ try splitAtProfileBreak(tempBasal: $0, profileBreaks: profileBreaks) })
+    }
+
+    /// Converts tempBasal commands to bolus commands with roughly equal insulin delivered
+    private static func extractTempBoluses(
+        from tempBasal: ComputedPumpHistoryEvent,
+        profile: Profile,
+        autosens: Autosens?
+    ) throws -> [ComputedPumpHistoryEvent] {
+        guard let duration = tempBasal.duration, duration > 0 else {
+            return []
+        }
+
+        guard let tempBasalRate = tempBasal.rate else {
+            throw IobError.rateNotSetOnTempBasal(timestamp: tempBasal.timestamp)
+        }
+
+        guard let profileCurrentRate = try Basal.basalLookup(profile.basalprofile ?? [], now: tempBasal.timestamp) ?? profile
+            .currentBasal
+        else {
+            throw IobError.basalRateNotSet
+        }
+
+        let currentRate = autosens.map { $0.ratio * profileCurrentRate } ?? profileCurrentRate
+
+        let netBasalRate = tempBasalRate - currentRate
+        let tempBolusSize: Decimal = netBasalRate < 0 ? -0.05 : 0.05
+
+        let netBasalAmountTmp = (netBasalRate * duration * 10 / 6).jsRounded()
+        let netBasalAmount = netBasalAmountTmp / Decimal(100)
+        // FIXME: I think the count should be floor not rounded due to pump implementation artifacts
+        let tempBolusCount = Int((netBasalAmount / tempBolusSize).rounded())
+
+        let tempBolusSpacing = Decimal(duration.minutesToSeconds) / Decimal(tempBolusCount)
+
+        return (0 ..< tempBolusCount).map { j in
+            let timestamp = tempBasal.timestamp + Double(j) * Double(tempBolusSpacing)
+            return ComputedPumpHistoryEvent.tempBolus(timestamp: timestamp, insulin: tempBolusSize)
+        }
+    }
+
+    /// Converts tempBasal commands into a series of relative bolus amounts.
+    ///
+    /// Operates on net insulin delivery relative to the current basal rate. Can result in
+    /// negative bolus amounts.
+    private static func convertTempBasalToBolus(
+        tempHistory: [ComputedPumpHistoryEvent],
+        profile: Profile,
+        autosens: Autosens?
+    ) throws -> [ComputedPumpHistoryEvent] {
+        let profileBreaksMinutesSinceMidnight = profile.basalprofile?.map({ Decimal($0.minutes) }) ?? []
+        let splitTempBasals = try tempHistory
+            .flatMap { try splitTempBasal(tempBasal: $0, profileBreaks: profileBreaksMinutesSinceMidnight) }
+        return try splitTempBasals
+            .flatMap { try extractTempBoluses(from: $0, profile: profile, autosens: autosens) }
+    }
+
+    static func calcTempTreatments(
+        history: [ComputedPumpHistoryEvent],
+        profile: Profile,
+        clock: Date,
+        autosens: Autosens?,
+        zeroTempDuration: Decimal?
+    ) throws -> [ComputedPumpHistoryEvent] {
+        // ignore any records in the future and sort them
+        let pumpHistory = history.filter({ $0.timestamp <= clock }).sorted { $0.timestamp < $1.timestamp }
+        let tempBasals = try getTempBasals(pumpHistory: pumpHistory, clock: clock, zeroTempDuration: zeroTempDuration)
+        let suspends = try getSuspends(pumpHistory: pumpHistory, clock: clock)
+        let boluses = pumpHistory.filter({ $0.type == .bolus }).map { $0.copyWith(insulin: $0.amount) }
+
+        var tempHistory: [ComputedPumpHistoryEvent]
+        if profile.suspendZerosIob {
+            tempHistory = splitAroundSuspends(tempBasals: tempBasals, suspends: suspends)
+        } else {
+            tempHistory = tempBasals
+        }
+
+        let tempBoluses = try convertTempBasalToBolus(
+            tempHistory: tempHistory,
+            profile: profile,
+            autosens: autosens
+        )
+
+        tempHistory = tempHistory.filter { !$0.omitFromTempHistory }
+
+        return (boluses + tempBoluses + tempHistory).sorted { $0.timestamp < $1.timestamp }
+    }
+}

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

@@ -0,0 +1,123 @@
+import Foundation
+
+enum JSONError: Error {
+    case invalidString
+    case invalidDate(String)
+    case decodingFailed(Error)
+    case encodingFailed
+}
+
+enum JSONBridge {
+    static func preferences(from: JSON) throws -> Preferences {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func pumpSettings(from: JSON) throws -> PumpSettings {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func bgTargets(from: JSON) throws -> BGTargets {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func basalProfile(from: JSON) throws -> [BasalProfileEntry] {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func trioCustomOrefVariables(from: JSON) throws -> TrioCustomOrefVariables {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func insulinSensitivities(from: JSON) throws -> InsulinSensitivities {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func carbRatios(from: JSON) throws -> CarbRatios {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func tempTargets(from: JSON) throws -> [TempTarget] {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func model(from: JSON) -> String {
+        from.rawJSON
+    }
+
+    static func trioSettings(from: JSON) throws -> TrioSettings {
+        try JSONBridge.from(string: from.rawJSON)
+    }
+
+    static func glucose(from: JSON) throws -> [BloodGlucose] {
+        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)
+        } catch {
+            // see if we got an empty object "{}"
+            guard let data = from.rawJSON.data(using: .utf8) else {
+                throw error
+            }
+
+            if let parsedObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
+               parsedObject.isEmpty
+            {
+                return []
+            }
+
+            throw error
+        }
+    }
+
+    static func profile(from: JSON) throws -> Profile {
+        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)
+    }
+
+    static func clock(from: JSON) throws -> Date {
+        let dateJson = from.rawJSON.replacingOccurrences(of: "\"", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
+        if let date = Formatter.iso8601withFractionalSeconds.date(from: dateJson) ?? Formatter.iso8601
+            .date(from: dateJson)
+        {
+            return date
+        }
+
+        throw JSONError.invalidDate(from.rawJSON)
+    }
+
+    static func from<T: Decodable>(string: String) throws -> T {
+        guard let data = string.data(using: .utf8) else {
+            throw JSONError.invalidString
+        }
+        return try JSONCoding.decoder.decode(T.self, from: data)
+    }
+
+    static func to<T: Encodable>(_ value: T) throws -> String {
+        let data = try JSONCoding.encoder.encode(value)
+        guard let string = String(data: data, encoding: .utf8) else {
+            throw JSONError.encodingFailed
+        }
+        return string
+    }
+}

+ 293 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealCob.swift

@@ -0,0 +1,293 @@
+import Foundation
+
+struct MealCob {
+    /// Internal structure to keep track of bucketed glucose values
+    struct BucketedGlucose: Codable {
+        let glucose: Decimal
+        let date: Date
+
+        func average(adding glucose: BucketedGlucose) -> BucketedGlucose {
+            // BUG: simple average of two values
+            let newGlucose = (self.glucose + glucose.glucose) / 2
+            return BucketedGlucose(glucose: newGlucose, date: date)
+        }
+    }
+
+    /// Result structure for carb absorption detection
+    struct CobResult {
+        let carbsAbsorbed: Decimal
+        let currentDeviation: Decimal
+        let maxDeviation: Decimal
+        let minDeviation: Decimal
+        let slopeFromMaxDeviation: Decimal
+        let slopeFromMinDeviation: Decimal
+        let allDeviations: [Decimal]
+    }
+
+    /// Detects carb absorption by analyzing glucose deviations from expected insulin activity
+    ///
+    /// This is the main COB detection algorithm entry point
+    ///
+    /// IMPORTANT: This implementation faithfully reproduces JavaScript bugs where:
+    /// - clock gets mutated to the last bgTime processed
+    /// - profile.currentBasal gets mutated to the basal rate at that time
+    /// These mutations persist between calls, affecting subsequent COB calculations
+    static func detectCarbAbsorption(
+        clock: inout Date, // Made inout to match JS mutation bug
+        glucose: [BloodGlucose],
+        pumpHistory: [PumpHistoryEvent],
+        basalProfile: [BasalProfileEntry],
+        profile: inout Profile, // Made inout to match JS mutation bug
+        mealDate: Date,
+        carbImpactDate: Date?
+    ) throws -> CobResult {
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory.map { $0.computedEvent() },
+            profile: profile,
+            clock: clock,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        let bucketedData = try bucketGlucoseForCob(
+            glucose: glucose,
+            profile: profile,
+            mealDate: mealDate,
+            carbImpactDate: carbImpactDate
+        )
+
+        return try calculateCarbAbsorption(
+            bucketedData: bucketedData,
+            treatments: treatments,
+            basalProfile: basalProfile,
+            profile: &profile,
+            mealDate: mealDate,
+            carbImpactDate: carbImpactDate,
+            clock: &clock
+        )
+    }
+
+    /// Groups glucose readings into time buckets with interpolation for missing data points
+    /// Faithful port of JS bucketing logic including all quirks
+    static func bucketGlucoseForCob(
+        glucose: [BloodGlucose],
+        profile: Profile,
+        mealDate: Date,
+        carbImpactDate: Date?
+    ) throws -> [BucketedGlucose] {
+        // Map glucose data like JS does
+        let glucoseData = glucose.compactMap({ (bg: BloodGlucose) -> BucketedGlucose? in
+            guard let glucose = bg.glucose ?? bg.sgv else { return nil }
+            return BucketedGlucose(glucose: Decimal(glucose), date: bg.dateString)
+        })
+
+        var bucketedData: [BucketedGlucose] = []
+        var foundPreMealBG = false
+        var lastbgi = 0
+
+        // Initialize first bucket if we have data
+        guard !glucoseData.isEmpty else { return [] }
+
+        // JS behavior: check if first glucose is valid
+        if glucoseData[0].glucose < 39 {
+            lastbgi = -1
+        }
+
+        bucketedData.append(glucoseData[0])
+        var j = 0
+
+        for i in 1 ..< glucoseData.count {
+            let bgTime = glucoseData[i].date
+            var lastbgTime: Date
+
+            // Skip invalid glucose
+            if glucoseData[i].glucose < 39 {
+                continue
+            }
+
+            // JS: only consider BGs for maxMealAbsorptionTime after a meal
+            let hoursAfterMeal = bgTime.timeIntervalSince(mealDate) / (60 * 60)
+            if hoursAfterMeal > Double(profile.maxMealAbsorptionTime) || foundPreMealBG {
+                continue
+            } else if hoursAfterMeal < 0 {
+                foundPreMealBG = true
+            }
+
+            // Only consider last ~45m of data in CI mode
+            if let carbImpactDate = carbImpactDate {
+                let hoursAgo = carbImpactDate.timeIntervalSince(bgTime) / (45 * 60)
+                if hoursAgo > 1 || hoursAgo < 0 {
+                    continue
+                }
+            }
+
+            // Get last bg time - JS logic
+            // Note display_time isn't set in Trio so this is the
+            // only logic that will trigger
+            if lastbgi >= 0, lastbgi < glucoseData.count {
+                lastbgTime = glucoseData[lastbgi].date
+            } else {
+                continue
+            }
+
+            var elapsedMinutes = bgTime.timeIntervalSince(lastbgTime) / 60
+
+            if abs(elapsedMinutes) > 8 {
+                // Interpolate missing data points - JS logic with all its quirks
+                var lastbg = lastbgi >= 0 && lastbgi < glucoseData.count ? glucoseData[lastbgi].glucose : bucketedData[j].glucose
+                // Cap at 4 hours like JS AND modify the variable
+                elapsedMinutes = min(240, abs(elapsedMinutes))
+
+                while elapsedMinutes > 5 {
+                    // JS creates previousbgTime by subtracting from lastbgTime
+                    let previousbgTime = lastbgTime.addingTimeInterval(-5 * 60)
+                    j += 1
+
+                    let gapDelta = glucoseData[i].glucose - lastbg
+                    // JS uses the capped elapsed_minutes value
+                    let previousbg = lastbg + (5 / Decimal(elapsedMinutes)) * gapDelta
+
+                    let interpolatedBucket = BucketedGlucose(
+                        glucose: previousbg.rounded(scale: 0),
+                        date: previousbgTime
+                    )
+                    bucketedData.append(interpolatedBucket)
+
+                    elapsedMinutes -= 5
+                    lastbg = previousbg
+                    lastbgTime = previousbgTime
+                }
+                // JS behavior: Do NOT add the actual glucose reading after interpolation
+
+            } else if abs(elapsedMinutes) > 2 {
+                // Add new sample
+                j += 1
+                bucketedData.append(BucketedGlucose(
+                    glucose: glucoseData[i].glucose,
+                    date: bgTime
+                ))
+            } else {
+                // Average with previous
+                bucketedData[j] = bucketedData[j].average(adding: glucoseData[i])
+            }
+
+            lastbgi = i
+        }
+
+        return bucketedData
+    }
+
+    /// Calculates carb absorption and related metrics from bucketed glucose data
+    /// Faithful port including JS bugs where clock and profile are mutated
+    private static func calculateCarbAbsorption(
+        bucketedData: [BucketedGlucose],
+        treatments: [ComputedPumpHistoryEvent],
+        basalProfile: [BasalProfileEntry],
+        profile: inout Profile, // Mutated to match JS bug
+        mealDate: Date,
+        carbImpactDate: Date?,
+        clock: inout Date // Mutated to match JS bug
+    ) throws -> CobResult {
+        var carbsAbsorbed: Decimal = 0
+        var currentDeviation: Decimal = 0
+        var slopeFromMaxDeviation: Decimal = 0
+        var slopeFromMinDeviation: Decimal = 999
+        var maxDeviation: Decimal = 0
+        var minDeviation: Decimal = 999
+        var allDeviations: [Decimal] = []
+
+        // Process bucketed data (excluding last 3 entries)
+        for i in 0 ..< max(0, bucketedData.count - 3) {
+            let bgTime = bucketedData[i].date
+            let bg = bucketedData[i].glucose
+
+            // Skip if glucose values are invalid
+            guard bg >= 39, bucketedData[i + 3].glucose >= 39 else {
+                continue
+            }
+
+            let avgDelta = ((bg - bucketedData[i + 3].glucose) / 3).jsRounded(scale: 2)
+            let delta = bg - bucketedData[i + 1].glucose
+
+            // Get ISF
+            guard let isfProfile = profile.isfProfile?.toInsulinSensitivities() else {
+                throw CobError.missingIsfProfile
+            }
+            let (sens, _) = try Isf.isfLookup(isfDataInput: isfProfile, timestamp: bgTime)
+
+            // JS BUGS: These mutations persist!
+            clock = bgTime // Mutates the clock
+            profile.currentBasal = try Basal.basalLookup(basalProfile, now: bgTime) // Mutates the profile
+
+            // Calculate IOB with mutated values
+            let iob = try IobCalculation.iobTotal(
+                treatments: treatments,
+                profile: profile,
+                time: clock // Uses the mutated clock
+            )
+
+            // JS: bgi = Math.round(( -iob.activity * sens * 5 )*100)/100
+            let bgi: Decimal = (-iob.activity * sens * 5).jsRounded(scale: 2)
+            let deviation = delta - bgi
+
+            // Calculate current deviation
+            if i == 0 {
+                // JS: currentDeviation = Math.round((avgDelta-bgi)*1000)/1000
+                currentDeviation = (avgDelta - bgi).jsRounded(scale: 3)
+                if let carbImpactDate = carbImpactDate, carbImpactDate > bgTime {
+                    allDeviations.append(currentDeviation.rounded())
+                }
+            } else if let carbImpactDate = carbImpactDate, carbImpactDate > bgTime {
+                // JS: avgDeviation = Math.round((avgDelta-bgi)*1000)/1000
+                let avgDeviation = (avgDelta - bgi).jsRounded(scale: 3)
+                // JS: deviationSlope = (avgDeviation-currentDeviation)/(bgTime-ciTime)*1000*60*5
+                // we can drop the *1000 since we're already in seconds
+                let deviationSlope = (avgDeviation - currentDeviation) /
+                    Decimal(bgTime.timeIntervalSince(carbImpactDate)) * 60 * 5
+
+                if avgDeviation > maxDeviation {
+                    slopeFromMaxDeviation = min(0, deviationSlope)
+                    maxDeviation = avgDeviation
+                }
+                if avgDeviation < minDeviation {
+                    slopeFromMinDeviation = max(0, deviationSlope)
+                    minDeviation = avgDeviation
+                }
+
+                allDeviations.append(avgDeviation.rounded())
+            }
+
+            // Calculate carbs absorbed
+            if bgTime > mealDate {
+                guard let carbRatio = profile.carbRatio else {
+                    throw CobError.missingCarbRatioInProfile
+                }
+
+                // JS: ci = Math.max(deviation, currentDeviation/2, profile.min_5m_carbimpact)
+                let ci = max(deviation, currentDeviation / 2, profile.min5mCarbImpact)
+                let absorbed = ci * carbRatio / sens
+                carbsAbsorbed += absorbed
+            }
+        }
+
+        // IMPORTANT: clock and profile.currentBasal remain mutated after this function returns!
+
+        return CobResult(
+            carbsAbsorbed: carbsAbsorbed,
+            currentDeviation: currentDeviation,
+            maxDeviation: maxDeviation,
+            minDeviation: minDeviation,
+            slopeFromMaxDeviation: slopeFromMaxDeviation,
+            slopeFromMinDeviation: slopeFromMinDeviation,
+            allDeviations: allDeviations
+        )
+    }
+}
+
+/// Error types for COB calculation
+enum CobError: Error {
+    case missingIsfProfile
+    case isfLookupError
+    case missingCarbRatioInProfile
+    case couldNotDetermineLastglucoseTime
+}

+ 40 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealGenerator.swift

@@ -0,0 +1,40 @@
+import Foundation
+
+enum MealGenerator {
+    static func generate(
+        pumpHistory: [PumpHistoryEvent],
+        profile: Profile,
+        basalProfile: [BasalProfileEntry],
+        clock: Date,
+        carbHistory: [CarbsEntry],
+        glucoseHistory: [BloodGlucose]
+    ) throws -> ComputedCarbs? {
+        let treatments: [MealInput] = MealHistory.findMealInputs(pumpHistory: pumpHistory, carbHistory: carbHistory)
+
+        let recentCarbs = try MealTotal.recentCarbs(
+            treatments: treatments,
+            pumpHistory: pumpHistory,
+            profile: profile,
+            basalProfile: basalProfile,
+            glucose: glucoseHistory,
+            time: clock
+        )
+
+        // copy the glucose check from prepare/meal.js in Trio
+        guard glucoseHistory.count >= 4 else {
+            return ComputedCarbs(
+                carbs: 0,
+                mealCOB: 0,
+                currentDeviation: 0,
+                maxDeviation: 0,
+                minDeviation: 0,
+                slopeFromMaxDeviation: 0,
+                slopeFromMinDeviation: 0,
+                allDeviations: [],
+                lastCarbTime: 0
+            )
+        }
+
+        return recentCarbs
+    }
+}

+ 81 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealHistory.swift

@@ -0,0 +1,81 @@
+import Foundation
+
+/// Represents the "temp" object built in JS meal/history.js
+struct MealInput {
+    let timestamp: Date
+    var carbs: Decimal? /// `current.carbs`
+    var bolus: Decimal? /// from `current.amount` in Bolus events
+    /// omitting nsCarbs, bwCarbs, journalCarbs
+
+    enum InputType: String {
+        case carbs
+        case bolus
+    }
+}
+
+private struct MealInputKey: Hashable {
+    let timestamp: Date
+    let type: MealInput.InputType
+}
+
+enum MealHistory {
+    /// Converts carb and bolus records into a single, chronological list of MealInput,
+    /// removing any duplicate entries of the same type whose timestamps are within ±2 seconds.
+    /// - Parameters:
+    ///   - pumpHistory: Array of PumpHistoryEvent (bolus events)
+    ///   - carbHistory: Array of CarbsEntry (carb treatments)
+    /// - Returns: A deduplicated array of MealInput, preserving original order but collapsing
+    ///            any carb or bolus events that occur within 2 seconds of an earlier one.
+    static func findMealInputs(
+        pumpHistory: [PumpHistoryEvent],
+        carbHistory: [CarbsEntry]
+    ) -> [MealInput] {
+        let carbInputs = carbHistory.compactMap { entry -> MealInput? in
+            guard entry.carbs > 0 else { return nil }
+            return MealInput(
+                timestamp: entry.createdAt,
+                carbs: entry.carbs,
+                bolus: nil
+            )
+        }
+
+        let bolusInputs = pumpHistory.compactMap { ev -> MealInput? in
+            guard ev.type == .bolus, let amt = ev.amount else { return nil }
+            return MealInput(
+                timestamp: ev.timestamp,
+                carbs: nil,
+                bolus: amt
+            )
+        }
+
+        let combinedIputs = carbInputs + bolusInputs
+        var seenBuckets: [MealInput.InputType: Set<Int>] = [
+            .carbs: Set(),
+            .bolus: Set()
+        ]
+
+        var dedupedInputs: [MealInput] = []
+        dedupedInputs.reserveCapacity(combinedIputs.count)
+
+        for input in combinedIputs {
+            let type: MealInput.InputType = input.carbs != nil ? .carbs : .bolus
+            let tSec = Int(input.timestamp.timeIntervalSince1970)
+
+            // check if any second in [tSec-2 ... tSec+2] is already in our bucket
+            let bucket = seenBuckets[type]!
+            let isDuplicate = (tSec - 2 ... tSec + 2).contains { bucket.contains($0) }
+
+            if !isDuplicate {
+                dedupedInputs.append(input)
+
+                /// copies out bucket, mutates it, writes it back
+                /// ensuring every entry exists at least once, but is properly deduped
+                var newBucket = bucket
+                newBucket.insert(tSec)
+                seenBuckets[type] = newBucket
+            }
+        }
+
+        return dedupedInputs
+    }
+}

+ 210 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealTotal.swift

@@ -0,0 +1,210 @@
+import Foundation
+
+struct ComputedCarbs: Codable {
+    var carbs: Decimal
+    var mealCOB: Decimal
+    var currentDeviation: Decimal?
+    var maxDeviation: Decimal
+    var minDeviation: Decimal
+    var slopeFromMaxDeviation: Decimal
+    var slopeFromMinDeviation: Decimal
+    var allDeviations: [Decimal]
+    var lastCarbTime: TimeInterval
+
+    enum CodingKeys: String, CodingKey {
+        case carbs
+        case mealCOB
+        case currentDeviation
+        case maxDeviation
+        case minDeviation
+        case slopeFromMaxDeviation
+        case slopeFromMinDeviation
+        case allDeviations
+        case lastCarbTime
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(carbs, forKey: .carbs)
+        try container.encode(mealCOB, forKey: .mealCOB)
+        try container.encode(maxDeviation, forKey: .maxDeviation)
+        try container.encode(minDeviation, forKey: .minDeviation)
+        try container.encode(slopeFromMaxDeviation, forKey: .slopeFromMaxDeviation)
+        try container.encode(slopeFromMinDeviation, forKey: .slopeFromMinDeviation)
+        try container.encode(allDeviations, forKey: .allDeviations)
+        try container.encode(lastCarbTime, forKey: .lastCarbTime)
+
+        if let currentDeviation = currentDeviation {
+            try container.encode(currentDeviation, forKey: .currentDeviation)
+        } else {
+            try container.encodeNil(forKey: .currentDeviation)
+        }
+    }
+}
+
+struct IOBInput {
+    var profile: Profile
+    let history: [PumpHistoryEvent]
+    // var to enable input mutation
+    var clock: Date
+}
+
+struct COBInputs {
+    let glucoseData: [BloodGlucose]
+    // var to enable input mutations
+    var iobInputs: IOBInput
+    let basalProfile: [BasalProfileEntry]
+    var mealDate: Date
+    var carbImpactDate: Date?
+}
+
+enum MealTotal {
+    /// Calculates the effective carbohydrates on board (COB) and glucose deviations
+    /// resulting from recent meal entries within the user’s absorption window.
+    ///
+    /// This function:
+    /// 1. Filters `treatments` to only those within `profile.maxMealAbsorptionTime` hours before `time`.
+    /// 2. Iterates each carb entry (≥1 g), calling `MealCob.detectCarbAbsorption` in CI mode
+    ///    (last ~45 min) to estimate how much of the accumulated carbs remain unabsorbed,
+    ///    tracking the peak “COB driver” and removing any extra carbs that never drove COB.
+    /// 3. Subtracts out those “extra” carbs to yield the true total carbs counted.
+    /// 4. Performs a final COB + deviation pass anchored at the earliest valid carb timestamp,
+    ///    again in CI mode, to compute:
+    ///    – `currentDeviation`, `maxDeviation`, `minDeviation`,
+    ///    – `slopeFromMaxDeviation`, `slopeFromMinDeviation`,
+    ///    – the full series `allDeviations`,
+    ///    – and applies profile caps (`maxCOB`) and “zombie‐carb” safety (zero COB if no dev).
+    ///
+    /// - Parameters:
+    ///   - treatments:   list of past carb-and-bolus `MealInput` events
+    ///   - pumpHistory:  insulin pump history for IOB calculations
+    ///   - profile:      user profile (carb ratio, ISF, maxMealAbsorptionTime, maxCOB, etc.)
+    ///   - basalProfile: basal insulin schedule
+    ///   - glucose:      BG readings used to bucket and compute deviations
+    ///   - time:         the “now” timestamp at which to evaluate COB & deviations
+    ///
+    /// - Returns:
+    ///   A `ComputedCarbs` struct containing:
+    ///     • `carbs` – total carbs counted
+    ///     • `mealCOB` – carbs on board at peak
+    ///     • `currentDeviation`, `maxDeviation`, `minDeviation`
+    ///     • `slopeFromMaxDeviation`, `slopeFromMinDeviation`
+    ///     • `allDeviations` – the deviation history
+    ///     • `lastCarbTime` – timestamp of the most recent carb used
+    ///
+    /// - Throws: any errors from `MealCob.detectCarbAbsorption`.
+    static func recentCarbs(
+        treatments: [MealInput],
+        pumpHistory: [PumpHistoryEvent],
+        profile: Profile,
+        basalProfile: [BasalProfileEntry],
+        glucose: [BloodGlucose],
+        time: Date
+    ) throws -> ComputedCarbs? {
+        // Re-assign to a var, so it can be sorted
+        var _treatments = treatments
+        var profile = profile
+
+        // Define defaults
+        var carbs = Decimal(0)
+        let mealCarbTime: TimeInterval = time.timeIntervalSince1970
+        var lastCarbTime: TimeInterval = 0
+
+        let iobInputs = IOBInput(profile: profile, history: pumpHistory, clock: time)
+        var cobInputs = COBInputs(
+            glucoseData: glucose,
+            iobInputs: iobInputs,
+            basalProfile: basalProfile,
+            mealDate: Date(timeIntervalSince1970: mealCarbTime)
+        )
+        var mealCOB = Decimal(0)
+
+        _treatments.sort(by: {
+            $0.timestamp > $1.timestamp
+        })
+
+        var carbsToRemove = Decimal(0)
+
+        for treatment in _treatments {
+            let now = time.timeIntervalSince1970
+
+            // Use new maxMealAbsorptionTime setting here instead of default 6 hrs
+            let carbWindow = now - TimeInterval(hours: Double(truncating: profile.maxMealAbsorptionTime as NSNumber))
+
+            let treatmentDate = treatment.timestamp
+            let treatmentTime = treatmentDate.timeIntervalSince1970
+
+            if treatmentTime > carbWindow, treatmentTime <= now {
+                if let _carbs = treatment.carbs, _carbs >= 1 {
+                    carbs += _carbs
+
+                    cobInputs.mealDate = treatmentDate
+                    lastCarbTime = max(lastCarbTime, treatmentTime)
+
+                    let myCarbsAbsorbed = try MealCob.detectCarbAbsorption(
+                        clock: &cobInputs.iobInputs.clock,
+                        glucose: cobInputs.glucoseData,
+                        pumpHistory: cobInputs.iobInputs.history,
+                        basalProfile: cobInputs.basalProfile,
+                        profile: &cobInputs.iobInputs.profile,
+                        mealDate: cobInputs.mealDate,
+                        carbImpactDate: cobInputs.carbImpactDate
+                    ).carbsAbsorbed
+
+                    // TODO: add logging?
+                    let myMealCOB = max(0, carbs - myCarbsAbsorbed)
+                    mealCOB = max(mealCOB, myMealCOB)
+
+                    if myMealCOB < mealCOB {
+                        carbsToRemove += treatment.carbs ?? 0
+                    } else {
+                        carbsToRemove = 0
+                    }
+                }
+            }
+        }
+
+        // only include carbs actually used in calculating COB
+        carbs -= carbsToRemove
+
+        // calculate the current deviation and steepest deviation downslope over the last hour
+        cobInputs.carbImpactDate = time
+        cobInputs.mealDate = time - Double(profile.maxMealAbsorptionTime) * 3600
+
+        // set a hard upper limit on COB to mitigate impact of erroneous or malicious carb entry
+        mealCOB = min(profile.maxCOB, mealCOB)
+        /// omiting maxCOB check here, the setting is not Optional in Swift and must be part of profile
+
+        let finalCobResult = try MealCob.detectCarbAbsorption(
+            clock: &cobInputs.iobInputs.clock,
+            glucose: cobInputs.glucoseData,
+            pumpHistory: cobInputs.iobInputs.history,
+            basalProfile: cobInputs.basalProfile,
+            profile: &cobInputs.iobInputs.profile,
+            mealDate: cobInputs.mealDate,
+            carbImpactDate: cobInputs.carbImpactDate
+        )
+
+        // the comment in JS says this:
+        //    if currentDeviation is null or maxDeviation is 0, set mealCOB to 0
+        //    for zombie-carb safety
+        // But the code only checks if it's defined, not the value
+        if finalCobResult.allDeviations.isEmpty {
+            mealCOB = 0
+        }
+
+        let currentDeviation = finalCobResult.allDeviations.isEmpty ? nil : finalCobResult.currentDeviation.rounded(scale: 2)
+
+        return ComputedCarbs(
+            carbs: carbs,
+            mealCOB: mealCOB,
+            currentDeviation: currentDeviation,
+            maxDeviation: finalCobResult.maxDeviation.rounded(scale: 2),
+            minDeviation: finalCobResult.minDeviation.rounded(scale: 2),
+            slopeFromMaxDeviation: finalCobResult.slopeFromMaxDeviation.rounded(scale: 3),
+            slopeFromMinDeviation: finalCobResult.slopeFromMinDeviation.rounded(scale: 3),
+            allDeviations: finalCobResult.allDeviations,
+            lastCarbTime: (lastCarbTime * 1000).rounded()
+        )
+    }
+}

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

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

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

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

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

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

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

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

@@ -0,0 +1,40 @@
+import Foundation
+
+struct ForecastResult {
+    public let iob: [Decimal]
+    public let cob: [Decimal]?
+    public let uam: [Decimal]?
+    public let zt: [Decimal]
+    public let internalCob: [Decimal] // non optional, used downstream
+    public let internalUam: [Decimal] // non optional, used downstream
+    public let eventualGlucose: Decimal
+    public let minForecastedGlucose: Decimal
+    public let minIOBForecastedGlucose: Decimal
+    public let minGuardGlucose: Decimal
+    public let carbImpact: Decimal
+    public let remainingCarbImpactPeak: Decimal
+    public let adjustedCarbRatio: 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
+}

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

@@ -0,0 +1,28 @@
+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?
+
+    /// helper function to calculate the maxDelta variable from JS
+    public var maxDelta: Decimal {
+        max(delta, shortAvgDelta, longAvgDelta)
+    }
+}

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

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

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

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

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

@@ -0,0 +1,210 @@
+import Foundation
+
+struct OpenAPSSwift {
+    static func makeProfile(
+        preferences: JSON,
+        pumpSettings: JSON,
+        bgTargets: JSON,
+        basalProfile: JSON,
+        isf: JSON,
+        carbRatio: JSON,
+        tempTargets: JSON,
+        model: JSON,
+        trioSettings: JSON,
+        clock: Date
+    ) -> (OrefFunctionResult) {
+        do {
+            let preferences = try JSONBridge.preferences(from: preferences)
+            let pumpSettings = try JSONBridge.pumpSettings(from: pumpSettings)
+            let bgTargets = try JSONBridge.bgTargets(from: bgTargets)
+            let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
+            let isf = try JSONBridge.insulinSensitivities(from: isf)
+            let carbRatio = try JSONBridge.carbRatios(from: carbRatio)
+            let tempTargets = try JSONBridge.tempTargets(from: tempTargets)
+            let model = JSONBridge.model(from: model)
+            let trioSettings = try JSONBridge.trioSettings(from: trioSettings)
+
+            let profile = try ProfileGenerator.generate(
+                pumpSettings: pumpSettings,
+                bgTargets: bgTargets,
+                basalProfile: basalProfile,
+                isf: isf,
+                preferences: preferences,
+                carbRatios: carbRatio,
+                tempTargets: tempTargets,
+                model: model,
+                clock: clock
+            )
+
+            return (try .success(JSONBridge.to(profile)))
+        } catch {
+            return (.failure(error))
+        }
+    }
+
+    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) {
+        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)
+
+            guard let mealData = meal, let autosensData = autosens else {
+                return .failure(DeterminationError.missingInputs)
+            }
+
+            let rawDetermination = try DeterminationGenerator.generate(
+                profile: profile,
+                preferences: preferences,
+                currentTemp: currentTemp,
+                iobData: iob,
+                mealData: mealData,
+                autosensData: autosensData,
+                reservoirData: reservoir ?? 100,
+                glucose: glucose,
+                microBolusAllowed: microBolusAllowed,
+                trioCustomOrefVariables: trioCustomOrefVariables,
+                currentTime: clock
+            )
+
+            return try .success(JSONBridge.to(rawDetermination))
+
+        } catch let determinationError as DeterminationError {
+            // if we get a determination error we want to return it as a JSON
+            // object that is { "error": "some error" }
+            do {
+                let response = try JSONBridge.to(DeterminationErrorResponse(error: determinationError.localizedDescription))
+                return .success(response)
+            } catch {
+                return .failure(determinationError)
+            }
+        } catch {
+            return .failure(error)
+        }
+    }
+
+    static func meal(
+        pumphistory: JSON,
+        profile: JSON,
+        basalProfile: JSON,
+        clock: JSON,
+        carbs: JSON,
+        glucose: JSON
+    ) -> (OrefFunctionResult) {
+        do {
+            let pumpHistory = try JSONBridge.pumpHistory(from: pumphistory)
+            let profile = try JSONBridge.profile(from: profile)
+            let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
+            let clock = try JSONBridge.clock(from: clock)
+            let carbs = try JSONBridge.carbs(from: carbs)
+            let glucose = try JSONBridge.glucose(from: glucose)
+
+            let mealResult = try MealGenerator.generate(
+                pumpHistory: pumpHistory,
+                profile: profile,
+                basalProfile: basalProfile,
+                clock: clock,
+                carbHistory: carbs,
+                glucoseHistory: glucose
+            )
+
+            return try .success(JSONBridge.to(mealResult))
+        } catch {
+            return .failure(error)
+        }
+    }
+
+    static func iob(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON) -> (OrefFunctionResult) {
+        do {
+            let pumpHistory = try JSONBridge.pumpHistory(from: pumphistory)
+            let profile = try JSONBridge.profile(from: profile)
+            let clock = try JSONBridge.clock(from: clock)
+            let autosens = try JSONBridge.autosens(from: autosens)
+
+            let iobResult = try IobGenerator.generate(
+                history: pumpHistory,
+                profile: profile,
+                clock: clock,
+                autosens: autosens
+            )
+
+            return try .success(JSONBridge.to(iobResult))
+        } catch {
+            return .failure(error)
+        }
+    }
+
+    static func autosense(
+        glucose: JSON,
+        pumpHistory: JSON,
+        basalProfile: JSON,
+        profile: JSON,
+        carbs: JSON,
+        tempTargets: JSON,
+        clock: JSON,
+        includeDeviationsForTesting: Bool = false
+    ) -> (OrefFunctionResult) {
+        do {
+            let glucose = try JSONBridge.glucose(from: glucose)
+            let pumpHistory = try JSONBridge.pumpHistory(from: pumpHistory)
+            let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
+            let profile = try JSONBridge.profile(from: profile)
+            let carbs = try JSONBridge.carbs(from: carbs)
+            let tempTargets = try JSONBridge.tempTargets(from: tempTargets)
+            let clock = try JSONBridge.clock(from: clock)
+
+            // this logic is from prepare/autosens.js
+            let ratio8h = try AutosensGenerator.generate(
+                glucose: glucose,
+                pumpHistory: pumpHistory,
+                basalProfile: basalProfile,
+                profile: profile,
+                carbs: carbs,
+                tempTargets: tempTargets,
+                maxDeviations: 96,
+                clock: clock,
+                includeDeviationsForTesting: includeDeviationsForTesting
+            )
+
+            let ratio24h = try AutosensGenerator.generate(
+                glucose: glucose,
+                pumpHistory: pumpHistory,
+                basalProfile: basalProfile,
+                profile: profile,
+                carbs: carbs,
+                tempTargets: tempTargets,
+                maxDeviations: 288,
+                clock: clock,
+                includeDeviationsForTesting: includeDeviationsForTesting
+            )
+
+            let lowestRatio = ratio8h.ratio < ratio24h.ratio ? ratio8h : ratio24h
+
+            return try .success(JSONBridge.to(lowestRatio))
+        } catch {
+            return .failure(error)
+        }
+    }
+}

+ 13 - 0
Trio/Sources/APS/OpenAPSSwift/OrefFunctionResult.swift

@@ -0,0 +1,13 @@
+import Foundation
+
+enum OrefFunctionResult {
+    case success(RawJSON)
+    case failure(Error)
+
+    func returnOrThrow() throws -> RawJSON {
+        switch self {
+        case let .success(json): return json
+        case let .failure(error): throw error
+        }
+    }
+}

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

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

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

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

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

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

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

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

+ 222 - 0
Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift

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

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

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

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

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

+ 0 - 2
Trio/Sources/APS/Storage/DeterminationStorage.swift

@@ -183,8 +183,6 @@ final class BaseDeterminationStorage: DeterminationStorage, Injectable {
                         isf: self.decimal(from: orefDetermination.insulinSensitivity),
                         timestamp: orefDetermination.timestamp,
                         current_target: self.decimal(from: orefDetermination.currentTarget),
-                        insulinForManualBolus: self.decimal(from: orefDetermination.insulinForManualBolus),
-                        manualBolusErrorString: self.decimal(from: orefDetermination.manualBolusErrorString),
                         minDelta: self.decimal(from: orefDetermination.minDelta),
                         expectedDelta: self.decimal(from: orefDetermination.expectedDelta),
                         minGuardBG: nil,

+ 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
 }
@@ -602,6 +603,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()

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

@@ -68141,6 +68141,9 @@
         }
       }
     },
+    "CGM noise level too high: %lld." : {
+
+    },
     "CGM: " : {
       "localizations" : {
         "bg" : {
@@ -78960,6 +78963,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" : {
@@ -113719,6 +113732,12 @@
         }
       }
     },
+    "Error: could not determine target_bg. " : {
+
+    },
+    "Error: could not get current basal rate" : {
+
+    },
     "Error! Bolus cancellation failed with error: %@" : {
       "comment" : "Error message for canceling a bolus",
       "localizations" : {
@@ -115894,6 +115913,9 @@
         }
       }
     },
+    "EXPERIMENTAL FEATURE! Enables new, fully Swift-based algorithm version." : {
+      "comment" : "Use Swift Oref mini hint"
+    },
     "Expiration Reminder" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -129631,6 +129653,9 @@
         }
       }
     },
+    "Glucose out of range: %@." : {
+
+    },
     "Glucose Reading" : {
       "localizations" : {
         "bg" : {
@@ -174476,6 +174501,9 @@
         }
       }
     },
+    "Missing required inputs; cannot determine basal." : {
+
+    },
     "mmol/L" : {
       "comment" : "The short unit display string for millimoles of glucose per liter",
       "localizations" : {
@@ -179013,6 +179041,9 @@
         }
       }
     },
+    "No glucose delta (flat readings); cannot determine trend." : {
+
+    },
     "No Glucose Notifications will be triggered." : {
       "localizations" : {
         "bg" : {
@@ -179385,6 +179416,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",
@@ -180144,6 +180181,9 @@
         }
       }
     },
+    "No profile; cannot determine basal." : {
+
+    },
     "No recent oref algorithm determination." : {
       "localizations" : {
         "bg" : {
@@ -252124,6 +252164,9 @@
         }
       }
     },
+    "This feature is EXPERIMENTAL and not yet cleared for general use." : {
+
+    },
     "This feature resets the Autosens Ratio to neutral when you rewind your pump on the assumption that this corresponds to a site change." : {
       "localizations" : {
         "bg" : {
@@ -273306,6 +273349,9 @@
         }
       }
     },
+    "Unknown determination error." : {
+
+    },
     "Unknown Error" : {
       "localizations" : {
         "bg" : {
@@ -277093,6 +277139,9 @@
         }
       }
     },
+    "Use Swift Oref" : {
+      "comment" : "Use Swift Oref"
+    },
     "Use your Tidepool credentials to log in. If you don't have a Tidepool account, you can sign up on the login page.\n\nWhen connected, Trio uploads your glucose, carb entries, insulin (bolus and basal), pump settings, and therapy settings to Tidepool.\n\nTherapy settings include basal schedules, carb ratios, insulin sensitivities, and glucose targets." : {
       "localizations" : {
         "bg" : {
@@ -282482,6 +282531,9 @@
         }
       }
     },
+    "We're building a faster and more maintainable Swift version of the algorithm (Oref) that runs in Trio. It's faster, more accurate and improves Trio for everyone." : {
+
+    },
     "Week" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -284605,6 +284657,9 @@
         }
       }
     },
+    "When enabled, Trio will no longer use the old JavaScript-based algorithm that runs virtualized on your phone. Instead, it will use a fully Swift-based algorithm." : {
+
+    },
     "When Remote Control is enabled, you can send boluses, overrides, temporary targets, carbs, and other commands to Trio via push notifications." : {
       "localizations" : {
         "bg" : {
@@ -288756,6 +288811,9 @@
         }
       }
     },
+    "You can disable this feature anytime." : {
+
+    },
     "You can influence the adjustments made by Dynamic ISF primarily by adjusting Autosens Max, Autosens Min, and Adjustment Factor. Other settings also influence Dynamic ISF's response, such as Glucose Target, Profile ISF, Peak Insulin Time, and Weighted Average of TDD." : {
       "localizations" : {
         "bg" : {

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

@@ -1,7 +1,24 @@
 import Foundation
 
 struct Autosens: JSON {
+    struct DebugInfo: Codable {
+        let iobClock: Date
+        let bgi: Decimal
+        let iobActivity: Decimal
+        let deltaGlucose: Decimal
+        let deviation: Decimal
+        let stateType: String
+        // COB state for debugging state transitions
+        var mealCOB: Decimal?
+        var absorbing: Bool?
+        var mealCarbs: Decimal?
+        var mealStartCounter: Int?
+    }
+
     let ratio: Decimal
     let newisf: Decimal?
+    var deviationsUnsorted: [Decimal]?
     var timestamp: Date?
+    var debugInfo: [DebugInfo]?
+    var error: String?
 }

+ 7 - 1
Trio/Sources/Models/BloodGlucose.swift

@@ -104,8 +104,14 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
         }
 
         direction = try container.decodeIfPresent(Direction.self, forKey: .direction)
-        date = try container.decode(Decimal.self, forKey: .date)
         dateString = try container.decode(Date.self, forKey: .dateString)
+
+        do {
+            date = try container.decode(Decimal.self, forKey: .date)
+        } catch {
+            date = Decimal(dateString.timeIntervalSince1970 * 1000).rounded()
+        }
+
         unfiltered = try container.decodeIfPresent(Decimal.self, forKey: .unfiltered)
         filtered = try container.decodeIfPresent(Decimal.self, forKey: .filtered)
         noise = try container.decodeIfPresent(Int.self, forKey: .noise)

+ 8 - 7
Trio/Sources/Models/Determination.swift

@@ -1,14 +1,18 @@
 import Foundation
 
+struct DeterminationErrorResponse: JSON, Equatable {
+    let error: String
+}
+
 struct Determination: JSON, Equatable {
     let id: UUID?
     var reason: String
-    let units: Decimal?
-    let insulinReq: Decimal?
+    var units: Decimal?
+    var insulinReq: Decimal?
     var eventualBG: Int?
     let sensitivityRatio: Decimal?
-    let rate: Decimal?
-    let duration: Decimal?
+    var rate: Decimal?
+    var duration: Decimal?
     let iob: Decimal?
     let cob: Decimal?
     var predictions: Predictions?
@@ -25,8 +29,6 @@ struct Determination: JSON, Equatable {
     var tdd: Decimal?
 
     var current_target: Decimal?
-    let insulinForManualBolus: Decimal?
-    let manualBolusErrorString: Decimal?
     var minDelta: Decimal?
     var expectedDelta: Decimal?
     var minGuardBG: Decimal?
@@ -66,7 +68,6 @@ extension Determination {
         case current_target
         case tdd = "TDD"
         case insulinForManualBolus
-        case manualBolusErrorString
         case minDelta
         case expectedDelta
         case minGuardBG

+ 0 - 5
Trio/Sources/Models/PumpHistoryEvent.swift

@@ -57,10 +57,6 @@ enum EventType: String, JSON {
     case bolus = "Bolus"
     case smb = "SMB"
     case isExternal = "External Insulin"
-    case mealBolus = "Meal Bolus"
-    case correctionBolus = "Correction Bolus"
-    case snackBolus = "Snack Bolus"
-    case bolusWizard = "BolusWizard"
     case tempBasal = "TempBasal"
     case tempBasalDuration = "TempBasalDuration"
     case pumpSuspend = "PumpSuspend"
@@ -69,7 +65,6 @@ enum EventType: String, JSON {
     case pumpBattery = "PumpBattery"
     case rewind = "Rewind"
     case prime = "Prime"
-    case journalCarbs = "JournalEntryMealMarker"
 
     case nsTempBasal = "Temp Basal"
     case nsCarbCorrection = "Carb Correction"

+ 1 - 1
Trio/Sources/Models/TrioCustomOrefVariables.swift

@@ -42,7 +42,7 @@ struct TrioCustomOrefVariables: JSON, Equatable {
         start: Decimal,
         end: Decimal,
         smbMinutes: Decimal,
-        uamMinutes: Decimal,
+        uamMinutes: Decimal
     ) {
         self.average_total_data = average_total_data
         self.weightedAverage = weightedAverage

+ 5 - 0
Trio/Sources/Models/TrioSettings.swift

@@ -76,6 +76,7 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var requireAdjustmentsConfirmation: Bool = false
+    var useSwiftOref: Bool = false
 
     /// Selected Garmin watchface (Trio or SwissAlpine)
     var garminWatchface: GarminWatchface = .trio
@@ -363,6 +364,10 @@ extension TrioSettings: Decodable {
             settings.requireAdjustmentsConfirmation = requireAdjustmentsConfirmation
         }
 
+        if let useSwiftOref = try? container.decode(Bool.self, forKey: .useSwiftOref) {
+            settings.useSwiftOref = useSwiftOref
+        }
+
         if let garminWatchface = try? container.decode(GarminWatchface.self, forKey: .garminWatchface) {
             settings.garminWatchface = garminWatchface
         }

+ 7 - 1
Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift

@@ -11,6 +11,7 @@ extension AlgorithmAdvancedSettings {
 
         var units: GlucoseUnits = .mgdL
 
+        // settings
         @Published var maxDailySafetyMultiplier: Decimal = 3
         @Published var currentBasalSafetyMultiplier: Decimal = 4
         @Published var useCustomPeakTime: Bool = false
@@ -21,6 +22,8 @@ extension AlgorithmAdvancedSettings {
         @Published var remainingCarbsFraction: Decimal = 1.0
         @Published var remainingCarbsCap: Decimal = 90
         @Published var noisyCGMTargetMultiplier: Decimal = 1.3
+        @Published var useSwiftOref: Bool = false
+        // preference
         @Published var insulinActionCurve: Decimal = 10
         @Published var smbDeliveryRatio: Decimal = 0.5
         @Published var smbInterval: Decimal = 3
@@ -45,9 +48,12 @@ extension AlgorithmAdvancedSettings {
             subscribePreferencesSetting(\.remainingCarbsCap, on: $remainingCarbsCap) { remainingCarbsCap = $0 }
             subscribePreferencesSetting(\.noisyCGMTargetMultiplier, on: $noisyCGMTargetMultiplier) {
                 noisyCGMTargetMultiplier = $0 }
+            subscribeSetting(\.useSwiftOref, on: $useSwiftOref) {
+                useSwiftOref = $0 }
+            subscribePreferencesSetting(\.smbDeliveryRatio, on: $smbDeliveryRatio) { smbDeliveryRatio = $0 }
+            subscribePreferencesSetting(\.smbInterval, on: $smbInterval) { smbInterval = $0 }
             subscribePreferencesSetting(\.smbDeliveryRatio, on: $smbDeliveryRatio) { smbDeliveryRatio = $0 }
             subscribePreferencesSetting(\.smbInterval, on: $smbInterval) { smbInterval = $0 }
-
             insulinActionCurve = pumpSettings.insulinActionCurve
         }
 

+ 34 - 0
Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift

@@ -390,6 +390,40 @@ extension AlgorithmAdvancedSettings {
                         Text("Note: A CGM is considered noisy when it provides inconsistent readings.")
                     }
                 )
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.useSwiftOref,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0.map { AnyView($0) }
+                            hintLabel = NSLocalizedString("Use Swift Oref", comment: "Use Swift Oref")
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: NSLocalizedString("Use Swift Oref", comment: "Use Swift Oref"),
+                    miniHint: String(
+                        localized: "EXPERIMENTAL FEATURE! Enables new, fully Swift-based algorithm version.",
+                        comment: "Use Swift Oref mini hint"
+                    ),
+                    verboseHint:
+                    VStack(alignment: .leading, spacing: 10) {
+                        Text("Default: OFF").bold()
+                        Text("This feature is EXPERIMENTAL and not yet cleared for general use.").bold().foregroundStyle(.orange)
+                        Text(
+                            "We're building a faster and more maintainable Swift version of the algorithm (Oref) that runs in Trio. It's faster, more accurate and improves Trio for everyone."
+                        )
+                        Text(
+                            "When enabled, Trio will no longer use the old JavaScript-based algorithm that runs virtualized on your phone. Instead, it will use a fully Swift-based algorithm."
+                        )
+
+                        Text(
+                            "You can disable this feature anytime."
+                        )
+                    }
+                )
             }
             .listSectionSpacing(sectionSpacing)
             .sheet(isPresented: $shouldDisplayHint) {

+ 5 - 0
Trio/Sources/Modules/Home/View/Header/LoopStatusView.swift

@@ -288,6 +288,11 @@ struct LoopStatusView: View {
             tags.append("Smoothing: On")
         }
 
+        // FIXME: remove this before feat/dev-oref-swift is merged to dev
+        if state.settingsManager.settings.useSwiftOref {
+            tags.append("Swift Oref")
+        }
+
         if let currentTDD = state.fetchedTDDs.first?.totalDailyDose, currentTDD != 0 {
             tags.append("TDD: \(currentTDD)")
         }

+ 0 - 2
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -884,8 +884,6 @@ extension Treatments.StateModel {
                     carbsReq: 0,
                     temp: nil,
                     reservoir: 0,
-                    insulinForManualBolus: 0,
-                    manualBolusErrorString: 0,
                     carbRatio: 0,
                     received: false
                 )

+ 2 - 1
Trio/Sources/Views/TagCloudView.swift

@@ -59,7 +59,8 @@ struct TagCloudView: View {
             switch textTag {
             case textTag where textTag.contains("SMB Delivery Ratio:"):
                 return .uam
-            case textTag where textTag.contains("Bolus"):
+            case textTag where textTag.contains("Bolus"),
+                 textTag where textTag.contains("Swift Oref"): // FIXME: remove this before feat/dev-oref-swift is merged to dev
                 return .green
             case textTag where textTag.contains("TDD:"),
                  textTag where textTag.contains("tdd_factor"),

+ 42 - 0
TrioTests/CoreDataTests/GlucoseStorageTests.swift

@@ -237,4 +237,46 @@ import Testing
         #expect(storedEntries?.first?.glucose == 100, "Normal glucose value should match")
         #expect(storage.alarm == nil, "Should not trigger any alarm")
     }
+
+    /* Commenting out while we don't have getGlucoseStatus defined
+     @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)
+     }*/
 }

+ 8 - 0
TrioTests/Info.plist

@@ -14,5 +14,13 @@
 	<string>$(PRODUCT_NAME)</string>
 	<key>CFBundlePackageType</key>
 	<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
+	<key>EnableReplayTests</key>
+	<string>$(ENABLE_REPLAY_TESTS)</string>
+	<key>ReplayTestTimezone</key>
+	<string>$(REPLAY_TEST_TIMEZONE)</string>
+	<key>HttpFilesOffset</key>
+	<string>$(HTTP_FILES_OFFSET)</string>
+	<key>HttpFilesLength</key>
+	<string>$(HTTP_FILES_LENGTH)</string>
 </dict>
 </plist>

+ 0 - 2
TrioTests/JSONImporterTests.swift

@@ -276,8 +276,6 @@ class BundleReference {}
         #expect(determination.reservoir == Decimal(string: "3735928559").map(NSDecimalNumber.init))
         #expect(determination.insulinSensitivity == Decimal(string: "4.6").map(NSDecimalNumber.init))
         #expect(determination.currentTarget == Decimal(string: "94").map(NSDecimalNumber.init))
-        #expect(determination.insulinForManualBolus == Decimal(string: "0.8").map(NSDecimalNumber.init))
-        #expect(determination.manualBolusErrorString == Decimal(string: "0").map(NSDecimalNumber.init))
         #expect(determination.minDelta == NSDecimalNumber(5))
         #expect(determination.expectedDelta == Decimal(string: "-5.9").map(NSDecimalNumber.init))
         #expect(determination.threshold == Decimal(string: "3.7").map(NSDecimalNumber.init))

+ 175 - 0
TrioTests/OpenAPSSwiftTests/AutosensTests.swift

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

Разлика између датотеке није приказан због своје велике величине
+ 1174 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalAggressiveDosingTests.swift


+ 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; 20 / 24 = 0.8333; result = 2 + 0.8333 = 2.8333 -> rounded to 2.8
+        #expect(result == 2.8)
+    }
+
+    /// 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; 24 / 24 = 1 → result = 1.5 + 1 = 2.5
+        #expect(result == 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; 40 / 24 = 1.666... → result = 0 + 1.666... = 1.7
+        #expect(result == 1.7)
+    }
+
+    /// 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; -20 / 24 = -0.8333... → result = 0 + (-0.8333...) = -0.8
+        #expect(result == -0.8)
+    }
+
+    /// Fractional delta is truncated before block division.
+    @Test("fractional delta truncation") func fractionalDelta() {
+        let result = DeterminationGenerator.calculateExpectedDelta(
+            targetGlucose: Decimal(125.5),
+            eventualGlucose: Decimal(100),
+            glucoseImpact: Decimal(0)
+        )
+        // delta = 25.5; 25.5 / 24 = 1.0625 → result = 1.1
+        #expect(result == 1.1)
+    }
+
+    /// 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(1.27)
+        )
+        // delta=24 → adjustment=1; 1.27+1=2.27 → rounded to 2.3
+        #expect(result == 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; -230 / 24 = -9.5833... → result = -9.6
+        #expect(result == -9.6)
+    }
+
+    /// 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; 81 / 24 = 3.375 → result = 3.4
+        #expect(result == 3.4)
+    }
+
+    /// 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(1.7)
+        )
+        // delta = 2; 2 / 24 = 0.0833... → result = 1.7 + 0.0833... = 1.8
+        #expect(result == 1.8)
+    }
+}

+ 573 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalEarlyExitTests.swift

@@ -0,0 +1,573 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("DetermineBasal early exits before core dosing logic") struct DetermineBasalEarlyExitTests {
+    private func createDefaultInputs(currentTime: Date = Date()) -> (
+        profile: Profile,
+        preferences: Preferences,
+        currentTemp: TempBasal,
+        iobData: [IobResult],
+        mealData: ComputedCarbs,
+        autosensData: Autosens,
+        reservoirData: Decimal,
+        glucoseStatus: GlucoseStatus,
+        microBolusAllowed: Bool,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        currentTime: Date
+    ) {
+        var profile = Profile()
+        profile.maxIob = 2.5
+        profile.dia = 3
+        profile.currentBasal = 0.9
+        profile.maxDailyBasal = 1.3
+        profile.maxBasal = 3.5
+        profile.maxBg = 120
+        profile.minBg = 110
+        profile.sens = 40
+        profile.carbRatio = 10
+        profile.thresholdSetting = 80
+        profile.temptargetSet = false
+        profile.bolusIncrement = 0.1
+        profile.useCustomPeakTime = false
+        profile.curve = .rapidActing
+
+        var preferences = Preferences()
+        preferences.useNewFormula = false
+        preferences.sigmoid = false
+        preferences.adjustmentFactor = 0.8
+        preferences.adjustmentFactorSigmoid = 0.5
+        preferences.curve = .rapidActing
+        preferences.useCustomPeakTime = false
+
+        let currentTemp = TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: currentTime)
+
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: currentTime,
+            iobWithZeroTemp: IobResult.IobWithZeroTemp(
+                iob: 0,
+                activity: 0,
+                basaliob: 0,
+                bolusiob: 0,
+                netbasalinsulin: 0,
+                bolusinsulin: 0,
+                time: currentTime
+            ),
+            lastBolusTime: nil,
+            lastTemp: IobResult.LastTemp(
+                rate: 0,
+                timestamp: currentTime,
+                started_at: currentTime,
+                date: UInt64(currentTime.timeIntervalSince1970 * 1000),
+                duration: 30
+            )
+        )]
+
+        let mealData = ComputedCarbs(
+            carbs: 0,
+            mealCOB: 0,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [0, 0, 0, 0, 0],
+            lastCarbTime: 0
+        )
+
+        let autosensData = Autosens(ratio: 1.0, newisf: nil)
+
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 115,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0.1,
+            date: currentTime,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let trioCustomOrefVariables = TrioCustomOrefVariables(
+            average_total_data: 0,
+            weightedAverage: 0,
+            currentTDD: 0,
+            past2hoursAverage: 0,
+            date: currentTime,
+            overridePercentage: 100,
+            useOverride: false,
+            duration: 0,
+            unlimited: false,
+            overrideTarget: 0,
+            smbIsOff: false,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: false,
+            cr: false,
+            smbIsScheduledOff: false,
+            start: 0,
+            end: 0,
+            smbMinutes: 30,
+            uamMinutes: 30
+        )
+
+        return (
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: 100,
+            glucoseStatus: glucoseStatus,
+            microBolusAllowed: true,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+    }
+
+    // Test 1 from JS
+    @Test("should fail if current_basal is missing") func missingCurrentBasal() throws {
+        var (
+            profile,
+            preferences,
+            currentTemp,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            microBolusAllowed,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        profile.currentBasal = nil
+        profile.basalprofile = [] // ensure basalFor also returns nil
+
+        #expect(throws: DeterminationError.missingCurrentBasal) {
+            _ = try DeterminationGenerator.determineBasal(
+                profile: profile,
+                preferences: preferences,
+                currentTemp: currentTemp,
+                iobData: iobData,
+                mealData: mealData,
+                autosensData: autosensData,
+                reservoirData: reservoirData,
+                glucoseStatus: glucoseStatus,
+                microBolusAllowed: microBolusAllowed,
+                trioCustomOrefVariables: trioCustomOrefVariables,
+                currentTime: currentTime
+            )
+        }
+    }
+
+    // Test 2 from JS
+    @Test("should cancel high temp if BG is 38") func cancelHighTempBG38() throws {
+        let (
+            profile,
+            preferences,
+            _,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            _,
+            microBolusAllowed,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 38,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0.1,
+            date: currentTime,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let currentTemp = TempBasal(duration: 30, rate: 1.5, temp: .absolute, timestamp: currentTime)
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            microBolusAllowed: microBolusAllowed,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == profile.currentBasal)
+        #expect(result?.duration == 30)
+        #expect(result?.reason.contains("Replacing high temp basal") == true)
+    }
+
+    // Test 3 from JS
+    @Test("should shorten long zero temp if BG data is too old") func shortenLongZeroTempTooOldBG() throws {
+        let (
+            profile,
+            preferences,
+            _,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            _,
+            microBolusAllowed,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        let glucoseTime = currentTime.addingTimeInterval(-15 * 60)
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 115,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0.1,
+            date: glucoseTime,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let currentTemp = TempBasal(duration: 60, rate: 0, temp: .absolute, timestamp: currentTime)
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            microBolusAllowed: microBolusAllowed,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect(result?.duration == 30)
+        #expect(result?.reason.contains("Shortening") == true)
+    }
+
+    // Test 4 from JS
+    @Test("should do nothing if BG is too old and temp is not high") func doNothingOldBGNotHighTemp() throws {
+        let (
+            profile,
+            preferences,
+            _,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            _,
+            microBolusAllowed,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        let glucoseTime = currentTime.addingTimeInterval(-15 * 60)
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 115,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0.1,
+            date: glucoseTime,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let currentTemp = TempBasal(duration: 30, rate: 0.5, temp: .absolute, timestamp: currentTime)
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            microBolusAllowed: microBolusAllowed,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == nil)
+        #expect(result?.duration == nil)
+        #expect(result?.reason.contains("doing nothing") == true)
+    }
+
+    // Test 5 from JS
+    @Test("should error if target_bg cannot be determined") func errorIfTargetBGMissing() throws {
+        var (
+            profile,
+            preferences,
+            currentTemp,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            microBolusAllowed,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        profile.minBg = nil
+
+        #expect(throws: DeterminationError.invalidProfileTarget) {
+            _ = try DeterminationGenerator.determineBasal(
+                profile: profile,
+                preferences: preferences,
+                currentTemp: currentTemp,
+                iobData: iobData,
+                mealData: mealData,
+                autosensData: autosensData,
+                reservoirData: reservoirData,
+                glucoseStatus: glucoseStatus,
+                microBolusAllowed: microBolusAllowed,
+                trioCustomOrefVariables: trioCustomOrefVariables,
+                currentTime: currentTime
+            )
+        }
+    }
+
+    // Test 6 from JS
+    @Test("should cancel temp if currenttemp and lastTemp from pumphistory do not match") func cancelTempMismatch() throws {
+        let (
+            profile,
+            preferences,
+            _,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            microBolusAllowed,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        let currentTemp = TempBasal(duration: 30, rate: 1.5, temp: .absolute, timestamp: currentTime)
+
+        let lastTempTime = currentTime.addingTimeInterval(-15 * 60)
+        let lastTemp = IobResult.LastTemp(
+            rate: 1.0,
+            timestamp: lastTempTime,
+            started_at: lastTempTime,
+            date: UInt64(lastTempTime.timeIntervalSince1970 * 1000),
+            duration: 30
+        )
+
+        var mutableIobData = iobData
+        mutableIobData[0].lastTemp = lastTemp
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: mutableIobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            microBolusAllowed: microBolusAllowed,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect(result?.duration == 0)
+        // Note: In swift we use a different reason then JS
+        #expect(
+            result?
+                .reason ==
+                "Warning: currenttemp rate 1.5 != lastTemp rate 1 from pumphistory; canceling temp"
+        )
+    }
+
+    // Test 7 from JS
+    @Test("should cancel temp if lastTemp from pumphistory ended long ago") func cancelTempOldLastTemp() throws {
+        let (
+            profile,
+            preferences,
+            _,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            microBolusAllowed,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        let currentTemp = TempBasal(duration: 30, rate: 1.5, temp: .absolute, timestamp: currentTime)
+
+        let lastTempTime = currentTime.addingTimeInterval(-40 * 60)
+        let lastTemp = IobResult.LastTemp(
+            rate: 1.5,
+            timestamp: lastTempTime,
+            started_at: lastTempTime,
+            date: UInt64(lastTempTime.timeIntervalSince1970 * 1000),
+            duration: 30
+        )
+
+        var mutableIobData = iobData
+        mutableIobData[0].lastTemp = lastTemp
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: mutableIobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            microBolusAllowed: microBolusAllowed,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect(result?.duration == 0)
+        // Note: In swift we use a different reason then JS
+        #expect(
+            result?
+                .reason == "Warning: currenttemp running but lastTemp from pumphistory ended 10m ago; canceling temp"
+        )
+    }
+
+    // Test 8 from JS
+    @Test("should throw error if eventualBG cannot be calculated") func eventualBGNaN() throws {
+        var (
+            profile,
+            preferences,
+            currentTemp,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            microBolusAllowed,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+        profile.sens = .nan
+
+        #expect(throws: DeterminationError.eventualGlucoseCalculationError(sensitivity: .nan, deviation: .nan)) {
+            _ = try DeterminationGenerator.determineBasal(
+                profile: profile,
+                preferences: preferences,
+                currentTemp: currentTemp,
+                iobData: iobData,
+                mealData: mealData,
+                autosensData: autosensData,
+                reservoirData: reservoirData,
+                glucoseStatus: glucoseStatus,
+                microBolusAllowed: microBolusAllowed,
+                trioCustomOrefVariables: trioCustomOrefVariables,
+                currentTime: currentTime
+            )
+        }
+    }
+
+    // Test 9 from JS
+    @Test("should low-temp if BG is below threshold") func lowGlucoseSuspend() throws {
+        let (
+            profile,
+            preferences,
+            currentTemp,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            _,
+            microBolusAllowed,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 70,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0.1,
+            date: currentTime,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            microBolusAllowed: microBolusAllowed,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect((result?.duration ?? 0) >= 30)
+        #expect(result?.reason.contains("minGuardBG") == true)
+    }
+
+    // Test 10 from JS
+    @Test("should cancel temp before the hour if not doing SMB") func skipNeutralTemp() throws {
+        // Create a date that is 56 minutes past the hour
+        var components = Calendar.current.dateComponents(in: .current, from: Date())
+        components.minute = 56
+        let currentTime = Calendar.current.date(from: components)!
+
+        var (
+            profile,
+            preferences,
+            currentTemp,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            microBolusAllowed,
+            trioCustomOrefVariables,
+            _
+        ) = createDefaultInputs(currentTime: currentTime)
+
+        profile.skipNeutralTemps = true
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            microBolusAllowed: microBolusAllowed,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect(result?.duration == 0)
+        #expect(result?.reason.contains("Canceling temp") == true)
+    }
+}

+ 420 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalEnableSmbTests.swift

@@ -0,0 +1,420 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("DosingEngine: shouldEnableSmb Tests") struct DetermineBasalEnableSmbTests {
+    /// Helper to create a default set of inputs.
+    /// Each test can then modify the specific properties relevant to its case.
+    private func createDefaultInputs() -> (
+        profile: Profile,
+        meal: ComputedCarbs,
+        currentGlucose: Decimal,
+        adjustedTargetGlucose: Decimal,
+        minGuardGlucose: Decimal,
+        threshold: Decimal,
+        glucoseStatus: GlucoseStatus,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        clock: Date
+    ) {
+        var profile = Profile()
+        // Ensure default is false so we can test enabling conditions.
+        profile.enableSMBAlways = false
+        profile.temptargetSet = false
+
+        let meal = ComputedCarbs(
+            carbs: 0,
+            mealCOB: 0,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [],
+            lastCarbTime: Date().timeIntervalSince1970
+        )
+
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 120,
+            noise: 0,
+            shortAvgDelta: 0,
+            longAvgDelta: 0,
+            date: Date(),
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let trioCustomOrefVariables = TrioCustomOrefVariables(
+            average_total_data: 0,
+            weightedAverage: 0,
+            currentTDD: 0,
+            past2hoursAverage: 0,
+            date: Date(),
+            overridePercentage: 100,
+            useOverride: false,
+            duration: 0,
+            unlimited: false,
+            overrideTarget: 0,
+            smbIsOff: false,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: false,
+            cr: false,
+            smbIsScheduledOff: false,
+            start: 0,
+            end: 0,
+            smbMinutes: 0,
+            uamMinutes: 0
+        )
+
+        return (
+            profile: profile,
+            meal: meal,
+            currentGlucose: 120,
+            adjustedTargetGlucose: 100,
+            minGuardGlucose: 110,
+            threshold: 70,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            clock: Date()
+        )
+    }
+
+    // MARK: - Disabling Conditions
+
+    @Test("Should return false by default with no enabling preferences") func defaultIsFalse() throws {
+        let inputs = createDefaultInputs()
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == false)
+    }
+
+    @Test("Should disable SMB when smbIsOff is true") func disableWhenSmbIsOff() throws {
+        var inputs = createDefaultInputs()
+        inputs.trioCustomOrefVariables.smbIsOff = true
+        inputs.profile.enableSMBAlways = true // Ensure smbIsOff takes precedence
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == false)
+    }
+
+    @Test("Should disable SMB with high temp target when not allowed") func disableWithHighTempTarget() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.allowSMBWithHighTemptarget = false
+        inputs.profile.temptargetSet = true
+        inputs.adjustedTargetGlucose = 120
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == false)
+    }
+
+    @Test("Should disable SMB when minGuardGlucose is below threshold") func disableWhenMinGuardBelowThreshold() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true // Enable SMB initially to test the safety override
+        inputs.minGuardGlucose = 65
+        inputs.threshold = 70
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == false)
+        #expect(decision.minGuardGlucose == 65)
+    }
+
+    @Test("Should disable SMB when maxDelta is too high") func disableWhenMaxDeltaTooHigh() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true // Enable SMB initially
+        inputs.profile.maxDeltaBgThreshold = 0.2
+        inputs.currentGlucose = 100
+        // Set maxDelta to be > 20% of currentGlucose
+        inputs.glucoseStatus = GlucoseStatus(
+            delta: 21,
+            glucose: 100,
+            noise: 0,
+            shortAvgDelta: 5,
+            longAvgDelta: 5,
+            date: Date(),
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == false)
+        #expect(decision.reason != nil)
+    }
+
+    // MARK: - Enabling Conditions
+
+    @Test("Should enable SMB when enableSMBAlways is true") func enableWhenAlwaysOn() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == true)
+    }
+
+    @Test("Should enable SMB with COB") func enableWithCob() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBWithCOB = true
+        inputs.meal = ComputedCarbs(
+            carbs: 20,
+            mealCOB: 10,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [],
+            lastCarbTime: Date().timeIntervalSince1970
+        )
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == true)
+    }
+
+    @Test("Should enable SMB after carbs") func enableAfterCarbs() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAfterCarbs = true
+        inputs.meal = ComputedCarbs(
+            carbs: 20,
+            mealCOB: 0,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [],
+            lastCarbTime: Date().timeIntervalSince1970
+        )
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == true)
+    }
+
+    @Test("Should enable SMB with low temp target") func enableWithLowTempTarget() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBWithTemptarget = true
+        inputs.profile.temptargetSet = true
+        inputs.adjustedTargetGlucose = 90
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == true)
+    }
+
+    @Test("Should enable SMB for high BG") func enableWithHighBg() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBHighBg = true
+        inputs.profile.enableSMBHighBgTarget = 140
+        inputs.currentGlucose = 145
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == true)
+    }
+
+    // MARK: - Scheduled Off Tests
+
+    @Test("Scheduled Off (Normal): should disable SMB inside the window") func scheduledOffNormal_Inside() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true // Ensure schedule is the only reason for failure
+        inputs.trioCustomOrefVariables.smbIsScheduledOff = true
+        inputs.trioCustomOrefVariables.start = 9 // 9 AM
+        inputs.trioCustomOrefVariables.end = 17 // 5 PM
+        inputs.clock = Calendar.current.date(bySettingHour: 14, minute: 0, second: 0, of: Date())!
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == false)
+    }
+
+    @Test("Scheduled Off (Normal): should NOT disable SMB outside the window") func scheduledOffNormal_Outside() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true
+        inputs.trioCustomOrefVariables.smbIsScheduledOff = true
+        inputs.trioCustomOrefVariables.start = 9 // 9 AM
+        inputs.trioCustomOrefVariables.end = 17 // 5 PM
+        inputs.clock = Calendar.current.date(bySettingHour: 18, minute: 0, second: 0, of: Date())!
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == true)
+    }
+
+    @Test(
+        "Scheduled Off (Wrapping): should disable SMB inside the window (after midnight)"
+    ) func scheduledOffWrapping_InsideAfterMidnight() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true
+        inputs.trioCustomOrefVariables.smbIsScheduledOff = true
+        inputs.trioCustomOrefVariables.start = 22 // 10 PM
+        inputs.trioCustomOrefVariables.end = 6 // 6 AM
+        inputs.clock = Calendar.current.date(bySettingHour: 2, minute: 0, second: 0, of: Date())!
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == false)
+    }
+
+    @Test(
+        "Scheduled Off (Wrapping): should disable SMB inside the window (before midnight)"
+    ) func scheduledOffWrapping_InsideBeforeMidnight() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true
+        inputs.trioCustomOrefVariables.smbIsScheduledOff = true
+        inputs.trioCustomOrefVariables.start = 22 // 10 PM
+        inputs.trioCustomOrefVariables.end = 6 // 6 AM
+        inputs.clock = Calendar.current.date(bySettingHour: 23, minute: 0, second: 0, of: Date())!
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == false)
+    }
+
+    @Test("Scheduled Off (Wrapping): should NOT disable SMB outside the window") func scheduledOffWrapping_Outside() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true
+        inputs.trioCustomOrefVariables.smbIsScheduledOff = true
+        inputs.trioCustomOrefVariables.start = 22 // 10 PM
+        inputs.trioCustomOrefVariables.end = 6 // 6 AM
+        inputs.clock = Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: Date())!
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == true)
+    }
+
+    @Test("Scheduled Off (All Day): should disable SMB") func scheduledOffAllDay() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true
+        inputs.trioCustomOrefVariables.smbIsScheduledOff = true
+        inputs.trioCustomOrefVariables.start = 0
+        inputs.trioCustomOrefVariables.end = 0
+        inputs.clock = Calendar.current.date(bySettingHour: 15, minute: 0, second: 0, of: Date())!
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == false)
+    }
+
+    @Test("Scheduled Off (Single Hour): should disable SMB inside the window") func scheduledOffSingleHour_Inside() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true
+        inputs.trioCustomOrefVariables.smbIsScheduledOff = true
+        inputs.trioCustomOrefVariables.start = 11 // 11 AM
+        inputs.trioCustomOrefVariables.end = 11 // 11 AM
+        inputs.clock = Calendar.current.date(bySettingHour: 11, minute: 30, second: 0, of: Date())!
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == false)
+    }
+
+    @Test("Scheduled Off (Single Hour): should NOT disable SMB outside the window") func scheduledOffSingleHour_Outside() throws {
+        var inputs = createDefaultInputs()
+        inputs.profile.enableSMBAlways = true
+        inputs.trioCustomOrefVariables.smbIsScheduledOff = true
+        inputs.trioCustomOrefVariables.start = 11 // 11 AM
+        inputs.trioCustomOrefVariables.end = 11 // 11 AM
+        inputs.clock = Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: Date())!
+
+        let decision = try DosingEngine.makeSMBDosingDecision(
+            profile: inputs.profile, meal: inputs.meal, currentGlucose: inputs.currentGlucose,
+            adjustedTargetGlucose: inputs.adjustedTargetGlucose,
+            minGuardGlucose: inputs.minGuardGlucose,
+            threshold: inputs.threshold, glucoseStatus: inputs.glucoseStatus,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables, clock: inputs.clock
+        )
+        #expect(decision.isEnabled == true)
+    }
+}

+ 131 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift

@@ -0,0 +1,131 @@
+
+import Foundation
+import Testing
+@testable import Trio
+
+/// These tests should be an exact copy of the JS tests here:
+/// - https://github.com/kingst/trio-oref/blob/dev-fixes-for-swift-comparison/tests/determine-basal-eventual-or-forecast-glucose-less-than-max.test.js
+@Suite("DosingEngine.eventualOrForecastGlucoseLessThanMax") struct DetermineBasalEventualOrForecastGlucoseLessThanMaxTests {
+    private func defaultProfile() -> Profile {
+        var profile = Profile()
+        profile.maxBg = 120
+        profile.currentBasal = 1.0
+        profile.maxDailyBasal = 3.5
+        profile.maxBasal = 1.5
+        profile.outUnits = .mgdL
+        return profile
+    }
+
+    private func callEventualOrForecastGlucoseLessThanMax(
+        eventualGlucose: Decimal = 110,
+        maxGlucose: Decimal? = nil,
+        minPredictedGlucose: Decimal = 115,
+        currentTemp: TempBasal = TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: Date()),
+        basal: Decimal? = nil,
+        smbIsEnabled: Bool = false,
+        profile: Profile? = nil,
+        determination: Determination? = nil
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        let testProfile = profile ?? defaultProfile()
+        let testDetermination = determination ?? Determination(
+            id: nil,
+            reason: "",
+            units: nil,
+            insulinReq: nil,
+            eventualBG: nil,
+            sensitivityRatio: nil,
+            rate: nil,
+            duration: nil,
+            iob: nil,
+            cob: nil,
+            predictions: nil,
+            deliverAt: nil,
+            carbsReq: nil,
+            temp: nil,
+            bg: nil,
+            reservoir: nil,
+            isf: nil,
+            timestamp: nil,
+            tdd: nil,
+            current_target: nil,
+            minDelta: nil,
+            expectedDelta: nil,
+            minGuardBG: nil,
+            minPredBG: nil,
+            threshold: nil,
+            carbRatio: nil,
+            received: nil
+        )
+
+        return try DosingEngine.eventualOrForecastGlucoseLessThanMax(
+            eventualGlucose: eventualGlucose,
+            maxGlucose: maxGlucose ?? testProfile.maxBg!,
+            minForecastGlucose: minPredictedGlucose,
+            currentTemp: currentTemp,
+            basal: basal ?? testProfile.currentBasal!,
+            smbIsEnabled: smbIsEnabled,
+            profile: testProfile,
+            determination: testDetermination
+        )
+    }
+
+    @Test("Guard: not less than max glucose") func testNotLessThanMaxGlucose() throws {
+        let (shouldSet, determination) = try callEventualOrForecastGlucoseLessThanMax(
+            eventualGlucose: 120,
+            maxGlucose: 120,
+            minPredictedGlucose: 125
+        )
+        #expect(shouldSet == false)
+        #expect(determination.reason == "")
+    }
+
+    @Test("Guard: SMB is enabled") func testSmbIsEnabled() throws {
+        let (shouldSet, determination) = try callEventualOrForecastGlucoseLessThanMax(smbIsEnabled: true)
+        #expect(shouldSet == false)
+        #expect(determination.reason == "")
+    }
+
+    @Test("Continue current temp") func testContinueCurrentTemp() throws {
+        let profile = defaultProfile()
+        let currentTemp = TempBasal(duration: 20, rate: profile.currentBasal!, temp: .absolute, timestamp: Date())
+        let (shouldSet, determination) = try callEventualOrForecastGlucoseLessThanMax(
+            currentTemp: currentTemp,
+            basal: profile.currentBasal!,
+            profile: profile
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == nil) // No change
+        #expect(determination.reason.contains("temp \(currentTemp.rate) ~ req \(profile.currentBasal!)U/hr."))
+    }
+
+    @Test("Set new temp") func testSetNewTemp() throws {
+        let profile = defaultProfile()
+        let currentTemp = TempBasal(duration: 10, rate: 1.0, temp: .absolute, timestamp: Date())
+        let basal: Decimal = 1.2
+        let (shouldSet, determination) = try callEventualOrForecastGlucoseLessThanMax(
+            currentTemp: currentTemp,
+            basal: basal,
+            profile: profile
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == basal)
+        #expect(determination.duration == 30)
+        #expect(determination.reason.contains("setting current basal of \(basal) as temp."))
+    }
+
+    @Test("Set new temp when rates differ") func testSetNewTempWhenRatesDiffer() throws {
+        let profile = defaultProfile()
+        // duration > 15, but rate is different from basal
+        let currentTemp = TempBasal(duration: 20, rate: 1.0, temp: .absolute, timestamp: Date())
+        let basal: Decimal = 1.2
+        let (shouldSet, determination) = try callEventualOrForecastGlucoseLessThanMax(
+            currentTemp: currentTemp,
+            basal: basal,
+            profile: profile
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == basal)
+        #expect(determination.duration == 30)
+        #expect(determination.reason.contains("setting current basal of \(basal) as temp."))
+    }
+}

+ 130 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalGlucoseFallingFasterThanExpectedTests.swift

@@ -0,0 +1,130 @@
+
+import Foundation
+import Testing
+@testable import Trio
+
+/// These tests should be an exact copy of the JS tests here:
+/// - https://github.com/kingst/trio-oref/blob/dev-fixes-for-swift-comparison/tests/determine-basal-glucose-falling-faster-than-expected.test.js
+@Suite("DosingEngine.glucoseFallingFasterThanExpected") struct DetermineBasalGlucoseFallingFasterThanExpectedTests {
+    private func defaultProfile() -> Profile {
+        var profile = Profile()
+        profile.minBg = 90
+        profile.targetBg = 100
+        profile.currentBasal = 1.0
+        profile.maxDailyBasal = 1.3
+        profile.maxBasal = 3.5
+        profile.sens = 50
+        profile.outUnits = .mgdL
+        return profile
+    }
+
+    private func defaultGlucoseStatus() -> GlucoseStatus {
+        GlucoseStatus(
+            delta: 5,
+            glucose: 100,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0,
+            date: Date(),
+            lastCalIndex: nil,
+            device: "test"
+        )
+    }
+
+    private func callGlucoseFallingFasterThanExpected(
+        eventualGlucose: Decimal = 100,
+        minGlucose: Decimal? = nil,
+        minDelta: Decimal = 4,
+        expectedDelta: Decimal = 5,
+        glucoseStatus: GlucoseStatus? = nil,
+        currentTemp: TempBasal = TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: Date()),
+        basal: Decimal? = nil,
+        smbIsEnabled: Bool = false,
+        profile: Profile? = nil,
+        determination: Determination? = nil
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        let testProfile = profile ?? defaultProfile()
+        let testDetermination = determination ?? Determination(
+            id: nil,
+            reason: "",
+            units: nil,
+            insulinReq: nil,
+            eventualBG: nil,
+            sensitivityRatio: nil,
+            rate: nil,
+            duration: nil,
+            iob: nil,
+            cob: nil,
+            predictions: nil,
+            deliverAt: nil,
+            carbsReq: nil,
+            temp: nil,
+            bg: nil,
+            reservoir: nil,
+            isf: nil,
+            timestamp: nil,
+            tdd: nil,
+            current_target: nil,
+            minDelta: nil,
+            expectedDelta: nil,
+            minGuardBG: nil,
+            minPredBG: nil,
+            threshold: nil,
+            carbRatio: nil,
+            received: nil
+        )
+
+        return try DosingEngine.glucoseFallingFasterThanExpected(
+            eventualGlucose: eventualGlucose,
+            minGlucose: minGlucose ?? testProfile.minBg!,
+            minDelta: minDelta,
+            expectedDelta: expectedDelta,
+            glucoseStatus: glucoseStatus ?? defaultGlucoseStatus(),
+            currentTemp: currentTemp,
+            basal: basal ?? testProfile.currentBasal!,
+            smbIsEnabled: smbIsEnabled,
+            profile: testProfile,
+            determination: testDetermination
+        )
+    }
+
+    @Test("Guard: minDelta not less than expectedDelta") func testMinDeltaNotLessThanExpected() throws {
+        let (shouldSet, determination) = try callGlucoseFallingFasterThanExpected(minDelta: 5, expectedDelta: 5)
+        #expect(shouldSet == false)
+        #expect(determination.reason == "")
+    }
+
+    @Test("Guard: SMB is enabled") func testSmbIsEnabled() throws {
+        let (shouldSet, determination) = try callGlucoseFallingFasterThanExpected(smbIsEnabled: true)
+        #expect(shouldSet == false)
+        #expect(determination.reason == "")
+    }
+
+    @Test("Continue current temp") func testContinueCurrentTemp() throws {
+        let profile = defaultProfile()
+        let currentTemp = TempBasal(duration: 20, rate: profile.currentBasal!, temp: .absolute, timestamp: Date())
+        let (shouldSet, determination) = try callGlucoseFallingFasterThanExpected(
+            currentTemp: currentTemp,
+            basal: profile.currentBasal!,
+            profile: profile
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == nil) // No change
+        #expect(determination.reason.contains("temp \(currentTemp.rate) ~ req \(profile.currentBasal!)U/hr."))
+    }
+
+    @Test("Set new temp") func testSetNewTemp() throws {
+        let profile = defaultProfile()
+        let currentTemp = TempBasal(duration: 10, rate: 1.0, temp: .absolute, timestamp: Date())
+        let basal: Decimal = 1.2
+        let (shouldSet, determination) = try callGlucoseFallingFasterThanExpected(
+            currentTemp: currentTemp,
+            basal: basal,
+            profile: profile
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == basal)
+        #expect(determination.duration == 30)
+        #expect(determination.reason.contains("setting current basal of \(basal) as temp."))
+    }
+}

+ 119 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalIobGreaterThanMaxTests.swift

@@ -0,0 +1,119 @@
+
+import Foundation
+import Testing
+@testable import Trio
+
+/// These tests should be an exact copy of the JS tests here:
+/// - https://github.com/kingst/trio-oref/blob/dev-fixes-for-swift-comparison/tests/determine-basal-iob-greater-than-max.test.js
+@Suite("DosingEngine.iobGreaterThanMax") struct DetermineBasalIobGreaterThanMaxTests {
+    private func defaultProfile() -> Profile {
+        var profile = Profile()
+        profile.maxIob = 1.5
+        profile.currentBasal = 1.0
+        profile.maxBasal = 1.5
+        profile.maxDailyBasal = 3.5
+        profile.outUnits = .mgdL
+        return profile
+    }
+
+    private func callIobGreaterThanMax(
+        iob: Decimal = 1.5,
+        maxIob: Decimal? = nil,
+        currentTemp: TempBasal = TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: Date()),
+        basal: Decimal? = nil,
+        profile: Profile? = nil,
+        determination: Determination? = nil
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        let testProfile = profile ?? defaultProfile()
+        let testDetermination = determination ?? Determination(
+            id: nil,
+            reason: "",
+            units: nil,
+            insulinReq: nil,
+            eventualBG: nil,
+            sensitivityRatio: nil,
+            rate: nil,
+            duration: nil,
+            iob: nil,
+            cob: nil,
+            predictions: nil,
+            deliverAt: nil,
+            carbsReq: nil,
+            temp: nil,
+            bg: nil,
+            reservoir: nil,
+            isf: nil,
+            timestamp: nil,
+            tdd: nil,
+            current_target: nil,
+            minDelta: nil,
+            expectedDelta: nil,
+            minGuardBG: nil,
+            minPredBG: nil,
+            threshold: nil,
+            carbRatio: nil,
+            received: nil
+        )
+
+        return try DosingEngine.iobGreaterThanMax(
+            iob: iob,
+            maxIob: maxIob ?? testProfile.maxIob,
+            currentTemp: currentTemp,
+            basal: basal ?? testProfile.currentBasal!,
+            profile: testProfile,
+            determination: testDetermination
+        )
+    }
+
+    @Test("Guard: iob not greater than max") func testIobNotGreaterThanMax() throws {
+        let (shouldSet, determination) = try callIobGreaterThanMax(iob: 1.5, maxIob: 1.5)
+        #expect(shouldSet == false)
+        #expect(determination.reason == "")
+    }
+
+    @Test("Continue current temp") func testContinueCurrentTemp() throws {
+        let profile = defaultProfile()
+        let currentTemp = TempBasal(duration: 20, rate: profile.currentBasal!, temp: .absolute, timestamp: Date())
+        let (shouldSet, determination) = try callIobGreaterThanMax(
+            iob: 1.6,
+            currentTemp: currentTemp,
+            basal: profile.currentBasal!,
+            profile: profile
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == nil) // No change
+        #expect(determination.reason.contains("temp \(currentTemp.rate) ~ req \(profile.currentBasal!)U/hr."))
+    }
+
+    @Test("Set new temp when duration is short") func testSetNewTempDurationShort() throws {
+        let profile = defaultProfile()
+        let currentTemp = TempBasal(duration: 10, rate: 1.0, temp: .absolute, timestamp: Date())
+        let basal: Decimal = 1.2
+        let (shouldSet, determination) = try callIobGreaterThanMax(
+            iob: 1.6,
+            currentTemp: currentTemp,
+            basal: basal,
+            profile: profile
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == basal)
+        #expect(determination.duration == 30)
+        #expect(determination.reason.contains("setting current basal of \(basal) as temp."))
+    }
+
+    @Test("Set new temp when rates differ") func testSetNewTempRatesDiffer() throws {
+        let profile = defaultProfile()
+        let currentTemp = TempBasal(duration: 20, rate: 1.0, temp: .absolute, timestamp: Date())
+        let basal: Decimal = 1.2
+        let (shouldSet, determination) = try callIobGreaterThanMax(
+            iob: 1.6,
+            currentTemp: currentTemp,
+            basal: basal,
+            profile: profile
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == basal)
+        #expect(determination.duration == 30)
+        #expect(determination.reason.contains("setting current basal of \(basal) as temp."))
+    }
+}

+ 186 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalLowEventualGlucoseTests.swift

@@ -0,0 +1,186 @@
+import Foundation
+import Testing
+@testable import Trio
+
+/// these tests should be an exact copy of the JS tests here:
+/// - https://github.com/kingst/trio-oref/blob/dev-fixes-for-swift-comparison/tests/determine-basal-low-eventual-glucose.test.js
+/// We had to extract the key functionality from JS and put it in a function to facilitate testing
+@Suite("DetermineBasal low eventual glucose") struct HandleLowEventualGlucoseTests {
+    private func defaultProfile() -> Profile {
+        var profile = Profile()
+        profile.minBg = 100
+        profile.targetBg = 100
+        profile.currentBasal = 1.0
+        profile.maxDailyBasal = 1.3
+        profile.maxBasal = 3.5
+        profile.sens = 50
+        return profile
+    }
+
+    private func callHandleLowEventualGlucose(
+        eventualGlucose: Decimal = 90,
+        minGlucose: Decimal? = nil,
+        targetGlucose: Decimal? = nil,
+        minDelta: Decimal = 0,
+        expectedDelta: Decimal = 0,
+        carbsRequired: Decimal = 0,
+        naiveEventualGlucose: Decimal = 90,
+        glucoseStatus: GlucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 100,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0,
+            date: Date(),
+            lastCalIndex: nil,
+            device: "test"
+        ),
+        currentTemp: TempBasal = TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: Date()),
+        basal: Decimal? = nil,
+        profile: Profile? = nil,
+        determination: Determination? = nil,
+        adjustedSensitivity: Decimal? = nil,
+        overrideFactor: Decimal = 1
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        let testProfile = profile ?? defaultProfile()
+        let testDetermination = determination ?? Determination(
+            id: nil,
+            reason: "",
+            units: nil,
+            insulinReq: nil,
+            eventualBG: nil,
+            sensitivityRatio: nil,
+            rate: nil,
+            duration: nil,
+            iob: nil,
+            cob: nil,
+            predictions: nil,
+            deliverAt: nil,
+            carbsReq: nil,
+            temp: nil,
+            bg: nil,
+            reservoir: nil,
+            isf: nil,
+            timestamp: nil,
+            tdd: nil,
+            current_target: nil,
+            minDelta: nil,
+            expectedDelta: nil,
+            minGuardBG: nil,
+            minPredBG: nil,
+            threshold: nil,
+            carbRatio: nil,
+            received: nil
+        )
+
+        return try DosingEngine.handleLowEventualGlucose(
+            eventualGlucose: eventualGlucose,
+            minGlucose: minGlucose ?? testProfile.minBg!,
+            targetGlucose: targetGlucose ?? testProfile.targetBg!,
+            minDelta: minDelta,
+            expectedDelta: expectedDelta,
+            carbsRequired: carbsRequired,
+            naiveEventualGlucose: naiveEventualGlucose,
+            glucoseStatus: glucoseStatus,
+            currentTemp: currentTemp,
+            basal: basal ?? testProfile.currentBasal!,
+            profile: testProfile,
+            determination: testDetermination,
+            adjustedSensitivity: adjustedSensitivity ?? testProfile.sens!,
+            overrideFactor: overrideFactor
+        )
+    }
+
+    @Test("Guard: eventual glucose is not low") func testEventualGlucoseNotLow() throws {
+        let (shouldSet, determination) = try callHandleLowEventualGlucose(eventualGlucose: 100, minGlucose: 100)
+        #expect(shouldSet == false)
+        #expect(determination.reason == "")
+    }
+
+    @Test("Naive eventual glucose below 40") func testNaiveEventualGlucoseBelow40() throws {
+        let (shouldSet, determination) = try callHandleLowEventualGlucose(
+            minDelta: 1,
+            expectedDelta: 0,
+            carbsRequired: 0,
+            naiveEventualGlucose: 39
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == 0)
+        #expect(determination.duration == 30)
+        #expect(determination.reason.contains("naive_eventualBG < 40"))
+    }
+
+    @Test("Min delta > expected, but no carbs required") func testMinDeltaGreaterThanExpectedDeltaAndNoCarbs() throws {
+        let (shouldSet, _) = try callHandleLowEventualGlucose(minDelta: 1, expectedDelta: 0, carbsRequired: 0)
+        #expect(shouldSet == true)
+    }
+
+    @Test("Min delta < 0") func testMinDeltaLessThanZero() throws {
+        let (shouldSet, determination) = try callHandleLowEventualGlucose(minDelta: -1, expectedDelta: -2, carbsRequired: 0)
+        #expect(shouldSet == true)
+        #expect(determination.rate == 0.6)
+    }
+
+    @Test("Current temp rate matches basal") func testCurrentTempRateMatchesBasal() throws {
+        let profile = defaultProfile()
+        let currentTemp = TempBasal(duration: 20, rate: profile.currentBasal!, temp: .absolute, timestamp: Date())
+        let (shouldSet, determination) = try callHandleLowEventualGlucose(
+            minDelta: 1,
+            expectedDelta: 0,
+            carbsRequired: 0,
+            currentTemp: currentTemp,
+            profile: profile
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == nil) // No change
+        #expect(determination.reason.contains("temp \(currentTemp.rate) ~ req \(profile.currentBasal!)U/hr."))
+    }
+
+    @Test("Set basal as temp") func testSetBasalAsTemp() throws {
+        let profile = defaultProfile()
+        let (shouldSet, determination) = try callHandleLowEventualGlucose(
+            minDelta: 1,
+            expectedDelta: 0,
+            carbsRequired: 0,
+            profile: profile
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == profile.currentBasal)
+        #expect(determination.duration == 30)
+        #expect(determination.reason.contains("setting current basal of \(profile.currentBasal!) as temp."))
+    }
+
+    @Test("Insulin scheduled less than required") func testInsulinScheduledLessThanRequired() throws {
+        let (shouldSet, determination) = try callHandleLowEventualGlucose(
+            eventualGlucose: 80,
+            naiveEventualGlucose: 70,
+            currentTemp: TempBasal(duration: 120, rate: 0, temp: .absolute, timestamp: Date())
+        )
+        #expect(shouldSet == true)
+        #expect(determination.rate == nil)
+        #expect(determination.duration == nil)
+        #expect(determination.reason.contains("is a lot less than needed"))
+    }
+
+    @Test("Rate similar to current temp") func testRateSimilarToCurrentTemp() throws {
+        let currentTemp = TempBasal(duration: 10, rate: 0.1, temp: .absolute, timestamp: Date())
+        let (shouldSet, determination) = try callHandleLowEventualGlucose(
+            eventualGlucose: 99,
+            targetGlucose: 110,
+            currentTemp: currentTemp,
+            adjustedSensitivity: 50
+        )
+
+        #expect(shouldSet == true)
+        #expect(determination.rate == nil) // No change
+        #expect(determination.reason.contains("temp \(currentTemp.rate) ~< req"))
+    }
+
+    @Test("Set zero temp") func testSetZeroTemp() throws {
+        let (shouldSet, determination) = try callHandleLowEventualGlucose(eventualGlucose: 70, naiveEventualGlucose: 60)
+        #expect(shouldSet == true)
+        #expect(determination.rate == 0)
+        #expect(determination.duration! > 0)
+        #expect(determination.reason.contains("setting \(determination.duration!)m zero temp."))
+    }
+}

+ 179 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalSmbMicroBolusTests.swift

@@ -0,0 +1,179 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("determineBasal SMB microbolus behavior") struct DetermineBasalSmbMicroBolusTests {
+    private func buildInputs(lastBolusOffsetMinutes: Decimal) -> (
+        profile: Profile,
+        preferences: Preferences,
+        currentTemp: TempBasal,
+        iobData: [IobResult],
+        mealData: ComputedCarbs,
+        autosensData: Autosens,
+        reservoirData: Decimal,
+        glucoseStatus: GlucoseStatus,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        microBolusAllowed: Bool,
+        currentTime: Date
+    ) {
+        let now = Date()
+
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxDailyBasal = 1.5
+        profile.maxBasal = 3.0
+        profile.minBg = 90
+        profile.maxBg = 160
+        profile.targetBg = 100
+        profile.sens = 40
+        profile.carbRatio = 10
+        profile.thresholdSetting = 70
+        profile.maxIob = 6
+        profile.enableSMBAlways = true
+        profile.bolusIncrement = 0.1
+        profile.enableSMBHighBg = true
+        profile.enableSMBHighBgTarget = 140
+
+        var preferences = Preferences()
+        preferences.curve = .rapidActing
+        preferences.useCustomPeakTime = false
+
+        let currentTemp = TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: now)
+
+        let lastBolusTime = UInt64(
+            now
+                .addingTimeInterval(TimeInterval(-60 * NSDecimalNumber(decimal: lastBolusOffsetMinutes).doubleValue))
+                .timeIntervalSince1970 * 1000
+        )
+        let iobData = [IobResult(
+            iob: 0.2,
+            activity: 0,
+            basaliob: 0.2,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: now,
+            iobWithZeroTemp: IobResult.IobWithZeroTemp(
+                iob: 0.2,
+                activity: 0,
+                basaliob: 0.2,
+                bolusiob: 0,
+                netbasalinsulin: 0,
+                bolusinsulin: 0,
+                time: now
+            ),
+            lastBolusTime: lastBolusTime,
+            lastTemp: IobResult.LastTemp(
+                rate: 0,
+                timestamp: now,
+                started_at: now,
+                date: UInt64(now.timeIntervalSince1970 * 1000),
+                duration: 30
+            )
+        )]
+
+        let mealData = ComputedCarbs(
+            carbs: 0,
+            mealCOB: 0,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [0, 0, 0, 0, 0],
+            lastCarbTime: 0
+        )
+
+        let autosensData = Autosens(ratio: 1.0, newisf: nil)
+
+        let glucoseStatus = GlucoseStatus(
+            delta: 5,
+            glucose: 190,
+            noise: 1,
+            shortAvgDelta: 5,
+            longAvgDelta: 5,
+            date: now,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let trioCustomOrefVariables = TrioCustomOrefVariables(
+            average_total_data: 0,
+            weightedAverage: 0,
+            currentTDD: 0,
+            past2hoursAverage: 0,
+            date: now,
+            overridePercentage: 100,
+            useOverride: false,
+            duration: 0,
+            unlimited: false,
+            overrideTarget: 0,
+            smbIsOff: false,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: false,
+            cr: false,
+            smbIsScheduledOff: false,
+            start: 0,
+            end: 0,
+            smbMinutes: 30,
+            uamMinutes: 30
+        )
+
+        return (
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: 0,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            microBolusAllowed: true,
+            currentTime: now
+        )
+    }
+
+    @Test("Applies SMB microbolus when interval has elapsed") func testMicrobolusWhenIntervalElapsed() throws {
+        let inputs = buildInputs(lastBolusOffsetMinutes: 10)
+
+        let determination = try DeterminationGenerator.determineBasal(
+            profile: inputs.profile,
+            preferences: inputs.preferences,
+            currentTemp: inputs.currentTemp,
+            iobData: inputs.iobData,
+            mealData: inputs.mealData,
+            autosensData: inputs.autosensData,
+            reservoirData: inputs.reservoirData,
+            glucoseStatus: inputs.glucoseStatus,
+            microBolusAllowed: inputs.microBolusAllowed,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables,
+            currentTime: inputs.currentTime
+        )
+
+        #expect(determination?.units ?? 0 > 0)
+        #expect(determination?.reason.contains("Microbolusing") ?? false)
+    }
+
+    @Test("Waits for SMB interval before another microbolus") func testWaitsForInterval() throws {
+        let inputs = buildInputs(lastBolusOffsetMinutes: 1)
+
+        let determination = try DeterminationGenerator.determineBasal(
+            profile: inputs.profile,
+            preferences: inputs.preferences,
+            currentTemp: inputs.currentTemp,
+            iobData: inputs.iobData,
+            mealData: inputs.mealData,
+            autosensData: inputs.autosensData,
+            reservoirData: inputs.reservoirData,
+            glucoseStatus: inputs.glucoseStatus,
+            microBolusAllowed: inputs.microBolusAllowed,
+            trioCustomOrefVariables: inputs.trioCustomOrefVariables,
+            currentTime: inputs.currentTime
+        )
+
+        #expect(determination?.units == nil || determination?.units == 0)
+        #expect(determination?.reason.contains("Waiting") ?? false)
+    }
+}

+ 195 - 0
TrioTests/OpenAPSSwiftTests/DynamicISFTests.swift

@@ -0,0 +1,195 @@
+import Foundation
+import Testing
+@testable import Trio
+
+/// The corresponding Javascript tests to confirm these numbers are here:
+///  - https://github.com/kingst/trio-oref/blob/dev-fixes-for-swift-comparison/tests/dynamic-isf.test.js
+@Suite("DynamicISF Calculation Tests") struct DynamicISFTests {
+    // Helper to create common dependencies for tests
+    private func createDependencies(
+        useNewFormula: Bool = true,
+        tdd: Decimal = 30,
+        avgTDD: Decimal = 30,
+        sensitivity: Decimal = 50,
+        minAutosens: Decimal = 0.7,
+        maxAutosens: Decimal = 1.2,
+        useCustomPeakTime: Bool = false,
+        insulinCurve: InsulinCurve = .rapidActing
+    ) -> (Profile, Preferences, Decimal, TrioCustomOrefVariables) {
+        var preferences = Preferences()
+        preferences.useNewFormula = useNewFormula
+        preferences.sigmoid = false
+        preferences.adjustmentFactor = 0.8
+        preferences.adjustmentFactorSigmoid = 0.5
+        preferences.useCustomPeakTime = useCustomPeakTime
+        preferences.curve = insulinCurve
+
+        var profile = Profile()
+        profile.sens = sensitivity
+        profile.autosensMin = minAutosens
+        profile.autosensMax = maxAutosens
+        profile.minBg = 100
+        profile.curve = insulinCurve
+        profile.useCustomPeakTime = useCustomPeakTime
+        profile.insulinPeakTime = 60 // For custom peak time test
+
+        let glucose = Decimal(120)
+
+        let trioVars = TrioCustomOrefVariables(
+            average_total_data: avgTDD,
+            weightedAverage: tdd,
+            currentTDD: tdd,
+            past2hoursAverage: 0,
+            date: Date(),
+            overridePercentage: 100,
+            useOverride: false,
+            duration: 0,
+            unlimited: true,
+            overrideTarget: 0,
+            smbIsOff: false,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: true,
+            cr: true,
+            smbIsScheduledOff: false,
+            start: 0,
+            end: 0,
+            smbMinutes: 30,
+            uamMinutes: 30
+        )
+
+        return (profile, preferences, glucose, trioVars)
+    }
+
+    @Test("Returns nil if dISF is disabled") func disabledReturnsNil() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies(useNewFormula: false)
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )
+
+        #expect(result == nil)
+    }
+
+    @Test("Returns nil for invalid autosens limits") func invalidLimitsReturnsNil() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies(minAutosens: 1.2, maxAutosens: 1.2)
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )
+        #expect(result == nil)
+    }
+
+    @Test("Logarithmic formula calculates all result fields correctly") func logarithmicFormula() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies()
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.insulinFactor == 55)
+        #expect(result.tddRatio.rounded(toPlaces: 2) == 1)
+        #expect(result.ratio.rounded(toPlaces: 2) == 0.77)
+    }
+
+    @Test("Sigmoid formula calculates all result fields correctly") func sigmoidFormula() throws {
+        var (profile, preferences, glucose, trioVars) = createDependencies()
+        preferences.sigmoid = true
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.insulinFactor == 55)
+        #expect(result.tddRatio == 1.0)
+        #expect(result.ratio.rounded(scale: 2) == Decimal(string: "1.06"))
+    }
+
+    @Test("Uses default TDD ratio when average TDD is zero") func defaultTddRatio() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies(avgTDD: 0)
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.tddRatio == 1.0)
+        #expect(result.ratio.rounded(toPlaces: 2) == 0.77)
+    }
+
+    @Test("Uses custom peak time when enabled") func customPeakTime() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies(useCustomPeakTime: true)
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        // 120 - profile.insulinPeakTime (60) = 60
+        #expect(result.insulinFactor == 60)
+    }
+
+    @Test("Uses ultra-rapid insulin factor correctly") func ultraRapidInsulin() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies(insulinCurve: .ultraRapid)
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.ratio.rounded(scale: 2) == Decimal(string: "0.7"))
+    }
+
+    @Test("Sigmoid handles maxLimit of 1 correctly") func sigmoidMaxLimitOne() throws {
+        var (profile, preferences, glucose, trioVars) = createDependencies(maxAutosens: 1.0)
+        preferences.sigmoid = true
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.insulinFactor == 55)
+        #expect(result.tddRatio == 1.0)
+        // BUG: you would expect this to be 1 but because of the fudge factor the
+        // JS code uses to avoid divide by 0 it 0.99
+        #expect(result.ratio.rounded(scale: 2) == Decimal(string: "0.99"))
+    }
+
+    @Test("Override with sigmoid adjusts target and ratio correctly") func overrideWithSigmoid() throws {
+        var (profile, preferences, glucose, trioVars) = createDependencies()
+        preferences.sigmoid = true
+        trioVars.useOverride = true
+        trioVars.overrideTarget = 80
+        trioVars.overridePercentage = 80
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.ratio.rounded(toPlaces: 2) == Decimal(string: "1.11"))
+    }
+}

+ 188 - 0
TrioTests/OpenAPSSwiftTests/IobCalculateTests.swift

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

+ 167 - 0
TrioTests/OpenAPSSwiftTests/IobConsecutiveEventsTests.swift

@@ -0,0 +1,167 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Consecutive Pump Suspend/Resume Events Tests") struct IobConsecutiveEventsTests {
+    // Helper function to create a basic basal profile
+    func createBasicBasalProfile() -> [BasalProfileEntry] {
+        [
+            BasalProfileEntry(
+                start: "00:00:00",
+                minutes: 0,
+                rate: 1
+            )
+        ]
+    }
+
+    @Test(
+        "should treat two consecutive PumpSuspend events as a single, longer suspend from the first event"
+    ) func consecutivePumpSuspendEvents() async throws {
+        let basalprofile = createBasicBasalProfile()
+        let now = Calendar.current.startOfDay(for: Date()) + 60.minutesToSeconds // Current time 01:00
+
+        let suspendTime1 = now - 45.minutesToSeconds // Suspend 1 at 00:15
+        let suspendTime2 = now - 30.minutesToSeconds // Suspend 2 at 00:30
+        let resumeTime = now - 15.minutesToSeconds // Resume at 00:45
+
+        // JS: reversed chronological order (newest first)
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resumeTime
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspendTime2
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspendTime1
+            )
+        ]
+
+        var profile = Profile()
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = true
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Check total insulin impact for the period:
+        // It should produce -0.5U being suspended for 30m total
+        #expect(treatments.netInsulin().isWithin(0.05, of: -0.5))
+    }
+
+    @Test(
+        "should consider only the first PumpResume after a suspend event, ignoring subsequent consecutive resumes"
+    ) func consecutivePumpResumeEvents() async throws {
+        let basalprofile = createBasicBasalProfile()
+        let now = Calendar.current.startOfDay(for: Date()) + 60.minutesToSeconds // Current time 01:00
+
+        let suspendTime = now - 45.minutesToSeconds // Suspend at 00:15
+        let resumeTime1 = now - 30.minutesToSeconds // Resume 1 at 00:30
+        let resumeTime2 = now - 15.minutesToSeconds // Resume 2 at 00:45
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resumeTime2
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resumeTime1
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspendTime
+            )
+        ]
+
+        var profile = Profile()
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = true
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Check total insulin impact for the period:
+        // suspended for 15m, should be -0.25U
+        #expect(treatments.netInsulin().isWithin(0.05, of: -0.25))
+    }
+
+    @Test(
+        "should correctly process a complex sequence of suspend, suspend, resume, resume, suspend, resume events"
+    ) func complexSequenceEvents() async throws {
+        let basalprofile = createBasicBasalProfile()
+        let now = Calendar.current.startOfDay(for: Date()) + 90.minutesToSeconds // Current time 01:30
+
+        let suspend1 = now - 75.minutesToSeconds // Suspend 1 at 00:15
+        let suspend2 = now - 60.minutesToSeconds // Suspend 2 at 00:30
+        let resume1 = now - 45.minutesToSeconds // Resume 1 at 00:45
+        let resume2 = now - 30.minutesToSeconds // Resume 2 at 01:00
+        let suspend3 = now - 15.minutesToSeconds // Suspend 3 at 01:15
+        let resume3 = now // Resume 3 at 01:30 (current time)
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resume3
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspend3
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resume2
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resume1
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspend2
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspend1
+            )
+        ]
+
+        var profile = Profile()
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = true
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Total insulin calculation:
+        // Suspended for 45m total, should produce -0.75U
+        #expect(treatments.netInsulin().isWithin(0.05, of: -0.75))
+    }
+}

+ 37 - 0
TrioTests/OpenAPSSwiftTests/IobGenerateTests.swift

@@ -0,0 +1,37 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("IoB generate tests") struct IobGenerateTests {
+    /// One of our performance optimizations where we filter old pump events has subtle interactions
+    /// with the JS implementation. In particular, JS will hardcode 8 hours for DIA in the suspend logic
+    /// when a pump history has a resume as the first suspend/resume event. This hard coded value
+    /// can cause some old netbasalinsulin to get dropped if DIA > 8 hours. We fixed this bug by
+    /// not filtering suspend and resume events, and this test case checks for the bug fix.
+    @Test("should test suspend filtering") func testSuspendFiltering() async throws {
+        let now = Calendar.current.startOfDay(for: Date()) + 20.hoursToSeconds
+
+        let history = [
+            PumpHistoryEvent(id: UUID().uuidString, type: .pumpSuspend, timestamp: now - 15.hoursToSeconds),
+            PumpHistoryEvent(id: UUID().uuidString, type: .pumpResume, timestamp: now - 1.hoursToSeconds)
+        ]
+
+        var profile = Profile()
+        profile.dia = 10
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.basalprofile = [
+            BasalProfileEntry(
+                start: "00:00:00",
+                minutes: 0,
+                rate: 1
+            )
+        ]
+        profile.suspendZerosIob = true
+
+        let iob = try IobGenerator.generate(history: history, profile: profile, clock: now, autosens: nil)
+
+        // Matches the long suspend test in JS iob.test.js
+        #expect(iob[0].netbasalinsulin == -8.95)
+    }
+}

+ 636 - 0
TrioTests/OpenAPSSwiftTests/IobHistoryTests.swift

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

+ 379 - 0
TrioTests/OpenAPSSwiftTests/IobSuspendTests.swift

@@ -0,0 +1,379 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("IOB Suspend Logic Tests") struct IobSuspendTests {
+    // Helper function to create a basic basal profile
+    func createBasicBasalProfile() -> [BasalProfileEntry] {
+        [
+            BasalProfileEntry(
+                start: "00:00:00",
+                minutes: 0,
+                rate: 1
+            )
+        ]
+    }
+
+    // Helper function to create a multi-rate basal profile
+    func createMultiRateBasalProfile() -> [BasalProfileEntry] {
+        [
+            BasalProfileEntry(
+                start: "00:00:00",
+                minutes: 0,
+                rate: 1
+            ),
+            BasalProfileEntry(
+                start: "00:30:00",
+                minutes: 30,
+                rate: 2
+            )
+        ]
+    }
+
+    @Test("should handle basic suspend and resume") func handleBasicSuspendAndResume() async throws {
+        let basalprofile = createBasicBasalProfile()
+
+        // Create fixed test dates (matching JavaScript test)
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime]
+
+        let now = formatter.date(from: "2016-06-13T01:00:00Z")!
+        let timestamp30mAgo = formatter.date(from: "2016-06-13T00:30:00Z")!
+        let timestamp15mAgo = formatter.date(from: "2016-06-13T00:45:00Z")!
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: timestamp30mAgo,
+                duration: nil,
+                rate: 2,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: timestamp30mAgo,
+                durationMin: 30
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: timestamp15mAgo
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: now
+            )
+        ]
+
+        var profile = Profile()
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = true
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Calculate expected insulin impact:
+        // 15m at 2 U/h - 1 U/h = 0.25U
+        // 15m at 0 U/h - 1 U/h = -0.25U
+        // Total: 0U
+        #expect(treatments.netInsulin().isWithin(0.01, of: 0.0))
+    }
+
+    @Test("should handle suspend prior to history window") func handleSuspendPriorToHistoryWindow() async throws {
+        let basalprofile = createBasicBasalProfile()
+
+        // Create fixed test dates (matching JavaScript test)
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime]
+
+        let now = formatter.date(from: "2016-06-13T08:00:00Z")!
+        let resumeTime = formatter.date(from: "2016-06-13T07:00:00Z")!
+        let tempStartTime = formatter.date(from: "2016-06-13T07:30:00Z")!
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resumeTime
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: tempStartTime,
+                duration: nil,
+                rate: 2,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: tempStartTime,
+                durationMin: 30
+            )
+        ]
+
+        var profile = Profile()
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.dia = 10 // Longer DIA to match JS test
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = true
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Calculate expected insulin impact:
+        // 7h at 0 U/h - 1U/h = -7 (this may need adjustment based on implementation)
+        // 30m at 2 U/h - 1 U/h = 0.5U
+        // Total: approximately -6.5U
+
+        // Note: This test case may need adjustments based on how you implement the suspend
+        // prior to history window logic in your Swift port
+        let netInsulin = treatments.netInsulin()
+
+        // The exact value might vary due to implementation details, but the general direction should be consistent
+        #expect(netInsulin < -6.0)
+    }
+
+    @Test("should handle current suspension") func handleCurrentSuspension() async throws {
+        let basalprofile = createBasicBasalProfile()
+
+        // Setting up the dates for the test
+        let now = Calendar.current.startOfDay(for: Date()) + 60.minutesToSeconds
+        let suspendTime = now - 30.minutesToSeconds
+        let tempStartTime = now - 45.minutesToSeconds
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: tempStartTime,
+                duration: nil,
+                rate: 2,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: tempStartTime,
+                durationMin: 30
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspendTime
+            )
+        ]
+
+        var profile = Profile()
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = true
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Calculate expected insulin impact:
+        // 15m at 2 U/h - 1U/h = 0.25
+        // 30m at 0 U/h - 1U/h = -0.5
+        // Total: -0.25U
+        #expect(treatments.netInsulin().isWithin(0.01, of: -0.25))
+    }
+
+    @Test("should handle multiple suspend-resume cycles") func handleMultipleSuspendResumeCycles() async throws {
+        let basalprofile = createBasicBasalProfile()
+
+        // Setting up the dates for the test
+        let now = Calendar.current.startOfDay(for: Date()) + 90.minutesToSeconds
+
+        // Create history with 2 suspend-resume cycles
+        let suspend1 = now - 90.minutesToSeconds
+        let resume1 = now - 75.minutesToSeconds
+        let tempStart = now - 60.minutesToSeconds
+        let suspend2 = now - 45.minutesToSeconds
+        let resume2 = now - 30.minutesToSeconds
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspend1
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resume1
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: tempStart,
+                duration: nil,
+                rate: 2,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: tempStart,
+                durationMin: 60
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspend2
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resume2
+            )
+        ]
+
+        var profile = Profile()
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = true
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Calculate expected insulin impact:
+        // 15m at 0 U/h - 1 U/h = -0.25
+        // 15m at 2 U/h - 1 U/h = 0.25
+        // 15m at 0 U/h - 1 U/h = -0.25
+        // 30m at 2 U/h - 1 U/h = 0.5
+        // Total: 0.25U
+        #expect(treatments.netInsulin().isWithin(0.01, of: 0.25))
+    }
+
+    @Test("should handle suspend with basal profile changes") func handleSuspendWithBasalProfileChanges() async throws {
+        let basalprofile = createMultiRateBasalProfile()
+
+        // Create fixed test dates (matching JavaScript test)
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime]
+
+        let calendar = Calendar.current
+
+        let currentTime = Date()
+        let startTime = Calendar.current.startOfDay(for: currentTime) + 15.minutesToSeconds
+        let suspendTime = Calendar.current.startOfDay(for: currentTime) + 30.minutesToSeconds
+        let resumeTime = Calendar.current.startOfDay(for: currentTime) + 45.minutesToSeconds
+        let endTime = Calendar.current.startOfDay(for: currentTime) + 60.minutesToSeconds
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: startTime,
+                duration: nil,
+                rate: 3,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: startTime,
+                durationMin: 45
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspendTime
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resumeTime
+            )
+        ]
+
+        var profile = Profile()
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 2
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = true
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: endTime,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Calculate expected insulin impact:
+        // 15m at 3 U/h - 1 U/h = 0.5U (from start to basal change)
+        // 15m at 0 U/h - 2 U/h = -0.5U (from basal change and suspend)
+        // 15m at 3 U/h - 2 U/h = 0.25U (resume to finish)
+        // Total: 0.25U
+        #expect(treatments.netInsulin().isWithin(0.01, of: 0.25))
+    }
+
+    @Test("should properly handle IOB impact with suspends") func handleIobImpactWithSuspends() async throws {
+        let basalprofile = createBasicBasalProfile()
+
+        // Setting up the dates for the test
+        let now = Calendar.current.startOfDay(for: Date()) + 90.minutesToSeconds
+
+        let tempStart = now - 60.minutesToSeconds
+        let suspendTime = now - 30.minutesToSeconds
+        let resumeTime = now
+
+        let pumpHistory = [
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasal,
+                timestamp: tempStart,
+                duration: nil,
+                rate: 2,
+                temp: .absolute
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .tempBasalDuration,
+                timestamp: tempStart,
+                durationMin: 30
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpSuspend,
+                timestamp: suspendTime
+            ),
+            ComputedPumpHistoryEvent.forTest(
+                type: .pumpResume,
+                timestamp: resumeTime
+            )
+        ]
+
+        var profile = Profile()
+        profile.currentBasal = 1
+        profile.maxDailyBasal = 1
+        profile.dia = 3
+        profile.basalprofile = basalprofile
+        profile.suspendZerosIob = true
+
+        let treatments = try IobHistory.calcTempTreatments(
+            history: pumpHistory,
+            profile: profile,
+            clock: now,
+            autosens: nil,
+            zeroTempDuration: nil
+        )
+
+        // Calculate expected insulin impact:
+        // 30m at 2 U/h - 1 U/h = 0.5U (from temp start to temp end)
+        // 30m at 0 U/h - 1 U/h = -0.5U (from suspend to resume)
+        // Total: 0U
+        #expect(treatments.netInsulin().isWithin(0.01, of: 0.0))
+    }
+}

+ 200 - 0
TrioTests/OpenAPSSwiftTests/IobTotalTests.swift

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

+ 252 - 0
TrioTests/OpenAPSSwiftTests/MealCobBucketingTests.swift

@@ -0,0 +1,252 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("Meal glucose bucketing tests") struct MealCobBucketingTests {
+    // Default test profile - matches JS exactly
+    func createDefaultProfile() -> Profile {
+        var profile = Profile()
+        profile.dia = 4
+        profile.maxMealAbsorptionTime = 6
+        profile.min5mCarbImpact = 3
+        profile.carbRatio = 10
+        return profile
+    }
+
+    // Helper to create glucose entry - matches JS structure
+    func createGlucoseEntry(glucose: Int, timeMs: Double) -> BloodGlucose {
+        let date = Date(timeIntervalSince1970: timeMs / 1000)
+        return BloodGlucose(
+            sgv: glucose,
+            date: Decimal(timeMs),
+            dateString: date,
+            glucose: glucose
+        )
+    }
+
+    // Note: glucose_data is expected in reverse chronological order (newest first)
+    // The bucketGlucoseData function maintains this order in its output
+
+    @Test(
+        "should handle normal 5-minute interval data without modification"
+    ) func shouldHandleNormal5MinuteIntervalDataWithoutModification() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create regular 5-minute interval data (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 105, timeMs: mealTimeMs + 5 * 60 * 1000),
+            createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 10 * 60 * 1000),
+            createGlucoseEntry(glucose: 115, timeMs: mealTimeMs + 15 * 60 * 1000)
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        // Should return same number of entries
+        #expect(result.count == 4)
+        // Values should be unchanged (in reverse chronological order)
+        #expect(result[0].glucose == 115)
+        #expect(result[1].glucose == 110)
+        #expect(result[2].glucose == 105)
+        #expect(result[3].glucose == 100)
+    }
+
+    @Test("should interpolate missing data when gap > 8 minutes") func shouldInterpolateMissingDataWhenGapGreaterThan8Minutes(
+    ) async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data with a 21-minute gap (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 99, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 120, timeMs: mealTimeMs + 21 * 60 * 1000) // 21 min gap
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        // Should have interpolated 4 additional points (5, 10, 15, 20 minutes)
+        #expect(result.count == 5)
+
+        // Check interpolated values (in reverse chronological order)
+        #expect(result[0].glucose == 120) // original (newest)
+        #expect(result[1].glucose == 115) // interpolated
+        #expect(result[2].glucose == 110) // interpolated
+        #expect(result[3].glucose == 105) // interpolated
+        #expect(result[4].glucose == 100) // interpolated
+
+        // Check that dates are properly set
+        #expect(result[1].date == mealTime.addingTimeInterval(16 * 60))
+        #expect(result[2].date == mealTime.addingTimeInterval(11 * 60))
+        #expect(result[3].date == mealTime.addingTimeInterval(6 * 60))
+        #expect(result[4].date == mealTime.addingTimeInterval(1 * 60))
+    }
+
+    @Test("should stop processing after maxMealAbsorptionTime") func shouldStopProcessingAfterMaxMealAbsorptionTime() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data spanning 8 hours (chronological order)
+        var glucose_data: [BloodGlucose] = []
+        for i in 0 ... 96 { // 96 * 5 min = 8 hours
+            glucose_data.append(createGlucoseEntry(
+                glucose: 100 + i,
+                timeMs: mealTimeMs + Double(i) * 5 * 60 * 1000
+            ))
+        }
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        // Set maxMealAbsorptionTime to 2 hours
+        var profile = createDefaultProfile()
+        profile.maxMealAbsorptionTime = 2
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: profile,
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        // JS test expects 72 entries (not 25 as in original Swift test)
+        print(result)
+        #expect(result.count == 72)
+
+        // Check specific values to match JS test
+        #expect(result[0].glucose == 196)
+        #expect(result[1].glucose == 195)
+        #expect(result[12].glucose == 178)
+        #expect(result[24].glucose == 160)
+    }
+
+    @Test("should only process data within 45 minutes in CI mode") func shouldOnlyProcessDataWithin45MinutesInCIMode() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let ciTime = Date.from(isoString: "2024-01-01T14:00:00-05:00") // 2 hours after meal
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data spanning 3 hours (chronological order)
+        var glucose_data: [BloodGlucose] = []
+        for i in 0 ... 36 { // 36 * 5 min = 3 hours
+            glucose_data.append(createGlucoseEntry(
+                glucose: 100 + i,
+                timeMs: mealTimeMs + Double(i) * 5 * 60 * 1000
+            ))
+        }
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            carbImpactDate: ciTime
+        )
+
+        // JS test shows this captures more than 45 minutes due to the bucketing logic
+        for entry in result {
+            let minutesFromCI = abs(ciTime.timeIntervalSince(entry.date)) / 60
+            #expect(minutesFromCI <= 120) // JS test uses 120, not 45
+        }
+
+        // JS test expects 21 entries
+        #expect(result.count == 21)
+    }
+
+    @Test("should stop processing when pre-meal BG is found") func shouldStopProcessingWhenPreMealBGIsFound() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data that includes pre-meal values (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 90, timeMs: mealTimeMs - 10 * 60 * 1000), // 10 min before meal
+            createGlucoseEntry(glucose: 95, timeMs: mealTimeMs - 5 * 60 * 1000), // 5 min before meal
+            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 105, timeMs: mealTimeMs + 5 * 60 * 1000),
+            createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 10 * 60 * 1000),
+            createGlucoseEntry(glucose: 115, timeMs: mealTimeMs + 15 * 60 * 1000) // 15 min after
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        // JS test expects 5 entries (includes one pre-meal entry due to bug)
+        #expect(result.count == 5)
+        // Values should be unchanged (in reverse chronological order)
+        #expect(result[0].glucose == 115)
+        #expect(result[1].glucose == 110)
+        #expect(result[2].glucose == 105)
+        #expect(result[3].glucose == 100)
+        #expect(result[4].glucose == 95) // This pre-meal entry is included due to JS bug
+    }
+
+    @Test(
+        "should average glucose values when readings are very close (≤ 2 minutes)"
+    ) func shouldAverageGlucoseValuesWhenReadingsAreVeryClose() async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data with readings 1 minute apart (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 102, timeMs: mealTimeMs + 1 * 60 * 1000), // 1 min later
+            createGlucoseEntry(glucose: 104, timeMs: mealTimeMs + 2 * 60 * 1000), // 2 min later
+            createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 5 * 60 * 1000) // 5 min later
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        // Close readings should be averaged (in reverse chronological order)
+        #expect(result.count == 2)
+        #expect(result[0].glucose == 110)
+        // JS test shows averaging bug results in 101.5, not 102
+        #expect(result[1].glucose == 101.5)
+    }
+
+    @Test("should cap interpolation at 240 minutes for very large gaps") func shouldCapInterpolationAt240MinutesForVeryLargeGaps(
+    ) async throws {
+        let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
+        let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
+
+        // Create data with a 6-hour (360 minute) gap (chronological order)
+        var glucose_data = [
+            createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
+            createGlucoseEntry(glucose: 200, timeMs: mealTimeMs + 360 * 60 * 1000) // 6 hour gap
+        ]
+        glucose_data.reverse() // Convert to reverse chronological order
+
+        let result = try MealCob.bucketGlucoseForCob(
+            glucose: glucose_data,
+            profile: createDefaultProfile(),
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        // JS test expects 48 entries due to capping at 240 minutes
+        #expect(result.count == 48)
+
+        // Check that interpolation stopped at 240 minutes
+        let gapMinutes = result[0].date.timeIntervalSince(result[result.count - 1].date) / 60
+        #expect(gapMinutes == 235)
+    }
+}

+ 227 - 0
TrioTests/OpenAPSSwiftTests/MealCobTests.swift

@@ -0,0 +1,227 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("MealCob Tests") struct MealCobTests {
+    // Helper function to create basic profile for testing
+    func createBasicProfile() -> Profile {
+        var profile = Profile()
+        profile.dia = 4
+        profile.maxMealAbsorptionTime = 6
+        profile.min5mCarbImpact = 3
+        profile.carbRatio = 10
+        profile.currentBasal = 1.0
+        profile.isfProfile = ComputedInsulinSensitivities(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            sensitivities: [ComputedInsulinSensitivityEntry(sensitivity: 40, offset: 0, start: "00:00:00")]
+        )
+        return profile
+    }
+
+    // Helper function to create basal profile
+    func createBasalProfile() -> [BasalProfileEntry] {
+        [BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 1.0)]
+    }
+
+    // Helper function to create glucose data from values and timestamps
+    func createGlucoseData(startTime: Date, values: [Int], intervalMinutes: Int = 5) -> [BloodGlucose] {
+        values.enumerated().map { i, glucose in
+            let timestamp = startTime.addingTimeInterval(TimeInterval(i * intervalMinutes * 60))
+            return BloodGlucose(
+                sgv: glucose,
+                date: Decimal(timestamp.timeIntervalSince1970 * 1000), // JS uses ms
+                dateString: timestamp
+            )
+        }.reversed()
+    }
+
+    @Test("should detect carb absorption with rising glucose") func detectCarbAbsorptionWithRisingGlucose() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        var carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        // Create glucose data showing significant rise after meal
+        let glucoseValues = [100, 105, 110, 115, 120, 130, 140, 150, 155, 160, 160, 160, 160]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        var profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        // Test with carbImpactTime
+        var result = try MealCob.detectCarbAbsorption(
+            clock: &carbImpactTime, // no pump events, set to whatever
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: &profile,
+            mealDate: mealTime,
+            carbImpactDate: carbImpactTime
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 9.75))
+
+        // Test without carbImpactTime
+        result = try MealCob.detectCarbAbsorption(
+            clock: &carbImpactTime, // no pump events, set to whatever
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: &profile,
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 14.75))
+    }
+
+    @Test("should handle stable glucose (no carb absorption)") func handleStableGlucose() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        var carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        // Create stable glucose data
+        let glucoseValues = [100, 100, 100, 100, 100, 100]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        var profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            clock: &carbImpactTime, // no pump events, set to whatever
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: &profile,
+            mealDate: mealTime,
+            carbImpactDate: carbImpactTime
+        )
+
+        #expect(result.carbsAbsorbed == 0)
+    }
+
+    @Test("should handle falling glucose (negative deviation)") func handleFallingGlucose() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        var carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        // Create falling glucose data: 150 -> 125
+        let glucoseValues = [150, 145, 140, 135, 130, 125]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        var profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            clock: &carbImpactTime, // no pump events, set to whatever
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: &profile,
+            mealDate: mealTime,
+            carbImpactDate: carbImpactTime
+        )
+
+        #expect(result.carbsAbsorbed == 0) // No carbs absorbed when glucose is falling
+    }
+
+    @Test("should stop processing when pre-meal BG is found") func stopProcessingWhenPreMealBGFound() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        var carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        // Include glucose data from before meal time
+        let glucoseData = [
+            BloodGlucose(
+                sgv: 150,
+                date: Decimal(mealTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000), // 1 hour after meal
+                dateString: mealTime.addingTimeInterval(60 * 60)
+            ),
+            BloodGlucose(
+                sgv: 120,
+                date: Decimal(mealTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000), // 30 minutes after meal
+                dateString: mealTime.addingTimeInterval(30 * 60)
+            ),
+            BloodGlucose(
+                sgv: 100,
+                date: Decimal(mealTime.addingTimeInterval(-30 * 60).timeIntervalSince1970 * 1000),
+                // 30 minutes before meal (pre-meal)
+                dateString: mealTime.addingTimeInterval(-30 * 60)
+            )
+        ]
+
+        var profile = createBasicProfile()
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            clock: &carbImpactTime, // no pump events, set to whatever
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: &profile,
+            mealDate: mealTime,
+            carbImpactDate: carbImpactTime
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 3.75))
+    }
+
+    @Test("should respect maxMealAbsorptionTime") func respectMaxMealAbsorptionTime() async throws {
+        let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+        var carbImpactTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
+
+        // Create glucose data spanning longer than maxMealAbsorptionTime
+        var glucoseValues: [Int] = []
+        for i in 0 ..< 100 { // 100 * 5 minutes = ~8 hours
+            let value = Int(100 + sin(Double(i) * 0.1) * 20) // Sinusoidal pattern
+            glucoseValues.append(value)
+        }
+
+        let glucoseData = createGlucoseData(
+            startTime: mealTime.addingTimeInterval(-2 * 60 * 60), // Start 2 hours before meal
+            values: glucoseValues
+        )
+
+        var profile = createBasicProfile()
+        profile.maxMealAbsorptionTime = 2 // Only 2 hours
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            clock: &carbImpactTime, // no pump events, set to whatever
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: &profile,
+            mealDate: mealTime,
+            carbImpactDate: carbImpactTime
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 40.5))
+    }
+
+    @Test("should handle minimum carb impact from profile") func handleMinimumCarbImpactFromProfile() async throws {
+        var mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
+
+        // Create glucose data with slight rise to trigger carb absorption
+        let glucoseValues = [100, 101, 102, 103, 104, 105]
+        let glucoseData = createGlucoseData(startTime: mealTime, values: glucoseValues)
+
+        var profile = createBasicProfile()
+        profile.min5mCarbImpact = 5 // Higher minimum impact
+        let basalProfile = createBasalProfile()
+        let pumpHistory: [PumpHistoryEvent] = []
+
+        let result = try MealCob.detectCarbAbsorption(
+            clock: &mealTime, // no pump events, set to whatever
+            glucose: glucoseData,
+            pumpHistory: pumpHistory,
+            basalProfile: basalProfile,
+            profile: &profile,
+            mealDate: mealTime,
+            carbImpactDate: nil
+        )
+
+        #expect(result.carbsAbsorbed.isWithin(0.01, of: 3.75))
+    }
+}

+ 149 - 0
TrioTests/OpenAPSSwiftTests/MealHistoryTests.swift

@@ -0,0 +1,149 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("MealHistory Tests") struct MealHistoryTests {
+    @Test("should process carbs from carbHistory") func processCarbsFromCarbHistory() async {
+        let carbHistory = [
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                carbs: 20
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: [],
+            carbHistory: carbHistory
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].carbs == 20)
+        #expect(output[0].timestamp == Date.from(isoString: "2016-06-19T12:00:00-04:00"))
+    }
+
+    @Test("should process bolus events from pumpHistory") func processBolusEventsFromPumpHistory() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: []
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].bolus == 2.5)
+        #expect(output[0].timestamp == Date.from(isoString: "2016-06-19T12:00:00-04:00"))
+    }
+
+    @Test("should handle both carbs and bolus entries") func handleBothCarbsAndBolusEntries() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            )
+        ]
+
+        let carbHistory = [
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:30:00-04:00"),
+                carbs: 20
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: carbHistory
+        )
+
+        #expect(output.count == 2)
+
+        // Find the carb entry
+        let carbEntry = output.first { $0.carbs != nil }
+        #expect(carbEntry != nil)
+        #expect(carbEntry?.carbs == 20)
+
+        // Find the bolus entry
+        let bolusEntry = output.first { $0.bolus != nil }
+        #expect(bolusEntry != nil)
+        #expect(bolusEntry?.bolus == 2.5)
+    }
+
+    @Test("should dedupe carb entries with same timestamp") func dedupeCarbs() async {
+        let carbHistory = [
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                carbs: 20
+            ),
+            CarbsEntry.forTest(
+                createdAt: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                carbs: 30
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: [],
+            carbHistory: carbHistory
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].carbs == 20)
+    }
+
+    @Test("should dedupe bolus entries with same timestamp") func dedupeBolusEntries() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            ),
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 3.0
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: []
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].bolus == 2.5)
+    }
+
+    @Test("should consider timestamps within 2 seconds as duplicates") func timestampNearlyDuplicates() async {
+        let pumpHistory = [
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:00-04:00"),
+                amount: 2.5
+            ),
+            PumpHistoryEvent(
+                id: UUID().uuidString,
+                type: .bolus,
+                timestamp: Date.from(isoString: "2016-06-19T12:00:01-04:00"),
+                amount: 3.0
+            )
+        ]
+
+        let output = MealHistory.findMealInputs(
+            pumpHistory: pumpHistory,
+            carbHistory: []
+        )
+
+        #expect(output.count == 1)
+        #expect(output[0].bolus == 2.5)
+    }
+}

+ 0 - 0
TrioTests/OpenAPSSwiftTests/MealTotalTests.swift


Неке датотеке нису приказане због велике количине промена