Browse Source

Merge branch 'dev' into fix/nightscout-mmol-import

Deniz Cengiz 4 days ago
parent
commit
62a5429f90
100 changed files with 9495 additions and 827 deletions
  1. 3 6
      .gitmodules
  2. 1 1
      CGMBLEKit
  3. 2 2
      Config.xcconfig
  4. 9 0
      DATA_MAINTAINERS.md
  5. 145 0
      DeveloperDocs/OrefSwift/oref_swift_port_notes.md
  6. 152 0
      DeveloperDocs/OrefSwift/replay.md
  7. 64 0
      DeveloperDocs/OrefSwift/roadmap.md
  8. 1 0
      EversenseKit
  9. 1 1
      G7SensorKit
  10. 1 1
      MedtrumKit
  11. 20 0
      Model/CoreDataStack.swift
  12. 0 14
      Model/JSONImporter.swift
  13. 0 1
      OmniBLE
  14. 0 1
      OmniKit
  15. 1 1
      OmnipodKit
  16. 103 0
      PRIVACY.md
  17. 1 1
      PRIVACY_POLICY.md
  18. 960 258
      Trio.xcodeproj/project.pbxproj
  19. 12 25
      Trio.xcodeproj/xcshareddata/xcschemes/Trio.xcscheme
  20. 2 5
      Trio.xcworkspace/contents.xcworkspacedata
  21. 1 10
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  22. 40 40
      Trio/Resources/InfoPlist.xcstrings
  23. BIN
      Trio/Resources/Sounds/alarm.caf
  24. BIN
      Trio/Resources/Sounds/bloom.caf
  25. BIN
      Trio/Resources/Sounds/bloop.caf
  26. BIN
      Trio/Resources/Sounds/bright_alarm.caf
  27. BIN
      Trio/Resources/Sounds/chime.caf
  28. BIN
      Trio/Resources/Sounds/clear_chimes.caf
  29. BIN
      Trio/Resources/Sounds/critical.caf
  30. BIN
      Trio/Resources/Sounds/crying.caf
  31. BIN
      Trio/Resources/Sounds/dings.caf
  32. BIN
      Trio/Resources/Sounds/high_chimes.caf
  33. BIN
      Trio/Resources/Sounds/honk.caf
  34. BIN
      Trio/Resources/Sounds/minimal.caf
  35. BIN
      Trio/Resources/Sounds/mood_synth.caf
  36. BIN
      Trio/Resources/Sounds/simple.caf
  37. BIN
      Trio/Resources/Sounds/spring.caf
  38. BIN
      Trio/Resources/Sounds/synth.caf
  39. BIN
      Trio/Resources/Sounds/trill.caf
  40. BIN
      Trio/Resources/Sounds/urgent_low.caf
  41. 2 0
      Trio/Resources/Trio.entitlements
  42. 306 170
      Trio/Sources/APS/APSManager.swift
  43. 98 1
      Trio/Sources/APS/CGM/AppGroupSource.swift
  44. 228 0
      Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift
  45. 4 0
      Trio/Sources/APS/CGM/GlucoseSource.swift
  46. 59 10
      Trio/Sources/APS/CGM/PluginSource.swift
  47. 37 142
      Trio/Sources/APS/DeviceDataManager.swift
  48. 8 0
      Trio/Sources/APS/Extensions/DecimalExtensions.swift
  49. 28 1
      Trio/Sources/APS/FetchGlucoseManager.swift
  50. 352 136
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  51. 27 0
      Trio/Sources/APS/OpenAPSSwift/Autosens/AutosensError.swift
  52. 433 0
      Trio/Sources/APS/OpenAPSSwift/Autosens/AutosensGenerator.swift
  53. 46 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DeterminationError.swift
  54. 347 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasal+Helpers.swift
  55. 737 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift
  56. 917 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DosingEngine.swift
  57. 162 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DynamicISF.swift
  58. 114 0
      Trio/Sources/APS/OpenAPSSwift/DetermineBasal/TempBasalFunctions.swift
  59. 11 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedBGTargets+Getter.swift
  60. 24 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedInsulinSensitivities+Getter.swift
  61. 78 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/Date+MinutesFromMidnight.swift
  62. 47 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/Decimal+rounding.swift
  63. 33 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/DoubleApproximateMatching.swift
  64. 9 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/InsulinSensitivities+Convert.swift
  65. 11 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+Autosens.swift
  66. 95 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+TherapySettingGetter.swift
  67. 234 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/PumpHistory+copy.swift
  68. 18 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/PumpHistoryEvent+Duplicates.swift
  69. 27 0
      Trio/Sources/APS/OpenAPSSwift/Extensions/TimeExtensions.swift
  70. 80 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/CarbImpactParams.swift
  71. 244 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator+Forecasts.swift
  72. 433 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift
  73. 45 0
      Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastResults.swift
  74. 131 0
      Trio/Sources/APS/OpenAPSSwift/Iob/IobCalculation.swift
  75. 33 0
      Trio/Sources/APS/OpenAPSSwift/Iob/IobError.swift
  76. 50 0
      Trio/Sources/APS/OpenAPSSwift/Iob/IobGenerator.swift
  77. 485 0
      Trio/Sources/APS/OpenAPSSwift/Iob/IobHistory.swift
  78. 123 0
      Trio/Sources/APS/OpenAPSSwift/JSONBridge.swift
  79. 293 0
      Trio/Sources/APS/OpenAPSSwift/Meal/MealCob.swift
  80. 40 0
      Trio/Sources/APS/OpenAPSSwift/Meal/MealGenerator.swift
  81. 81 0
      Trio/Sources/APS/OpenAPSSwift/Meal/MealHistory.swift
  82. 210 0
      Trio/Sources/APS/OpenAPSSwift/Meal/MealTotal.swift
  83. 7 0
      Trio/Sources/APS/OpenAPSSwift/Models/AdjustedGlucoseTargets.swift
  84. 37 0
      Trio/Sources/APS/OpenAPSSwift/Models/ComputedBGTargets.swift
  85. 61 0
      Trio/Sources/APS/OpenAPSSwift/Models/ComputedInsulinSensitivities.swift
  86. 95 0
      Trio/Sources/APS/OpenAPSSwift/Models/ComputedPumpHistoryEvent.swift
  87. 40 0
      Trio/Sources/APS/OpenAPSSwift/Models/ForecastResult.swift
  88. 28 0
      Trio/Sources/APS/OpenAPSSwift/Models/GlucoseStatus.swift
  89. 92 0
      Trio/Sources/APS/OpenAPSSwift/Models/IobResult.swift
  90. 138 0
      Trio/Sources/APS/OpenAPSSwift/Models/Profile.swift
  91. 210 0
      Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift
  92. 13 0
      Trio/Sources/APS/OpenAPSSwift/OrefFunctionResult.swift
  93. 35 0
      Trio/Sources/APS/OpenAPSSwift/Profile/Basal.swift
  94. 35 0
      Trio/Sources/APS/OpenAPSSwift/Profile/Carbs.swift
  95. 67 0
      Trio/Sources/APS/OpenAPSSwift/Profile/Isf.swift
  96. 33 0
      Trio/Sources/APS/OpenAPSSwift/Profile/ProfileError.swift
  97. 222 0
      Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift
  98. 94 0
      Trio/Sources/APS/OpenAPSSwift/Profile/Targets.swift
  99. 29 0
      Trio/Sources/APS/OpenAPSSwift/Utils/JavascriptOptional.swift
  100. 0 0
      Trio/Sources/APS/PluginManager.swift

+ 3 - 6
.gitmodules

@@ -10,15 +10,9 @@
 [submodule "RileyLinkKit"]
 	path = RileyLinkKit
 	url = https://github.com/loopandlearn/RileyLinkKit
-[submodule "OmniBLE"]
-	path = OmniBLE
-	url = https://github.com/loopandlearn/OmniBLE.git
 [submodule "G7SensorKit"]
 	path = G7SensorKit
 	url = https://github.com/loopandlearn/G7SensorKit.git
-[submodule "OmniKit"]
-	path = OmniKit
-	url = https://github.com/loopandlearn/OmniKit.git
 [submodule "MinimedKit"]
 	path = MinimedKit
 	url = https://github.com/loopandlearn/MinimedKit.git
@@ -37,3 +31,6 @@
 [submodule "OmnipodKit"]
 	path = OmnipodKit
 	url = https://github.com/loopandlearn/OmnipodKit
+[submodule "EversenseKit"]
+	path = EversenseKit
+	url = https://github.com/loopandlearn/EversenseKit

+ 1 - 1
CGMBLEKit

@@ -1 +1 @@
-Subproject commit 98fae7929c8c8e4e849d18a70c1f249dd6c09e5f
+Subproject commit 26ad72115d3b98b05668d57b817b1bf961f8a721

+ 2 - 2
Config.xcconfig

@@ -18,8 +18,8 @@ BUNDLE_IDENTIFIER = org.nightscout.$(DEVELOPMENT_TEAM).trio
 TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
-APP_VERSION = 0.8.1
-APP_DEV_VERSION = 0.8.1.7
+APP_VERSION = 0.8.4
+APP_DEV_VERSION = 0.8.4.9
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 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

+ 1 - 0
EversenseKit

@@ -0,0 +1 @@
+Subproject commit 66979665bc73137b0a70e9b4f6f88f21558fa4c9

+ 1 - 1
G7SensorKit

@@ -1 +1 @@
-Subproject commit 4d0780db06c7c95b3a3bf3cdb2f2838d521e411a
+Subproject commit 0c87905297b70a7f0fa41e87fa0fbe1efff6c29f

+ 1 - 1
MedtrumKit

@@ -1 +1 @@
-Subproject commit 7a3cb276b65ebaee7a3d6485878baa525399c3ee
+Subproject commit 3ad0979a3c8fc36c2d48a76da8ea70ef2132943a

+ 20 - 0
Model/CoreDataStack.swift

@@ -113,11 +113,31 @@ class CoreDataStack: ObservableObject {
     func fetchPersistentHistory() async {
         do {
             try await fetchPersistentHistoryTransactionsAndChanges()
+        } catch let error as NSError where error.code == NSPersistentHistoryTokenExpiredError {
+            debug(.coreData, "Persistent history token expired; clearing token and replaying available history.")
+            await recoverFromExpiredHistoryToken()
         } catch {
             debug(.coreData, "\(error)")
         }
     }
 
+    /// Recovers the change-merge pipeline after the persistent history token expires.
+    private func recoverFromExpiredHistoryToken() async {
+        lastToken = nil
+        do {
+            try await fetchPersistentHistoryTransactionsAndChanges()
+        } catch {
+            debug(.coreData, "Replay after token reset failed; jumping to current token. \(error)")
+            let viewContext = persistentContainer.viewContext
+            let currentToken = persistentContainer.persistentStoreCoordinator
+                .currentPersistentHistoryToken(fromStores: nil)
+            lastToken = currentToken
+            await viewContext.perform {
+                viewContext.refreshAllObjects()
+            }
+        }
+    }
+
     private func fetchPersistentHistoryTransactionsAndChanges() async throws {
         let taskContext = newTaskContext()
         taskContext.name = "persistentHistoryContext"

+ 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

+ 0 - 1
OmniBLE

@@ -1 +0,0 @@
-Subproject commit 1912793284b736754a0f25cf3d828e2eecd9ff2f

+ 0 - 1
OmniKit

@@ -1 +0,0 @@
-Subproject commit 38af22b3d36e05a4cdffb242a1a47b347a4031fc

+ 1 - 1
OmnipodKit

@@ -1 +1 @@
-Subproject commit adf210d20bcfc05f8119e8665d8f5904db828e03
+Subproject commit b5b95562f73e6d3627e3e20ffe31f8d7f6a4639e

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

+ 1 - 1
PRIVACY_POLICY.md

@@ -72,7 +72,7 @@ The following information is included in the telemetry payload:
 - A small set of preference flags: units (mg/dL or mmol/L), closed-loop
   on/off, Live Activity enabled, calendar integration enabled
 - A rolling 7-day count of how often the app was cold-launched
-- The commit SHAs of pinned submodules (e.g. LoopKit, OmniBLE)
+- The commit SHAs of pinned submodules (e.g. LoopKit, OmnipodKit)
 - The device's system locale (e.g. "en_US") — used to help Trio
   developers understand which languages to prioritize for translation
 - The device's time zone identifier (e.g. "America/New_York") — used

File diff suppressed because it is too large
+ 960 - 258
Trio.xcodeproj/project.pbxproj


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

@@ -132,27 +132,24 @@
                ReferencedContainer = "container:LoopKit/LoopKit.xcodeproj">
             </BuildableReference>
          </BuildActionEntry>
-         <BuildActionEntry
-            buildForTesting = "NO"
-            buildForRunning = "NO"
-            buildForProfiling = "NO"
-            buildForArchiving = "NO"
-            buildForAnalyzing = "NO">
-            <BuildableReference
-               BuildableIdentifier = "primary"
-               BlueprintIdentifier = "84752E8A26ED0FFE009FD801"
-               BuildableName = "OmniBLETests.xctest"
-               BlueprintName = "OmniBLETests"
-               ReferencedContainer = "container:OmniBLE/OmniBLE.xcodeproj">
-            </BuildableReference>
-         </BuildActionEntry>
       </BuildActionEntries>
    </BuildAction>
    <TestAction
       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">
@@ -198,16 +195,6 @@
             skipped = "NO">
             <BuildableReference
                BuildableIdentifier = "primary"
-               BlueprintIdentifier = "C12ED9C929C7DBA900435701"
-               BuildableName = "OmniKitTests.xctest"
-               BlueprintName = "OmniKitTests"
-               ReferencedContainer = "container:OmniKit/OmniKit.xcodeproj">
-            </BuildableReference>
-         </TestableReference>
-         <TestableReference
-            skipped = "NO">
-            <BuildableReference
-               BuildableIdentifier = "primary"
                BlueprintIdentifier = "431CE7761F98564200255374"
                BuildableName = "RileyLinkBLEKitTests.xctest"
                BlueprintName = "RileyLinkBLEKitTests"

+ 2 - 5
Trio.xcworkspace/contents.xcworkspacedata

@@ -14,9 +14,6 @@
       location = "group:LibreTransmitter/LibreTransmitter.xcodeproj">
    </FileRef>
    <FileRef
-      location = "group:OmniBLE/OmniBLE.xcodeproj">
-   </FileRef>
-   <FileRef
       location = "group:DanaKit/DanaKit.xcodeproj">
    </FileRef>
    <FileRef
@@ -35,10 +32,10 @@
       location = "group:LoopKit/LoopKit.xcodeproj">
    </FileRef>
    <FileRef
-      location = "group:OmniKit/OmniKit.xcodeproj">
+      location = "group:G7SensorKit/G7SensorKit.xcodeproj">
    </FileRef>
    <FileRef
-      location = "group:G7SensorKit/G7SensorKit.xcodeproj">
+      location = "group:EversenseKit/EversenseKit.xcodeproj">
    </FileRef>
    <FileRef
       location = "group:TidepoolService/TidepoolService.xcodeproj">

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

@@ -1,5 +1,5 @@
 {
-  "originHash" : "1e72c1cdf8ea5ec9fe527ebfab01ea55fca9e8651fe3252338fd3d4ea2cb327a",
+  "originHash" : "af604046297daf839c0c212e73929960a33a0fecfdb97b64b9356d8a98b7bd60",
   "pins" : [
     {
       "identity" : "abseil-cpp-binary",
@@ -254,15 +254,6 @@
       }
     },
     {
-      "identity" : "swiftmessages",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/SwiftKickMobile/SwiftMessages",
-      "state" : {
-        "revision" : "62e12e138fc3eedf88c7553dd5d98712aa119f40",
-        "version" : "9.0.9"
-      }
-    },
-    {
       "identity" : "swinject",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/Swinject/Swinject",

+ 40 - 40
Trio/Resources/InfoPlist.xcstrings

@@ -7,25 +7,25 @@
       "localizations" : {
         "bg" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "cs" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "da" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "de" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
@@ -37,97 +37,97 @@
         },
         "es" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "fr" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "he" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "it" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "ko" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "nb-NO" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "nl" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "pl" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "pt-PT" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "ro" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "ru" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "sv" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "tr" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "uk" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "vi" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "zh-Hant" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         }
@@ -139,25 +139,25 @@
       "localizations" : {
         "bg" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "cs" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "da" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "de" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trios"
           }
         },
@@ -169,97 +169,97 @@
         },
         "es" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "fr" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "he" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "it" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "ko" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "nb-NO" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "nl" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "pl" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "pt-PT" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "ro" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "ru" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "sv" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "tr" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "uk" : {
           "stringUnit" : {
-            "state" : "new",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "vi" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         },
         "zh-Hant" : {
           "stringUnit" : {
-            "state" : "translated",
+            "state" : "needs_review",
             "value" : "Trio"
           }
         }

BIN
Trio/Resources/Sounds/alarm.caf


BIN
Trio/Resources/Sounds/bloom.caf


BIN
Trio/Resources/Sounds/bloop.caf


BIN
Trio/Resources/Sounds/bright_alarm.caf


BIN
Trio/Resources/Sounds/chime.caf


BIN
Trio/Resources/Sounds/clear_chimes.caf


BIN
Trio/Resources/Sounds/critical.caf


BIN
Trio/Resources/Sounds/crying.caf


BIN
Trio/Resources/Sounds/dings.caf


BIN
Trio/Resources/Sounds/high_chimes.caf


BIN
Trio/Resources/Sounds/honk.caf


BIN
Trio/Resources/Sounds/minimal.caf


BIN
Trio/Resources/Sounds/mood_synth.caf


BIN
Trio/Resources/Sounds/simple.caf


BIN
Trio/Resources/Sounds/spring.caf


BIN
Trio/Resources/Sounds/synth.caf


BIN
Trio/Resources/Sounds/trill.caf


BIN
Trio/Resources/Sounds/urgent_low.caf


+ 2 - 0
Trio/Resources/Trio.entitlements

@@ -14,6 +14,8 @@
 	<array>
 		<string>TAG</string>
 	</array>
+	<key>com.apple.developer.usernotifications.time-sensitive</key>
+	<true/>
 	<key>com.apple.security.application-groups</key>
 	<array>
 		<string>$(APP_GROUP_ID)</string>

+ 306 - 170
Trio/Sources/APS/APSManager.swift

@@ -8,6 +8,11 @@ import Swinject
 
 protocol APSManager {
     func heartbeat(date: Date)
+    /// Mark the next loop attempt as user-initiated (e.g. force-loop button).
+    /// Surfaces transient errors immediately instead of waiting for the
+    /// usual dwell threshold — when the user explicitly asks for a loop,
+    /// they want feedback even if the underlying error is "transient".
+    func markNextLoopUserInitiated()
     func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async
     var pumpManager: PumpManagerUI? { get set }
     var bluetoothManager: BluetoothStateManager? { get }
@@ -36,6 +41,13 @@ protocol APSManager {
     var iobFileDidUpdate: PassthroughSubject<Void, Never> { get }
 }
 
+/// Notified after a bolus-related failure so observing UI (e.g. the
+/// treatment screen's bolus state) can clean up state. Broadcast by
+/// `APSManager` from `enactBolus` / `cancelBolus` error paths.
+protocol BolusFailureObserver {
+    func bolusDidFail()
+}
+
 enum APSError: LocalizedError {
     case pumpError(Error)
     case invalidPumpState(message: String)
@@ -57,16 +69,27 @@ enum APSError: LocalizedError {
             return String(localized: "Manual Temporary Basal Rate (\(message)). Looping suspended.")
         }
     }
+}
 
-    static func pumpErrorMatches(message: String) -> Bool {
-        message.contains(String(localized: "Pump Error"))
+// MARK: - Thread-safe loop serialization
+
+/// Ensures only one loop runs at a time via actor isolation
+private actor LoopGuard {
+    private var isRunning = false
+
+    /// Atomically checks whether a new loop can start and marks it as running if so.
+    func tryStart(minInterval: TimeInterval, lastLoopDate: Date, lastLoopStartDate: Date) -> Bool {
+        // If the last loop completed after it started, enforce minimum interval
+        if lastLoopDate > lastLoopStartDate {
+            guard lastLoopStartDate.addingTimeInterval(minInterval) < Date() else { return false }
+        }
+        guard !isRunning else { return false }
+        isRunning = true
+        return true
     }
 
-    static func pumpWarningMatches(message: String) -> Bool {
-        message.contains(String(localized: "Invalid Pump State")) || message
-            .contains("PumpMessage") || message
-            .contains("PumpOpsError") || message.contains("RileyLink") || message
-            .contains(String(localized: "Pump did not respond in time"))
+    func finish() {
+        isRunning = false
     }
 }
 
@@ -82,6 +105,7 @@ final class BaseAPSManager: APSManager, Injectable {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var tddStorage: TDDStorage!
     @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var trioAlertManager: TrioAlertManager!
     @Persisted(key: "lastLoopStartDate") private var lastLoopStartDate: Date = .distantPast
     @Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast {
         didSet {
@@ -89,14 +113,18 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
-    let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     let privateContext = CoreDataStack.shared.newTaskContext()
 
     private var openAPS: OpenAPS!
 
     private var lifetime = Lifetime()
 
-    private var backgroundTaskID: UIBackgroundTaskIdentifier?
+    private let loopGuard = LoopGuard()
+    /// All reads/writes are dispatched onto `processQueue` so the bolus
+    /// trigger sink, `cancelBolus`, and the `DoseProgressReporter`
+    /// callback (which the pump manager already invokes on
+    /// `processQueue`) all serialize through one queue
+    private var bolusReporter: DoseProgressReporter?
 
     var pumpManager: PumpManagerUI? {
         get { deviceDataManager.pumpManager }
@@ -156,7 +184,7 @@ final class BaseAPSManager: APSManager, Injectable {
             if wasParsed {
                 Task {
                     do {
-                        try await openAPS.createProfiles()
+                        try await openAPS.createProfiles(useJavascriptOref: settings.useJavascriptOref)
                     } catch {
                         debug(
                             .apsManager,
@@ -178,46 +206,48 @@ final class BaseAPSManager: APSManager, Injectable {
         deviceDataManager.errorSubject
             .receive(on: processQueue)
             .map { APSError.pumpError($0) }
-            .sink {
-                self.processError($0)
+            .sink { [weak self] in
+                self?.processError($0)
             }
             .store(in: &lifetime)
 
         deviceDataManager.bolusTrigger
             .receive(on: processQueue)
-            .sink { bolusing in
+            .sink { [weak self] bolusing in
                 if bolusing {
-                    self.createBolusReporter()
+                    self?.createBolusReporter()
                 } else {
-                    self.clearBolusReporter()
+                    self?.clearBolusReporter()
                 }
             }
             .store(in: &lifetime)
 
+        // The following three publishers update `@Persisted` properties that
+        // are also read from the main thread (UI bindings)
         deviceDataManager.scheduledBasal
-            .receive(on: processQueue)
-            .sink { scheduledBasal in
-                self.isScheduledBasal = scheduledBasal
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] scheduledBasal in
+                self?.isScheduledBasal = scheduledBasal
             }
             .store(in: &lifetime)
 
         deviceDataManager.suspended
-            .receive(on: processQueue)
-            .sink { suspended in
-                self.isSuspended = suspended
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] suspended in
+                self?.isSuspended = suspended
             }
             .store(in: &lifetime)
 
         // manage a manual Temp Basal from PumpManager - force loop() after manual temp basal is cancelled or finishes
         deviceDataManager.manualTempBasal
-            .receive(on: processQueue)
-            .sink { manualBasal in
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] manualBasal in
                 if manualBasal {
-                    self.isManualTempBasal = true
+                    self?.isManualTempBasal = true
                 } else {
-                    if self.isManualTempBasal {
-                        self.isManualTempBasal = false
-                        self.loop()
+                    if self?.isManualTempBasal == true {
+                        self?.isManualTempBasal = false
+                        self?.loop()
                     }
                 }
             }
@@ -233,116 +263,115 @@ final class BaseAPSManager: APSManager, Injectable {
         Task { [weak self] in
             guard let self else { return }
 
-            // Check if we can start a new loop
-            guard await self.canStartNewLoop() else { return }
+            // Consume the user-initiated flag unconditionally — it was set
+            // for the loop the user just triggered. If the guards below block
+            // (suspended, too-soon, no pump), the next scheduled tick must
+            // not inherit it and bypass dwell suppression for an error the
+            // user didn't request.
+            let userInitiated = self.nextLoopUserInitiated
+            self.nextLoopUserInitiated = false
+
+            // Don't try to run a loop while pump setup / pod pairing is in
+            // progress — `verifyStatus` would throw `invalidPumpState("Pump
+            // not set")` and surface a modal banner on top of the pod
+            // activation sheet, closing the sheet (reported by tester during
+            // O5 pairing).
+            guard self.pumpManager != nil else {
+                debug(.apsManager, "No pump manager — skipping loop attempt")
+                return
+            }
 
-            // Setup loop and background task
-            var (loopStatRecord, backgroundTask) = await self.setupLoop()
+            // Atomic check-and-set via actor — eliminates the race between
+            // checking isLooping.value and sending isLooping(true).
+            guard await loopGuard.tryStart(
+                minInterval: Config.loopInterval,
+                lastLoopDate: lastLoopDate,
+                lastLoopStartDate: lastLoopStartDate
+            ) else {
+                debug(.apsManager, "Loop skipped (already running or too soon)")
+                return
+            }
 
-            do {
-                // Execute loop logic
-                try await self.executeLoop(loopStatRecord: &loopStatRecord)
+            // Affects whether transient errors surface immediately instead of
+            // dwell-suppressed (see `surfaceErrorIfNeeded`).
+            self.currentLoopUserInitiated = userInitiated
+            defer { self.currentLoopUserInitiated = false }
+
+            // Start background task
+            // we probably need to refactor this when implementing Swift 6 due to mutation of a captured var in an async context
+            var taskID: UIBackgroundTaskIdentifier = .invalid
+            taskID = await UIApplication.shared.beginBackgroundTask(withName: "Loop starting") {
+                // closure runs on the Main Thread
+                // removed the Task that provided no guarantee to end the background task
+                if taskID != .invalid {
+                    UIApplication.shared.endBackgroundTask(taskID)
+                    taskID = .invalid
+                }
+            }
+
+            isLooping.send(true)
+
+            let loopStartDate = Date()
+            lastLoopStartDate = loopStartDate
+            let interval = await calculateLoopInterval(loopStartDate: loopStartDate)
+
+            var loopStatRecord = LoopStats(
+                start: loopStartDate,
+                loopStatus: "Starting",
+                interval: interval
+            )
 
+            do {
+                try await executeLoop(loopStatRecord: &loopStatRecord)
                 requestNightscoutUpload(
                     [.carbs, .pumpHistory, .overrides, .tempTargets],
                     source: "APSManager"
                 )
+                await finalizeLoop(loopStatRecord: loopStatRecord)
             } catch {
-                var updatedStats = loopStatRecord
-                updatedStats.end = Date()
-                updatedStats.duration = roundDouble((updatedStats.end! - updatedStats.start).timeInterval / 60, 2)
-                updatedStats.loopStatus = error.localizedDescription
-                await loopCompleted(error: error, loopStatRecord: updatedStats)
+                let endDate = Date()
+                loopStatRecord.end = endDate
+                loopStatRecord.duration = roundDouble((endDate - loopStatRecord.start).timeInterval / 60, 2)
+                loopStatRecord.loopStatus = error.localizedDescription
+                await finalizeLoop(error: error, loopStatRecord: loopStatRecord)
                 debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to complete Loop: \(error)")
             }
 
-            // Cleanup background task
-            if let backgroundTask = backgroundTask {
-                await UIApplication.shared.endBackgroundTask(backgroundTask)
-                self.backgroundTaskID = .invalid
-            }
-        }
-    }
-
-    private func canStartNewLoop() async -> Bool {
-        // Check if too soon for next loop
-        if lastLoopDate > lastLoopStartDate {
-            guard lastLoopStartDate.addingTimeInterval(Config.loopInterval) < Date() else {
-                debug(.apsManager, "Not enough time have passed since last loop at : \(lastLoopStartDate)")
-                return false
+            // End the background task
+            if taskID != .invalid {
+                await UIApplication.shared.endBackgroundTask(taskID)
+                taskID = .invalid
             }
         }
-
-        // Check if loop already in progress
-        guard !isLooping.value else {
-            warning(.apsManager, "Loop already in progress. Skip recommendation.")
-            return false
-        }
-
-        return true
-    }
-
-    private func setupLoop() async -> (LoopStats, UIBackgroundTaskIdentifier?) {
-        // Start background task
-        let backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: "Loop starting") { [weak self] in
-            guard let self, let backgroundTask = self.backgroundTaskID else { return }
-            Task {
-                UIApplication.shared.endBackgroundTask(backgroundTask)
-            }
-            self.backgroundTaskID = .invalid
-        }
-        backgroundTaskID = backgroundTask
-
-        // Set loop start time
-        lastLoopStartDate = Date()
-
-        // Calculate interval from previous loop
-        let interval = await calculateLoopInterval()
-
-        // Create initial loop stats record
-        let loopStatRecord = LoopStats(
-            start: lastLoopStartDate,
-            loopStatus: "Starting",
-            interval: interval
-        )
-
-        isLooping.send(true)
-
-        return (loopStatRecord, backgroundTask)
     }
 
     private func executeLoop(loopStatRecord: inout LoopStats) async throws {
         try await determineBasal()
 
-        // Handle open loop
-        guard settings.closedLoop else {
-            loopStatRecord.end = Date()
-            loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
-            loopStatRecord.loopStatus = "Success"
-            await loopCompleted(loopStatRecord: loopStatRecord)
-            return
+        // Closed loop: also enact the determination.
+        if settings.closedLoop {
+            try await enactDetermination()
         }
 
-        // Handle closed loop
-        try await enactDetermination()
-        loopStatRecord.end = Date()
-        loopStatRecord.duration = roundDouble((loopStatRecord.end! - loopStatRecord.start).timeInterval / 60, 2)
+        let endDate = Date()
+        loopStatRecord.end = endDate
+        loopStatRecord.duration = roundDouble((endDate - loopStatRecord.start).timeInterval / 60, 2)
         loopStatRecord.loopStatus = "Success"
-        await loopCompleted(loopStatRecord: loopStatRecord)
     }
 
-    private func calculateLoopInterval() async -> Double? {
+    private func calculateLoopInterval(loopStartDate: Date) async -> Double? {
         do {
-            return try await privateContext.perform {
+            return try await privateContext.perform { [weak self] in
+                guard let self else { return nil }
                 let requestStats = LoopStatRecord.fetchRequest() as NSFetchRequest<LoopStatRecord>
                 let sortStats = NSSortDescriptor(key: "end", ascending: false)
                 requestStats.sortDescriptors = [sortStats]
                 requestStats.fetchLimit = 1
                 let previousLoop = try self.privateContext.fetch(requestStats)
 
-                if (previousLoop.first?.end ?? .distantFuture) < self.lastLoopStartDate {
+                if (previousLoop.first?.end ?? .distantFuture) < loopStartDate {
                     return self.roundDouble(
-                        (self.lastLoopStartDate - (previousLoop.first?.end ?? Date())).timeInterval / 60,
+                        (loopStartDate - (previousLoop.first?.end ?? Date())).timeInterval / 60,
                         1
                     )
                 }
@@ -354,21 +383,20 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
-    // Loop exit point
-    private func loopCompleted(error: Error? = nil, loopStatRecord: LoopStats) async {
+    /// Single exit point for loop — replaces the old `loopCompleted()`.
+    private func finalizeLoop(error: Error? = nil, loopStatRecord: LoopStats) async {
+        await loopGuard.finish()
         isLooping.send(false)
 
         if let error = error {
             warning(.apsManager, "Loop failed with error: \(error)")
-            if let backgroundTask = backgroundTaskID {
-                await UIApplication.shared.endBackgroundTask(backgroundTask)
-                backgroundTaskID = .invalid
-            }
             processError(error)
         } else {
             debug(.apsManager, "Loop succeeded")
             lastLoopDate = Date()
             lastError.send(nil)
+            transientCategoryFirstSeen.removeAll()
+            transientCategoryCount.removeAll()
         }
 
         loopStats(loopStatRecord: loopStatRecord)
@@ -376,12 +404,6 @@ final class BaseAPSManager: APSManager, Injectable {
         if settings.closedLoop {
             await reportEnacted(wasEnacted: error == nil)
         }
-
-        // End of the BG tasks
-        if let backgroundTask = backgroundTaskID {
-            await UIApplication.shared.endBackgroundTask(backgroundTask)
-            backgroundTaskID = .invalid
-        }
     }
 
     private func verifyStatus() -> Error? {
@@ -410,7 +432,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,
+                useJavascriptOref: settings.useJavascriptOref
+            )
             return result != nil
         }
 
@@ -475,15 +500,15 @@ final class BaseAPSManager: APSManager, Injectable {
         do {
             let now = Date()
 
-            // Parallelize the fetches using async let
-            async let currentTemp = fetchCurrentTempBasal(date: now)
-            async let autosenseResult = autosense()
+            // put profile creation up front since autosens needs it
+            try await openAPS.createProfiles(useJavascriptOref: settings.useJavascriptOref)
+            let currentTemp = try await fetchCurrentTempBasal(date: now)
+            _ = try await autosense()
 
-            _ = try await autosenseResult
-            try await openAPS.createProfiles()
             let determination = try await openAPS.determineBasal(
-                currentTemp: await currentTemp,
+                currentTemp: currentTemp,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
+                useJavascriptOref: settings.useJavascriptOref,
                 clock: now
             )
             iobFileDidUpdate.send(())
@@ -530,6 +555,7 @@ final class BaseAPSManager: APSManager, Injectable {
             return try await openAPS.determineBasal(
                 currentTemp: temp,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
+                useJavascriptOref: settings.useJavascriptOref,
                 clock: Date(),
                 simulatedCarbsAmount: simulatedCarbsAmount,
                 simulatedBolusAmount: simulatedBolusAmount,
@@ -551,8 +577,6 @@ final class BaseAPSManager: APSManager, Injectable {
         return min(rounded, maxBolus)
     }
 
-    private var bolusReporter: DoseProgressReporter?
-
     func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async {
         if amount <= 0 {
             return
@@ -597,9 +621,14 @@ final class BaseAPSManager: APSManager, Injectable {
             }
         } catch {
             warning(.apsManager, "Bolus failed with error: \(error)")
-            processError(APSError.pumpError(error))
+            lastError.send(APSError.pumpError(error))
+            issueAlertForCategory(
+                .bolusFailed,
+                title: String(localized: "Bolus failed"),
+                body: String(localized: "Check pump history before repeating.")
+                    + "\n\n\(error.localizedDescription)"
+            )
             if !isSMB {
-                // Use MainActor to handle broadcaster notification
                 let broadcaster = self.broadcaster
                 Task { @MainActor in
                     broadcaster?.notify(BolusFailureObserver.self, on: .main) {
@@ -623,7 +652,12 @@ final class BaseAPSManager: APSManager, Injectable {
             callback?(true, String(localized: "Bolus cancelled successfully.", comment: "Success message for canceling a bolus"))
         } catch {
             debug(.apsManager, "Bolus cancellation failed with error: \(error)")
-            processError(APSError.pumpError(error))
+            lastError.send(APSError.pumpError(error))
+            issueAlertForCategory(
+                .bolusFailed,
+                title: String(localized: "Bolus cancellation failed"),
+                body: String(localized: "Try again.") + "\n\n\(error.localizedDescription)"
+            )
             callback?(
                 false,
                 String(
@@ -632,9 +666,7 @@ final class BaseAPSManager: APSManager, Injectable {
                 )
             )
         }
-        bolusReporter?.removeObserver(self)
-        bolusReporter = nil
-        bolusProgress.send(nil)
+        clearBolusReporter()
     }
 
     func enactTempBasal(rate: Double, duration: TimeInterval) async {
@@ -714,10 +746,9 @@ final class BaseAPSManager: APSManager, Injectable {
             throw APSError.apsError(message: "Pump not set")
         }
 
-        // Check if pump is suspended and abort if it is
         if pump.status.pumpStatus.suspended {
-            info(.apsManager, "Skipping enactDetermination because pump is suspended")
-            return // return without throwing an error
+            debug(.apsManager, "Skipping enactDetermination because pump is suspended")
+            return
         }
 
         // Unable to do temp basal during manual temp basal 😁
@@ -833,31 +864,31 @@ final class BaseAPSManager: APSManager, Injectable {
         return Double(sorted[length / 2])
     }
 
+    /// Computes Time-in-Range statistics. Must be called from inside a
+    /// `privateContext.perform` block — the inner perform was redundant
     private func tir(_ glucose: [GlucoseStored]) -> (TIR: Double, hypos: Double, hypers: Double, normal_: Double) {
-        privateContext.perform {
-            let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
-            let totalReadings = justGlucoseArray.count
-            let highLimit = settingsManager.settings.high
-            let lowLimit = settingsManager.settings.low
-            let hyperArray = glucose.filter({ $0.glucose >= Int(highLimit) })
-            let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
-            let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
-            let hypoArray = glucose.filter({ $0.glucose <= Int(lowLimit) })
-            let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
-            let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
-            // Euglyccemic range
-            let normalArray = glucose.filter({ $0.glucose >= 70 && $0.glucose <= 140 })
-            let normalReadings = normalArray.compactMap({ each in each.glucose as Int16 }).count
-            let normalPercentage = Double(normalReadings) / Double(totalReadings) * 100
-            // TIR
-            let tir = 100 - (hypoPercentage + hyperPercentage)
-            return (
-                roundDouble(tir, 1),
-                roundDouble(hypoPercentage, 1),
-                roundDouble(hyperPercentage, 1),
-                roundDouble(normalPercentage, 1)
-            )
-        }
+        let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
+        let totalReadings = justGlucoseArray.count
+        let highLimit = settingsManager.settings.high
+        let lowLimit = settingsManager.settings.low
+        let hyperArray = glucose.filter({ $0.glucose >= Int(highLimit) })
+        let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
+        let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
+        let hypoArray = glucose.filter({ $0.glucose <= Int(lowLimit) })
+        let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
+        let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
+        // Euglycemic range
+        let normalArray = glucose.filter({ $0.glucose >= 70 && $0.glucose <= 140 })
+        let normalReadings = normalArray.compactMap({ each in each.glucose as Int16 }).count
+        let normalPercentage = Double(normalReadings) / Double(totalReadings) * 100
+        // TIR
+        let tir = 100 - (hypoPercentage + hyperPercentage)
+        return (
+            roundDouble(tir, 1),
+            roundDouble(hypoPercentage, 1),
+            roundDouble(hyperPercentage, 1),
+            roundDouble(normalPercentage, 1)
+        )
     }
 
     private func glucoseStats(_ fetchedGlucose: [GlucoseStored])
@@ -1055,17 +1086,14 @@ final class BaseAPSManager: APSManager, Injectable {
             let glucose24h = try await fetchGlucose(predicate: NSPredicate.predicateForOneDayAgo, fetchLimit: 288, batchSize: 50)
             let glucoseOneWeek = try await fetchGlucose(
                 predicate: NSPredicate.predicateForOneWeek,
-                fetchLimit: 288 * 7,
                 batchSize: 250
             )
             let glucoseOneMonth = try await fetchGlucose(
                 predicate: NSPredicate.predicateForOneMonth,
-                fetchLimit: 288 * 7 * 30,
                 batchSize: 500
             )
             let glucoseThreeMonths = try await fetchGlucose(
                 predicate: NSPredicate.predicateForThreeMonths,
-                fetchLimit: 288 * 7 * 30 * 3,
                 batchSize: 1000
             )
 
@@ -1191,7 +1219,8 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func loopStats(loopStatRecord: LoopStats) {
-        privateContext.perform {
+        privateContext.perform { [weak self] in
+            guard let self else { return }
             let nLS = LoopStatRecord(context: self.privateContext)
             nLS.start = loopStatRecord.start
             nLS.end = loopStatRecord.end ?? Date()
@@ -1208,20 +1237,126 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
+    private var transientCategoryFirstSeen: [String: Date] = [:]
+    private var transientCategoryCount: [String: Int] = [:]
+    private static let transientDwellThreshold: TimeInterval = 60
+    private static let transientCountThreshold = 2
+
+    /// Set by `markNextLoopUserInitiated()` (e.g. force-loop button), consumed
+    /// on the next entry into `loop()` so that errors during a user-initiated
+    /// loop surface immediately instead of being suppressed by dwell logic.
+    @SyncAccess private var nextLoopUserInitiated: Bool = false
+    private var currentLoopUserInitiated: Bool = false
+
+    func markNextLoopUserInitiated() {
+        nextLoopUserInitiated = true
+    }
+
     private func processError(_ error: Error) {
         warning(.apsManager, "\(error)")
         lastError.send(error)
+        surfaceErrorIfNeeded(error)
     }
 
+    private func surfaceErrorIfNeeded(_ error: Error) {
+        let category = TrioAlertClassifier.categorize(error: error)
+        let key = String(describing: category)
+
+        if category.shouldFireImmediately || currentLoopUserInitiated {
+            transientCategoryFirstSeen.removeValue(forKey: key)
+            transientCategoryCount.removeValue(forKey: key)
+            issueAlertForError(error, category: category)
+            return
+        }
+
+        let now = Date()
+        let firstSeen = transientCategoryFirstSeen[key] ?? now
+        let count = (transientCategoryCount[key] ?? 0) + 1
+        let dwellElapsed = now.timeIntervalSince(firstSeen)
+        let dwellMet = dwellElapsed >= Self.transientDwellThreshold
+        let countMet = count >= Self.transientCountThreshold
+
+        if dwellMet || countMet {
+            transientCategoryFirstSeen.removeValue(forKey: key)
+            transientCategoryCount.removeValue(forKey: key)
+            issueAlertForError(error, category: category)
+        } else {
+            transientCategoryFirstSeen[key] = firstSeen
+            transientCategoryCount[key] = count
+            debug(
+                .apsManager,
+                "APSManager suppressed transient \(category) (count=\(count)/\(Self.transientCountThreshold), dwell=\(Int(dwellElapsed))s/\(Int(Self.transientDwellThreshold))s)"
+            )
+        }
+    }
+
+    private func issueAlertForCategory(_ category: TrioAlertCategory, title: String, body: String) {
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: String(localized: "OK")
+        )
+        let alert = Alert(
+            identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: category.alertIdentifier),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: category.interruptionLevel
+        )
+        trioAlertManager?.issueAlert(alert)
+    }
+
+    private func issueAlertForError(_ error: Error, category: TrioAlertCategory) {
+        let (title, body) = describeForAlert(error)
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: "OK"
+        )
+        let alert = Alert(
+            identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: category.alertIdentifier),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: category.interruptionLevel
+        )
+        trioAlertManager?.issueAlert(alert)
+    }
+
+    private func describeForAlert(_ error: Error) -> (title: String, body: String) {
+        if let apsError = error as? APSError {
+            switch apsError {
+            case let .pumpError(inner):
+                return (
+                    String(localized: "Pump Error"),
+                    String(localized: "Trio could not communicate with the pump. Check the pump and try again.")
+                        + "\n\n\(inner.localizedDescription)"
+                )
+            case let .invalidPumpState(message): return (String(localized: "Pump State Error"), message)
+            case let .glucoseError(message): return (String(localized: "Glucose Error"), message)
+            case let .apsError(message): return (String(localized: "Algorithm Error"), message)
+            case let .manualBasalTemp(message): return (String(localized: "Manual Temp Basal Active"), message)
+            }
+        }
+        return ("Trio", error.localizedDescription)
+    }
+
+    /// Called from the `bolusTrigger` Combine sink (already on
+    /// `processQueue`) and from `doseProgressReporterDidUpdate` (the
+    /// pump manager schedules the callback on `processQueue` too).
+    /// Mutations are dispatched onto the queue regardless, so a future
+    /// caller from another context (e.g. `cancelBolus`) stays safe.
     private func createBolusReporter() {
-        bolusReporter = pumpManager?.createBolusProgressReporter(reportingOn: processQueue)
-        bolusReporter?.addObserver(self)
+        processQueue.async {
+            self.bolusReporter = self.pumpManager?.createBolusProgressReporter(reportingOn: self.processQueue)
+            self.bolusReporter?.addObserver(self)
+        }
     }
 
     private func clearBolusReporter() {
-        bolusReporter?.removeObserver(self)
-        bolusReporter = nil
-        processQueue.asyncAfter(deadline: .now() + 0.5) {
+        processQueue.async {
+            self.bolusReporter?.removeObserver(self)
+            self.bolusReporter = nil
             self.bolusProgress.send(nil)
         }
     }
@@ -1302,7 +1437,8 @@ extension BaseAPSManager: PumpManagerStatusObserver {
     func pumpManager(_: PumpManager, didUpdate status: PumpManagerStatus, oldStatus _: PumpManagerStatus) {
         let percent = Int((status.pumpBatteryChargeRemaining ?? 1) * 100)
 
-        privateContext.perform {
+        privateContext.perform { [weak self] in
+            guard let self else { return }
             /// only update the last item with the current battery infos instead of saving a new one each time
             let fetchRequest: NSFetchRequest<OpenAPS_Battery> = OpenAPS_Battery.fetchRequest()
             fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]

+ 98 - 1
Trio/Sources/APS/CGM/AppGroupSource.swift

@@ -30,6 +30,9 @@ struct AppGroupSource: GlucoseSource {
     let from: String
     var cgmType: CGMType
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<LoopKit.DeviceLifecycleProgress?, Never>(nil)
+
     func fetch(_ heartbeat: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
         guard let suiteName = Bundle.main.appGroupSuiteName,
               let sharedDefaults = UserDefaults(suiteName: suiteName)
@@ -52,7 +55,21 @@ struct AppGroupSource: GlucoseSource {
         HeartBeatManager.shared.checkCGMBluetoothTransmitter(sharedUserDefaults: sharedDefaults, heartbeat: heartbeat)
         debug(.deviceManager, "APPGROUP : START FETCH LAST BG ")
         let decoded = try? JSONSerialization.jsonObject(with: sharedData, options: [])
-        guard let sgvs = decoded as? [AnyObject] else {
+
+        // Two shapes accepted:
+        //   Legacy (xDrip4iOS today): top-level array of reading dicts.
+        //   Rich (xDrip4iOS extended for CGM lifecycle):
+        //   top-level dict carrying readings under
+        //   `recentReadings` plus sibling keys for CGM status, sensor
+        //   lifecycle, and transmitter info — see `applyRichState`.
+        let sgvs: [AnyObject]
+        if let dict = decoded as? [String: Any] {
+            applyRichState(dict)
+            sgvs = (dict["recentReadings"] as? [AnyObject]) ?? []
+        } else if let arr = decoded as? [AnyObject] {
+            applyRichState(nil)
+            sgvs = arr
+        } else {
             return []
         }
 
@@ -101,6 +118,81 @@ struct AppGroupSource: GlucoseSource {
         return results
     }
 
+    /// Reads the rich top-level dict from xDrip4iOS (when present) and
+    /// pushes status + lifecycle into the publishers HomeStateModel
+    /// subscribes to. Defensive on every key — xdrip ships partial dicts
+    /// during warmup / failure / between sensors.
+    private func applyRichState(_ payload: [String: Any]?) {
+        guard let payload else {
+            cgmDisplayState.value = nil
+            cgmProgressHighlight.value = nil
+            return
+        }
+
+        let cgm = payload["cgm"] as? [String: Any]
+        cgmDisplayState.value = parseStatus(cgm?["status"] as? [String: Any])
+        cgmProgressHighlight.value = parseSensorLifecycle(cgm?["sensor"] as? [String: Any])
+    }
+
+    private func parseStatus(_ status: [String: Any]?) -> CgmDisplayState? {
+        guard let status,
+              let message = status["localizedMessage"] as? String,
+              !message.isEmpty
+        else { return nil }
+        return CgmDisplayState(
+            localizedMessage: message,
+            imageName: (status["imageName"] as? String) ?? "",
+            status: cgmDisplayStatus(forCode: status["displayState"] as? String ?? status["code"] as? String)
+        )
+    }
+
+    /// xDrip4iOS sends a free-form code string (e.g. "normal", "warning",
+    /// "critical", "warmup", "calibration_needed", "sensor_failed"). We
+    /// fold anything unfamiliar into `.warning` so unknown future codes
+    /// surface visibly instead of going silent.
+    private func cgmDisplayStatus(forCode code: String?) -> CgmDisplayStatus {
+        switch code?.lowercased() {
+        case nil,
+             "normal",
+             "ok": return .normal
+        case "critical",
+             "expired",
+             "sensor_failed",
+             "session_failed",
+             "stopped": return .critical
+        default: return .warning
+        }
+    }
+
+    private func parseSensorLifecycle(_ sensor: [String: Any]?) -> DeviceLifecycleProgress? {
+        guard let sensor else { return nil }
+        let percent = (sensor["percentComplete"] as? NSNumber)?.doubleValue
+        guard let percent else { return nil }
+        let progressState = lifecycleProgressState(
+            for: sensor["progressState"] as? String,
+            isInWarmup: sensor["isInWarmup"] as? Bool ?? false,
+            isExpired: sensor["isExpired"] as? Bool ?? false
+        )
+        return AppGroupLifecycleProgress(
+            percentComplete: max(0, min(1, percent)),
+            progressState: progressState
+        )
+    }
+
+    private func lifecycleProgressState(
+        for code: String?,
+        isInWarmup: Bool,
+        isExpired: Bool
+    ) -> DeviceLifecycleProgressState {
+        if isExpired { return .critical }
+        if isInWarmup { return .normalCGM }
+        switch code?.lowercased() {
+        case "critical": return .critical
+        case "warning": return .warning
+        default: return .normalCGM
+        }
+    }
+
     private func parseDate(_ timestamp: String) -> Date? {
         // timestamp looks like "/Date(1462404576000)/"
         guard let re = try? NSRegularExpression(pattern: "\\((.*)\\)"),
@@ -119,6 +211,11 @@ struct AppGroupSource: GlucoseSource {
     }
 }
 
+private struct AppGroupLifecycleProgress: DeviceLifecycleProgress {
+    let percentComplete: Double
+    let progressState: DeviceLifecycleProgressState
+}
+
 public extension Bundle {
     var appGroupSuiteName: String? {
         object(forInfoDictionaryKey: "AppGroupID") as? String

+ 228 - 0
Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift

@@ -13,7 +13,9 @@
 ///  - OscillatingGenerator: BloodGlucoseGenerator - Generates sinusoidal glucose values around a center point
 
 import Combine
+import CoreData
 import Foundation
+import LoopKit
 import LoopKitUI
 
 // MARK: - Glucose simulator
@@ -25,6 +27,9 @@ final class GlucoseSimulatorSource: GlucoseSource {
     var cgmManager: CGMManagerUI?
     var glucoseManager: FetchGlucoseManager?
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
     private enum Config {
         /// Minimum time period between data publications (in seconds)
         static let workInterval: TimeInterval = 300
@@ -49,6 +54,57 @@ final class GlucoseSimulatorSource: GlucoseSource {
             }
             lastFetchDate = lastDate
         }
+        publishSimulatedState()
+    }
+
+    /// Republishes synthetic lifecycle/highlight from `simulatedScenario`.
+    func publishSimulatedState() {
+        cgmProgressHighlight.value = cgmLifecycleProgress
+        if let highlight = cgmStatusHighlight {
+            cgmDisplayState.value = CgmDisplayState(
+                localizedMessage: highlight.localizedMessage,
+                imageName: highlight.imageName,
+                status: CgmDisplayStatus.from(highlight.state)
+            )
+        } else {
+            cgmDisplayState.value = nil
+        }
+    }
+
+    /// Picker entry point — change scenario + propagate immediately. When
+    /// flipping to a state where a real sensor wouldn't be delivering fresh
+    /// readings, drop any GlucoseStored rows inside the home view's 12 min
+    /// freshness window so the bobble switches to its compact stale view
+    /// right away instead of waiting for organic aging.
+    func applySimulatedScenario(_ scenario: SimulatedSensorScenario) {
+        simulatedScenario = scenario
+        if !scenario.deliversFreshGlucose {
+            clearRecentSimulatorReadings()
+        }
+        publishSimulatedState()
+    }
+
+    /// Deletes GlucoseStored rows newer than the home view's 12 min
+    /// freshness window. Dev-only — only invoked from the simulator's
+    /// scenario picker, which is itself gated to simulator mode.
+    private func clearRecentSimulatorReadings() {
+        let context = CoreDataStack.shared.newTaskContext()
+        let cutoff = Date().addingTimeInterval(-12 * 60)
+        context.perform {
+            let request = GlucoseStored.fetchRequest()
+            request.predicate = NSPredicate(format: "date > %@", cutoff as NSDate)
+            do {
+                let recent = try context.fetch(request)
+                for row in recent {
+                    context.delete(row)
+                }
+                if context.hasChanges {
+                    try context.save()
+                }
+            } catch {
+                print("GlucoseSimulatorSource: clearRecentSimulatorReadings failed: \(error)")
+            }
+        }
     }
 
     /// The glucose generator used to create simulated values
@@ -74,6 +130,14 @@ final class GlucoseSimulatorSource: GlucoseSource {
         guard canGenerateNewValues else {
             return Just([]).eraseToAnyPublisher()
         }
+        // Match real CGM behavior: scenarios where a physical sensor wouldn't
+        // be delivering readings (warmup, calibration, expired, failed) also
+        // stop the simulator from emitting fresh values. Existing readings
+        // then age out of the 12 min freshness window, the bobble flips to
+        // its compact symbol view, and the highlight's imageName surfaces.
+        guard simulatedScenario.deliversFreshGlucose else {
+            return Just([]).eraseToAnyPublisher()
+        }
 
         let glucoses = generator.getBloodGlucoses(
             startDate: lastFetchDate,
@@ -94,6 +158,170 @@ final class GlucoseSimulatorSource: GlucoseSource {
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
     }
+
+    // MARK: - Simulated sensor lifecycle / status (dev-only)
+
+    //
+    // The simulator doesn't own a real `CGMManagerUI`, so `cgmStatusHighlight`
+    // and `cgmLifecycleProgress` would be nil on the home screen. To make the
+    // outer arc + tag indicator actually exercisable without Libre/Dexcom
+    // hardware, the simulator exposes synthetic values driven by
+    // `simulatedScenario`. Flip the persisted enum at runtime (debug menu or
+    // by editing the default below) and the home view picks up the change on
+    // its next 5-second refresh tick.
+
+    /// Which pre-canned sensor state the simulator should advertise. Plain
+    /// `UserDefaults` string access (not `@Persisted`) so the CGM-settings
+    /// picker — which writes via `UserDefaults.standard.set(_:forKey:)` — and
+    /// the read here use the same encoding. `@Persisted` stores values as
+    /// JSON-wrapped `Data`, which would silently break the picker round-trip.
+    static let simulatedScenarioKey = "GlucoseSimulator.simulatedScenario"
+
+    var simulatedScenario: SimulatedSensorScenario {
+        get {
+            let raw = UserDefaults.standard.string(forKey: Self.simulatedScenarioKey)
+            return raw.flatMap(SimulatedSensorScenario.init(rawValue:)) ?? .runningNormally
+        }
+        set {
+            UserDefaults.standard.set(newValue.rawValue, forKey: Self.simulatedScenarioKey)
+        }
+    }
+
+    /// Synthetic expiration date that mirrors what a real CGM would expose.
+    /// Picked so the remaining-time label matches the scenario's
+    /// `percentComplete` against a 10-day total (Dexcom-like).
+    var simulatedSensorExpiresAt: Date? {
+        guard let progress = cgmLifecycleProgress else { return nil }
+        let totalLifetime: TimeInterval = 10 * 24 * 60 * 60
+        let remaining = (1.0 - progress.percentComplete) * totalLifetime
+        return Date().addingTimeInterval(remaining)
+    }
+
+    /// Synthetic outer-arc data — nil for scenarios where lifetime is moot
+    /// (warmup, hardware fault). Matches the production `DeviceLifecycleProgress`
+    /// shape so the home state model can treat both sources identically.
+    var cgmLifecycleProgress: DeviceLifecycleProgress? {
+        switch simulatedScenario {
+        case .runningNormally:
+            return SimulatedLifecycleProgress(percentComplete: 0.45, progressState: .normalCGM)
+        case .expiringSoon:
+            return SimulatedLifecycleProgress(percentComplete: 0.94, progressState: .warning)
+        case .warmup:
+            return nil
+        case .calibrationRequired:
+            return SimulatedLifecycleProgress(percentComplete: 0.30, progressState: .normalCGM)
+        case .expired:
+            return SimulatedLifecycleProgress(percentComplete: 1.0, progressState: .critical)
+        case .sensorFailed:
+            return nil
+        }
+    }
+
+    /// Synthetic status highlight. nil for `.runningNormally` and
+    /// `.expiringSoon` (those run off lifecycle only).
+    var cgmStatusHighlight: DeviceStatusHighlight? {
+        switch simulatedScenario {
+        case .expiringSoon,
+             .runningNormally:
+            return nil
+        case .warmup:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Sensor warming up",
+                imageName: "hourglass",
+                state: .warning
+            )
+        case .calibrationRequired:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Calibrate",
+                imageName: "drop.fill",
+                state: .warning
+            )
+        case .expired:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Sensor expired",
+                imageName: "exclamationmark.circle.fill",
+                state: .critical
+            )
+        case .sensorFailed:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Replace Sensor",
+                imageName: "exclamationmark.triangle.fill",
+                state: .critical
+            )
+        }
+    }
+}
+
+/// Pre-canned sensor scenarios surfaced by `GlucoseSimulatorSource`. One per
+/// home-screen state so flipping this drives the indicator
+/// through every visual state.
+enum SimulatedSensorScenario: String, CaseIterable, Identifiable {
+    case runningNormally
+    case expiringSoon
+    case warmup
+    case calibrationRequired
+    case expired
+    case sensorFailed
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .runningNormally: return "Running normally"
+        case .expiringSoon: return "Expiring soon"
+        case .warmup: return "Warmup"
+        case .calibrationRequired: return "Calibration required"
+        case .expired: return "Expired"
+        case .sensorFailed: return "Sensor failed"
+        }
+    }
+
+    /// Whether a real CGM would still be delivering fresh glucose readings
+    /// while in this state. Drives the simulator's `fetch()` gate so non-
+    /// active scenarios stop emitting and the home view sees stale data
+    /// the same way it would from a real sensor.
+    var deliversFreshGlucose: Bool {
+        switch self {
+        case .expiringSoon,
+             .runningNormally:
+            return true
+        case .calibrationRequired,
+             .expired,
+             .sensorFailed,
+             .warmup:
+            return false
+        }
+    }
+
+    /// Short blurb shown under the picker so dev users know what each
+    /// scenario renders on the home screen.
+    var devNotes: String {
+        switch self {
+        case .runningNormally:
+            return "Teal outer ring at ~45%, tag shows time remaining."
+        case .expiringSoon:
+            return "Amber outer ring at ~94%, tag shows time remaining with \"left\" suffix."
+        case .warmup:
+            return "Arc hidden, pulsing amber tag, glucose shown as \"– –\"."
+        case .calibrationRequired:
+            return "Arc visible, amber tag with \"Calibrate\" message, glucose still shown."
+        case .expired:
+            return "Red full ring, red tag \"sensor expired\", glucose shown as \"– –\"."
+        case .sensorFailed:
+            return "Arc hidden, pulsing red tag, glucose shown as \"– –\"."
+        }
+    }
+}
+
+private struct SimulatedLifecycleProgress: DeviceLifecycleProgress {
+    let percentComplete: Double
+    let progressState: DeviceLifecycleProgressState
+}
+
+private struct SimulatedStatusHighlight: DeviceStatusHighlight {
+    let localizedMessage: String
+    let imageName: String
+    let state: DeviceStatusHighlightState
 }
 
 // MARK: - Glucose generator

+ 4 - 0
Trio/Sources/APS/CGM/GlucoseSource.swift

@@ -11,6 +11,10 @@ protocol GlucoseSource: SourceInfoProvider {
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never>
     var glucoseManager: FetchGlucoseManager? { get set }
     var cgmManager: CGMManagerUI? { get set }
+    /// Mirrors `CGMManagerUI.cgmStatusHighlight`; republished on manager change.
+    var cgmDisplayState: CurrentValueSubject<CgmDisplayState?, Never> { get }
+    /// Mirrors `CGMManagerUI.cgmLifecycleProgress`.
+    var cgmProgressHighlight: CurrentValueSubject<LoopKit.DeviceLifecycleProgress?, Never> { get }
 }
 
 extension GlucoseSource {

+ 59 - 10
Trio/Sources/APS/CGM/PluginSource.swift

@@ -11,7 +11,12 @@ final class PluginSource: GlucoseSource {
     private let glucoseStorage: GlucoseStorage!
     var glucoseManager: FetchGlucoseManager?
 
-    var cgmManager: CGMManagerUI?
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
+    var cgmManager: CGMManagerUI? {
+        didSet { publishCGMStatus() }
+    }
 
     var cgmHasValidSensorSession: Bool = false
 
@@ -22,6 +27,23 @@ final class PluginSource: GlucoseSource {
         cgmManager = glucoseManager.cgmManager
         cgmManager?.delegateQueue = processQueue
         cgmManager?.cgmManagerDelegate = self
+        // didSet doesn't fire from the defining class's own init.
+        publishCGMStatus()
+    }
+
+    /// Republishes the manager's lifecycle/highlight to the subjects.
+    /// Called from `cgmManager.didSet` and after delegate state updates.
+    func publishCGMStatus() {
+        if let highlight = cgmManager?.cgmStatusHighlight {
+            cgmDisplayState.value = CgmDisplayState(
+                localizedMessage: highlight.localizedMessage,
+                imageName: highlight.imageName,
+                status: CgmDisplayStatus.from(highlight.state)
+            )
+        } else {
+            cgmDisplayState.value = nil
+        }
+        cgmProgressHighlight.value = cgmManager?.cgmLifecycleProgress
     }
 
     /// Function that fetches blood glucose data
@@ -77,23 +99,47 @@ extension PluginSource: CGMManagerDelegate {
         debug(.deviceManager, "device Manager for \(String(describing: deviceIdentifier)) : \(message)")
     }
 
-    func issueAlert(_: LoopKit.Alert) {}
+    /// Forwards CGMManager-issued alerts into the unified `TrioAlertManager`
+    /// pipeline so they get the same in-app banner + UN scheduling + history
+    /// logging treatment as everything else. Used to be a no-op; on
+    /// dev-libre3 / LibreLoop builds that meant CGM-issued alerts (sensor
+    /// failure, signal loss, expiry, etc.) silently dropped.
+    func issueAlert(_ alert: LoopKit.Alert) {
+        glucoseManager?.trioAlertManager?.issueAlert(alert)
+    }
 
-    func retractAlert(identifier _: LoopKit.Alert.Identifier) {}
+    func retractAlert(identifier: LoopKit.Alert.Identifier) {
+        glucoseManager?.trioAlertManager?.retractAlert(identifier: identifier)
+    }
 
-    func doesIssuedAlertExist(identifier _: LoopKit.Alert.Identifier, completion _: @escaping (Result<Bool, Error>) -> Void) {}
+    /// LoopKit asks this on reconnect to avoid re-issuing an alert that's
+    /// still live. `TrioAlertManager` deduplicates downstream via its own
+    /// throttler and live-alert table, so answering "no" here is safe — at
+    /// worst we get one duplicate banner, which the throttler suppresses.
+    func doesIssuedAlertExist(identifier _: LoopKit.Alert.Identifier, completion: @escaping (Result<Bool, Error>) -> Void) {
+        completion(.success(false))
+    }
 
     func lookupAllUnretracted(
         managerIdentifier _: String,
-        completion _: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
-    ) {}
+        completion: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
+    ) {
+        completion(.success([]))
+    }
 
     func lookupAllUnacknowledgedUnretracted(
         managerIdentifier _: String,
-        completion _: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
-    ) {}
+        completion: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
+    ) {
+        completion(.success([]))
+    }
 
-    func recordRetractedAlert(_: LoopKit.Alert, at _: Date) {}
+    /// LoopKit calls this when a manager itself decides an alert is no
+    /// longer relevant. Mirror the action on our pipeline so the in-app
+    /// banner, scheduled UN, and history all clear.
+    func recordRetractedAlert(_ alert: LoopKit.Alert, at _: Date) {
+        glucoseManager?.trioAlertManager?.retractAlert(identifier: alert.identifier)
+    }
 
     func cgmManagerWantsDeletion(_ manager: CGMManager) {
         processQueue.async { [weak self] in
@@ -121,6 +167,8 @@ extension PluginSource: CGMManagerDelegate {
                 debug(.deviceManager, "CGM PLUGIN - unable to read CGM result")
             }
 
+            self.publishCGMStatus()
+
             debug(.deviceManager, "CGM PLUGIN - Direct return done")
         }
     }
@@ -170,6 +218,8 @@ extension PluginSource: CGMManagerDelegate {
                 cgmGlucosePluginId: fetchGlucoseManager.settingsManager.settings.cgmPluginIdentifier,
                 newManager: cgmManager as? CGMManagerUI
             )
+
+            self.publishCGMStatus()
         }
     }
 
@@ -180,7 +230,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 }
 

+ 37 - 142
Trio/Sources/APS/DeviceDataManager.swift

@@ -3,13 +3,12 @@ import Combine
 import CoreData
 import DanaKit
 import Foundation
+import HealthKit
 import LoopKit
 import LoopKitUI
 import MedtrumKit
 import MinimedKit
 import MockKit
-import OmniBLE
-import OmniKit
 import OmnipodKit
 import ShareClient
 import SwiftDate
@@ -38,8 +37,6 @@ protocol DeviceDataManager: GlucoseSource {
 
 private let staticPumpManagers: [PumpManagerUI.Type] = [
     MinimedPumpManager.self,
-    OmnipodPumpManager.self,
-    OmniBLEPumpManager.self,
     OmniPumpManager.self,
     DanaKitPumpManager.self,
     MedtrumPumpManager.self,
@@ -48,8 +45,6 @@ private let staticPumpManagers: [PumpManagerUI.Type] = [
 
 private let staticPumpManagersByIdentifier: [String: PumpManagerUI.Type] = [
     MinimedPumpManager.pluginIdentifier: MinimedPumpManager.self,
-    OmnipodPumpManager.pluginIdentifier: OmnipodPumpManager.self,
-    OmniBLEPumpManager.pluginIdentifier: OmniBLEPumpManager.self,
     OmniPumpManager.pluginIdentifier: OmniPumpManager.self,
     DanaKitPumpManager.pluginIdentifier: DanaKitPumpManager.self,
     MedtrumPumpManager.pluginIdentifier: MedtrumPumpManager.self,
@@ -67,6 +62,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var bluetoothProvider: BluetoothStateManager!
+    @Injected() private var trioAlertManager: TrioAlertManager!
 
     @Persisted(key: "BaseDeviceDataManager.lastEventDate") var lastEventDate: Date? = nil
     @SyncAccess(lock: accessLock) @Persisted(key: "BaseDeviceDataManager.lastHeartBeatTime") var lastHeartBeatTime: Date =
@@ -88,10 +84,16 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
 
     var pumpManager: PumpManagerUI? {
         didSet {
+            if let oldValue = oldValue {
+                trioAlertManager?.unregister(managerIdentifier: oldValue.pluginIdentifier)
+            }
             if let pumpManager = pumpManager {
                 pumpManager.pumpManagerDelegate = self
                 pumpManager.delegateQueue = processQueue
 
+                trioAlertManager?.register(responder: pumpManager, for: pumpManager.pluginIdentifier)
+                trioAlertManager?.register(soundVendor: pumpManager, for: pumpManager.pluginIdentifier)
+
                 /// Since the pump manager has been successfully instantiated from its saved state,
                 /// copy its rawValue to rawPumpManager which will be saved to persistant storage.
                 rawPumpManager = pumpManager.rawValue
@@ -112,22 +114,25 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
                     .bolusIncrement = bolusIncrement > 0 ? bolusIncrement : 0.1
                 storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences)
 
-                if let omnipod = pumpManager as? OmnipodPumpManager {
-                    pumpActivatedAtDate.send(nil)
-                    guard let endTime = omnipod.state.podState?.expiresAt else {
-                        pumpExpiresAtDate.send(nil)
-                        return
-                    }
-                    pumpExpiresAtDate.send(endTime)
-                }
-                if let omnipodBLE = pumpManager as? OmniBLEPumpManager {
-                    pumpActivatedAtDate.send(nil)
-                    guard let endTime = omnipodBLE.state.podState?.expiresAt else {
-                        pumpExpiresAtDate.send(nil)
-                        return
+                // Ensure the pump manager's delivery limits always reflect the user's
+                // current settings. Without this, the active pump manager instance
+                // may use a stale or default value from deserialized state
+                // — silently rejecting temp basals that oref correctly
+                // determines are within the user's configured maxBasal.
+                let pumpSettings = settingsManager.pumpSettings
+                let deliveryLimits = DeliveryLimits(
+                    maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(pumpSettings.maxBasal)),
+                    maximumBolus: HKQuantity(unit: .internationalUnit(), doubleValue: Double(pumpSettings.maxBolus))
+                )
+
+                processQueue.async {
+                    pumpManager.syncDeliveryLimits(limits: deliveryLimits) { result in
+                        if case let .failure(error) = result {
+                            debug(.deviceManager, "syncDeliveryLimits on pump manager init failed: \(error)")
+                        }
                     }
-                    pumpExpiresAtDate.send(endTime)
                 }
+
                 if let medtrumPump = pumpManager as? MedtrumPumpManager {
                     // Medtrum's state.patchExpiresAt is actually lifespan + grace
                     // keeping this in line with omnipod, we will use just the lifetime
@@ -243,7 +248,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
         injectServices(resolver)
         setupPumpManager()
         UIDevice.current.isBatteryMonitoringEnabled = true
-        broadcaster.register(AlertObserver.self, observer: self)
     }
 
     func setupPumpManager() {
@@ -343,6 +347,9 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     var cgmManager: CGMManagerUI?
     var cgmType: CGMType = .enlite
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
     }
@@ -489,72 +496,12 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
             settingsManager.updateInsulinCurve(status.insulinType)
         }
 
-        if let omnipod = pumpManager as? OmnipodPumpManager {
-            let reservoirVal = omnipod.state.podState?.lastInsulinMeasurements?.reservoirLevel ?? 0xDEAD_BEEF
-            // TODO: find the value Pod.maximumReservoirReading
-            let reservoir = Decimal(reservoirVal) > 50.0 ? 0xDEAD_BEEF : reservoirVal
-
-            storage.save(Decimal(reservoir), as: OpenAPS.Monitor.reservoir)
-            broadcaster.notify(PumpReservoirObserver.self, on: processQueue) {
-                $0.pumpReservoirDidChange(Decimal(reservoir))
-            }
-
-            if let tempBasal = omnipod.state.podState?.unfinalizedTempBasal, !tempBasal.isFinished(),
-               !tempBasal.automatic
-            {
-                // the manual basal temp is launch - block every thing
-                debug(.deviceManager, "manual temp basal")
-                manualTempBasal.send(true)
-            } else {
-                // no more manual Temp Basal !
-                manualTempBasal.send(false)
-            }
-
-            pumpActivatedAtDate.send(nil)
-            guard let endTime = omnipod.state.podState?.expiresAt else {
-                pumpExpiresAtDate.send(nil)
-                return
-            }
-            pumpExpiresAtDate.send(endTime)
-
-            if let startTime = omnipod.state.podState?.activatedAt {
-                storage.save(startTime, as: OpenAPS.Monitor.podAge)
-            }
-        }
-
-        if let omnipodBLE = pumpManager as? OmniBLEPumpManager {
-            let reservoirVal = omnipodBLE.state.podState?.lastInsulinMeasurements?.reservoirLevel ?? 0xDEAD_BEEF
-            // TODO: find the value Pod.maximumReservoirReading
-            let reservoir = Decimal(reservoirVal) > 50.0 ? 0xDEAD_BEEF : reservoirVal
-
-            storage.save(Decimal(reservoir), as: OpenAPS.Monitor.reservoir)
-            broadcaster.notify(PumpReservoirObserver.self, on: processQueue) {
-                $0.pumpReservoirDidChange(Decimal(reservoir))
-            }
-
-            // manual temp basal on
-            if let tempBasal = omnipodBLE.state.podState?.unfinalizedTempBasal, !tempBasal.isFinished(),
-               !tempBasal.automatic
-            {
-                // the manual basal temp is launch - block every thing
-                debug(.deviceManager, "manual temp basal")
-                manualTempBasal.send(true)
-            } else {
-                // no more manual Temp Basal !
-                manualTempBasal.send(false)
-            }
-
-            pumpActivatedAtDate.send(nil)
-            guard let endTime = omnipodBLE.state.podState?.expiresAt else {
-                pumpExpiresAtDate.send(nil)
-                return
-            }
-            pumpExpiresAtDate.send(endTime)
-
-            if let startTime = omnipodBLE.state.podState?.activatedAt {
-                storage.save(startTime, as: OpenAPS.Monitor.podAge)
-            }
+        // Check if manual temp basal is active
+        let manualTempBasalActive = status.basalDeliveryState?.isManualTempBasal ?? false
+        if manualTempBasalActive {
+            debug(.deviceManager, "manual temp basal")
         }
+        manualTempBasal.send(manualTempBasalActive)
 
         if let medtrumPump = pumpManager as? MedtrumPumpManager {
             storage.save(Decimal(medtrumPump.state.reservoir), as: OpenAPS.Monitor.reservoir)
@@ -589,18 +536,6 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
                 $0.pumpReservoirDidChange(Decimal(reservoir))
             }
 
-            // manual temp basal on
-            if let tempBasal = omni.state.podState?.unfinalizedTempBasal, !tempBasal.isFinished(),
-               !tempBasal.automatic
-            {
-                // the manual basal temp is launch - block every thing
-                debug(.deviceManager, "manual temp basal")
-                manualTempBasal.send(true)
-            } else {
-                // no more manual Temp Basal !
-                manualTempBasal.send(false)
-            }
-
             guard let endTime = omni.state.podState?.expiresAt else {
                 pumpExpiresAtDate.send(nil)
                 return
@@ -704,22 +639,13 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
 
 extension BaseDeviceDataManager: DeviceManagerDelegate {
     func issueAlert(_ alert: Alert) {
-        alertHistoryStorage.addAlert(
-            AlertEntry(
-                alertIdentifier: alert.identifier.alertIdentifier,
-                primitiveInterruptionLevel: alert.interruptionLevel.storedValue as? Decimal,
-                issuedDate: Date(),
-                managerIdentifier: alert.identifier.managerIdentifier,
-                triggerType: alert.trigger.storedType,
-                triggerInterval: alert.trigger.storedInterval as? Decimal,
-                contentTitle: alert.foregroundContent?.title,
-                contentBody: alert.foregroundContent?.body
-            )
-        )
+        debug(.deviceManager, "issueAlert \(alert.identifier.value)")
+        trioAlertManager.issueAlert(alert)
     }
 
     func retractAlert(identifier: Alert.Identifier) {
-        alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
+        debug(.deviceManager, "retractAlert \(identifier.value)")
+        trioAlertManager.retractAlert(identifier: identifier)
     }
 
     func doesIssuedAlertExist(identifier _: Alert.Identifier, completion _: @escaping (Result<Bool, Error>) -> Void) {
@@ -774,37 +700,6 @@ extension BaseDeviceDataManager: CGMManagerDelegate {
     func cgmManager(_: CGMManager, didUpdate _: CGMManagerStatus) {}
 }
 
-// MARK: - AlertPresenter
-
-extension BaseDeviceDataManager: AlertObserver {
-    func AlertDidUpdate(_ alerts: [AlertEntry]) {
-        alerts.forEach { alert in
-            if alert.acknowledgedDate == nil {
-                ackAlert(alert: alert)
-            }
-        }
-    }
-
-    private func ackAlert(alert: AlertEntry) {
-        let alertIssueDate = alert.issuedDate
-
-        processQueue.async {
-            self.pumpManager?.acknowledgeAlert(alertIdentifier: alert.alertIdentifier) { error in
-                if let error = error {
-                    self.alertHistoryStorage.acknowledgeAlert(alertIssueDate, error.localizedDescription)
-                    debug(.deviceManager, "acknowledge not succeeded with error \(error)")
-                } else {
-                    self.alertHistoryStorage.acknowledgeAlert(alertIssueDate, nil)
-                }
-            }
-
-            self.broadcaster.notify(pumpNotificationObserver.self, on: self.processQueue) {
-                $0.pumpNotification(alert: alert)
-            }
-        }
-    }
-}
-
 // extension BaseDeviceDataManager: AlertPresenter {
 //    func issueAlert(_: Alert) {}
 //    func retractAlert(identifier _: Alert.Identifier) {}

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

+ 28 - 1
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -19,6 +19,12 @@ protocol FetchGlucoseManager: SourceInfoProvider {
     var cgmGlucosePluginId: String { get }
     var settingsManager: SettingsManager! { get }
     var shouldSyncToRemoteService: Bool { get }
+    var cgmDisplayState: CurrentValueSubject<CgmDisplayState?, Never> { get }
+    var cgmProgressHighlight: CurrentValueSubject<DeviceLifecycleProgress?, Never> { get }
+    /// Routes CGMManager-issued alerts (sensor failure, signal loss, expiry,
+    /// etc.) into the unified `TrioAlertManager` pipeline. Read by
+    /// `PluginSource.issueAlert` / `retractAlert`.
+    var trioAlertManager: TrioAlertManager! { get }
 }
 
 extension FetchGlucoseManager {
@@ -40,6 +46,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     @Injected() var deviceDataManager: DeviceDataManager!
     @Injected() var pluginCGMManager: PluginManager!
     @Injected() var calibrationService: CalibrationService!
+    @Injected() var trioAlertManager: TrioAlertManager!
 
     private var lifetime = Lifetime()
     private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval)
@@ -148,7 +155,27 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         }
     }
 
-    var glucoseSource: GlucoseSource?
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+    private var cgmStatusSubscriptions = Set<AnyCancellable>()
+
+    var glucoseSource: GlucoseSource? {
+        didSet {
+            // Drop prior subscriptions so source swaps don't dupe emissions.
+            cgmStatusSubscriptions.removeAll()
+            cgmDisplayState.value = glucoseSource?.cgmDisplayState.value
+            cgmProgressHighlight.value = glucoseSource?.cgmProgressHighlight.value
+            guard let glucoseSource else { return }
+            glucoseSource.cgmDisplayState
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] state in self?.cgmDisplayState.value = state }
+                .store(in: &cgmStatusSubscriptions)
+            glucoseSource.cgmProgressHighlight
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] progress in self?.cgmProgressHighlight.value = progress }
+                .store(in: &cgmStatusSubscriptions)
+        }
+    }
 
     func removeCalibrations() {
         calibrationService.removeAllCalibrations()

+ 352 - 136
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
@@ -101,13 +99,20 @@ final class OpenAPS {
     func fetchAndProcessGlucose(
         context: NSManagedObjectContext,
         shouldSmoothGlucose: Bool,
-        fetchLimit: Int?
+        fetchLimit: Int?,
+        fetchHours: Decimal = 24
     ) async throws -> String {
-        // make it async and await it
+        // Time window from `fetchHours` hours ago up to now. determineBasal feeds
+        // `maxMealAbsorptionTime + 0.5h` (just enough glucose to cover the longest
+        // tracked meal absorption plus a small lead-in); Autosens uses the default
+        // 24h because its sensitivity algorithm needs that full window.
+        let cutoff = Date().addingTimeInterval(-(Double(truncating: fetchHours as NSNumber) * 3600))
+        let timePredicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
+
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
-            predicate: NSPredicate.predicateForOneDayAgoInMinutes,
+            predicate: timePredicate,
             key: "date",
             ascending: false,
             fetchLimit: fetchLimit,
@@ -380,6 +385,7 @@ final class OpenAPS {
     func determineBasal(
         currentTemp: TempBasal,
         shouldSmoothGlucose: Bool,
+        useJavascriptOref: Bool,
         clock: Date = Date(),
         simulatedCarbsAmount: Decimal? = nil,
         simulatedBolusAmount: Decimal? = nil,
@@ -394,13 +400,21 @@ final class OpenAPS {
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let carbs = fetchAndProcessCarbs(additionalCarbs: simulatedCarbsAmount ?? 0, carbsDate: simulatedCarbsDate)
-        async let glucose = fetchAndProcessGlucose(context: context, shouldSmoothGlucose: shouldSmoothGlucose, fetchLimit: 72)
+
+        var preferences = await storage.retrieveAsync(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
+        let glucoseFetchHours = preferences.maxMealAbsorptionTime + 0.5 // MMAT + half hour buffer
+        async let glucose = fetchAndProcessGlucose(
+            context: context,
+            shouldSmoothGlucose: shouldSmoothGlucose,
+            fetchLimit: nil,
+            fetchHours: glucoseFetchHours
+        )
+
         async let prepareTrioCustomOrefVariables = prepareTrioCustomOrefVariables()
         async let profileAsync = loadFileFromStorageAsync(name: Settings.profile)
         async let basalAsync = loadFileFromStorageAsync(name: Settings.basalProfile)
         async let autosenseAsync = loadFileFromStorageAsync(name: Settings.autosense)
         async let reservoirAsync = loadFileFromStorageAsync(name: Monitor.reservoir)
-        async let preferencesAsync = storage.retrieveAsync(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
         async let hasSufficientTddForDynamic = tddStorage.hasSufficientTDD()
 
         // Await the results of asynchronous tasks
@@ -433,7 +447,8 @@ final class OpenAPS {
             basalProfile: basalProfile,
             clock: clock,
             carbs: carbsAsJSON,
-            glucose: glucoseAsJSON
+            glucose: glucoseAsJSON,
+            useJavascriptOref: useJavascriptOref
         )
 
         // IOB calculation
@@ -441,7 +456,8 @@ final class OpenAPS {
             pumphistory: pumpHistoryJSON,
             profile: profile,
             clock: clock,
-            autosens: autosens.isEmpty ? .null : autosens
+            autosens: autosens.isEmpty ? .null : autosens,
+            useJavascriptOref: useJavascriptOref
         )
 
         // TODO: refactor this to core data
@@ -449,8 +465,6 @@ final class OpenAPS {
             storage.save(iob, as: Monitor.iob)
         }
 
-        var preferences = await preferencesAsync
-
         if !hasSufficientTdd, preferences.useNewFormula || (preferences.useNewFormula && preferences.sigmoid) {
             debug(.openAPS, "Insufficient TDD for dynamic formula; disabling for determine basal run.")
             preferences.useNewFormula = false
@@ -470,7 +484,8 @@ final class OpenAPS {
             pumpHistory: pumpHistoryJSON,
             preferences: preferences,
             basalProfile: basalProfile,
-            trioCustomOrefVariables: trioCustomOrefVariables
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            useJavascriptOref: useJavascriptOref
         )
 
         debug(.openAPS, "\(simulation ? "[SIMULATION]" : "") OREF DETERMINATION: \(orefDetermination)")
@@ -563,7 +578,7 @@ final class OpenAPS {
         }
     }
 
-    func autosense(shouldSmoothGlucose: Bool) async throws -> Autosens? {
+    func autosense(shouldSmoothGlucose: Bool, useJavascriptOref: Bool) async throws -> Autosens? {
         debug(.openAPS, "Start autosens")
 
         // Perform asynchronous calls in parallel
@@ -591,7 +606,8 @@ final class OpenAPS {
             basalprofile: basalProfile,
             profile: profile,
             carbs: carbsAsJSON,
-            temptargets: tempTargets
+            temptargets: tempTargets,
+            useJavascriptOref: useJavascriptOref
         )
 
         debug(.openAPS, "AUTOSENS: \(autosenseResult)")
@@ -605,7 +621,7 @@ final class OpenAPS {
         }
     }
 
-    func createProfiles() async throws {
+    func createProfiles(useJavascriptOref: Bool) async throws {
         debug(.openAPS, "Start creating pump profile and user profile")
 
         // Load required settings and profiles asynchronously
@@ -669,6 +685,7 @@ final class OpenAPS {
             }
         }
 
+        let clock = Date()
         do {
             let pumpProfile = try await makeProfile(
                 preferences: adjustedPreferences,
@@ -680,7 +697,9 @@ final class OpenAPS {
                 tempTargets: tempTargets,
                 model: model,
                 autotune: RawJSON.null,
-                trioData: trioSettings
+                trioSettings: trioSettings,
+                useJavascriptOref: useJavascriptOref,
+                clock: clock
             )
 
             let profile = try await makeProfile(
@@ -693,7 +712,9 @@ final class OpenAPS {
                 tempTargets: tempTargets,
                 model: model,
                 autotune: RawJSON.null,
-                trioData: trioSettings
+                trioSettings: trioSettings,
+                useJavascriptOref: useJavascriptOref,
+                clock: clock
             )
 
             // Save the profiles
@@ -708,22 +729,50 @@ 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,
+        useJavascriptOref: 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 useJavascriptOref {
+            let jsResult = await iobJavascript(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
+            return try jsResult.returnOrThrow()
+        } else {
+            let swiftResult = OpenAPSSwift
+                .iob(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
+            return try swiftResult.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 +782,63 @@ final class OpenAPS {
         basalProfile: JSON,
         clock: JSON,
         carbs: JSON,
-        glucose: JSON
+        glucose: JSON,
+        useJavascriptOref: 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 useJavascriptOref {
+            let jsResult = await mealJavascript(
+                pumphistory: pumphistory,
+                profile: profile,
+                basalProfile: basalProfile,
+                clock: clock,
+                carbs: carbs,
+                glucose: glucose
+            )
+            return try jsResult.returnOrThrow()
+        } else {
+            let swiftResult = OpenAPSSwift
+                .meal(
+                    pumphistory: pumphistory,
+                    profile: profile,
+                    basalProfile: basalProfile,
+                    clock: clock,
+                    carbs: carbs,
+                    glucose: glucose
+                )
+            return try swiftResult.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 +848,64 @@ final class OpenAPS {
         basalprofile: JSON,
         profile: JSON,
         carbs: JSON,
-        temptargets: JSON
+        temptargets: JSON,
+        useJavascriptOref: 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 useJavascriptOref {
+            let jsResult = await autosenseJavascript(
+                glucose: glucose,
+                pumpHistory: pumpHistory,
+                basalprofile: basalprofile,
+                profile: profile,
+                carbs: carbs,
+                temptargets: temptargets
+            )
+            return try jsResult.returnOrThrow()
+        } else {
+            let swiftResult = OpenAPSSwift
+                .autosense(
+                    glucose: glucose,
+                    pumpHistory: pumpHistory,
+                    basalProfile: basalprofile,
+                    profile: profile,
+                    carbs: carbs,
+                    tempTargets: temptargets,
+                    clock: Date()
+                )
+            return try swiftResult.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 +921,96 @@ final class OpenAPS {
         pumpHistory: JSON,
         preferences: JSON,
         basalProfile: JSON,
-        trioCustomOrefVariables: JSON
+        trioCustomOrefVariables: JSON,
+        useJavascriptOref: 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 useJavascriptOref {
+            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()
+        } else {
+            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()
+        }
+    }
 
-                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 +1026,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 +1078,38 @@ final class OpenAPS {
         tempTargets: JSON,
         model: JSON,
         autotune: JSON,
-        trioData: JSON
+        trioSettings: JSON,
+        useJavascriptOref: 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 useJavascriptOref {
+            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()
+        } else {
+            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()
         }
     }
 
@@ -897,23 +1130,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: adjustedSensitivity.jsRounded(),
+            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 \(forecastResult.eventualGlucose.jsRounded()) >= \(adjustedGlucoseTargets.maxGlucose.jsRounded()), "
+        }
+
+        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
+            )
+        }
+    }
+}

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

@@ -0,0 +1,917 @@
+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
+    }
+
+    /// 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 \(glucoseStatus.maxDelta.jsRounded()) > \(100 * maxDeltaGlucoseThreshold)% of BG \(currentGlucose.jsRounded()) - 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: minDelta.jsRounded())
+            let expectedDeltaString = String(describing: expectedDelta.jsRounded())
+
+            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: minGuardGlucose.jsRounded())
+            let thresholdString = String(describing: threshold.jsRounded())
+            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 \(eventualGlucose.jsRounded()) < \(minGlucose.jsRounded())"
+
+        // 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 \(glucoseStatus.delta.jsRounded()) > expectedDelta \(expectedDelta.jsRounded())"
+            } else {
+                let minDeltaFormatted = String(format: "%.2f", Double(truncating: minDelta.jsRounded(scale: 2) as NSNumber))
+                newDetermination
+                    .reason +=
+                    ", but Min. Delta \(minDeltaFormatted) > Exp. Delta \(expectedDelta.jsRounded())"
+            }
+
+            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 \(eventualGlucose.jsRounded()) > \(minGlucose.jsRounded()) but Delta \(glucoseStatus.delta.jsRounded()) < Exp. Delta \(expectedDelta.jsRounded())"
+            } else {
+                let minDeltaFormatted = String(format: "%.2f", Double(truncating: minDelta.jsRounded(scale: 2) as NSNumber))
+                newDetermination
+                    .reason +=
+                    "Eventual BG \(eventualGlucose.jsRounded()) > \(minGlucose.jsRounded()) but Min. Delta \(minDeltaFormatted) < Exp. Delta \(expectedDelta.jsRounded())"
+            }
+
+            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 +=
+                "\(eventualGlucose.jsRounded())-\(minForecastGlucose.jsRounded()) 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
+    }
+}

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

@@ -0,0 +1,162 @@
+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
+        }
+
+        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 < 1 || 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 - 0
Trio/Sources/APS/PluginManager.swift


Some files were not shown because too many files changed in this diff