Explorar o código

Merge pull request #585 from nightscout/oref-swift-more-core-dosing-logic

Port more dosing logic to Swift
Sam King hai 8 meses
pai
achega
e5a9d8d8d9

+ 12 - 0
Trio.xcodeproj/project.pbxproj

@@ -225,6 +225,9 @@
 		3B2F77862D7E52ED005ED9FA /* TDD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77852D7E52ED005ED9FA /* TDD.swift */; };
 		3B2F77882D7E5387005ED9FA /* CurrentTDDSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */; };
 		3B31D5742E0E26C00047D32D /* ReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B31D5732E0E26BB0047D32D /* ReplayTests.swift */; };
+		3B3B4F542E661A1200B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3B4F532E6619FF00B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift */; };
+		3B3B4F562E661FD600B668E3 /* DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3B4F552E661FC700B668E3 /* DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift */; };
+		3B3B4F582E662B1E00B668E3 /* DetermineBasalIobGreaterThanMaxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3B4F572E662B1500B668E3 /* DetermineBasalIobGreaterThanMaxTests.swift */; };
 		3B3B57C92DA07B3400849D16 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3B57C82DA07B3400849D16 /* GoogleService-Info.plist */; };
 		3B4196E02D8C4BC00091DFF7 /* HomeStateModel+CGM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4196DF2D8C4BBB0091DFF7 /* HomeStateModel+CGM.swift */; };
 		3B4550532D862C0000551B0D /* PumpHistoryEvent+Duplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4550522D862BF200551B0D /* PumpHistoryEvent+Duplicates.swift */; };
@@ -1175,6 +1178,9 @@
 		3B2F77852D7E52ED005ED9FA /* TDD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDD.swift; sourceTree = "<group>"; };
 		3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentTDDSetup.swift; sourceTree = "<group>"; };
 		3B31D5732E0E26BB0047D32D /* ReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayTests.swift; sourceTree = "<group>"; };
+		3B3B4F532E6619FF00B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalGlucoseFallingFasterThanExpectedTests.swift; sourceTree = "<group>"; };
+		3B3B4F552E661FC700B668E3 /* DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift; sourceTree = "<group>"; };
+		3B3B4F572E662B1500B668E3 /* DetermineBasalIobGreaterThanMaxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalIobGreaterThanMaxTests.swift; sourceTree = "<group>"; };
 		3B3B57C82DA07B3400849D16 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
 		3B4196DF2D8C4BBB0091DFF7 /* HomeStateModel+CGM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeStateModel+CGM.swift"; sourceTree = "<group>"; };
 		3B4550522D862BF200551B0D /* PumpHistoryEvent+Duplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PumpHistoryEvent+Duplicates.swift"; sourceTree = "<group>"; };
@@ -2951,6 +2957,9 @@
 				DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */,
 				3B8221B12E5882D900585156 /* DetermineBasalEarlyExitTests.swift */,
 				3BAC929A2E55FF4F00B853DA /* DetermineBasalEnableSmbTests.swift */,
+				3B3B4F552E661FC700B668E3 /* DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift */,
+				3B3B4F532E6619FF00B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift */,
+				3B3B4F572E662B1500B668E3 /* DetermineBasalIobGreaterThanMaxTests.swift */,
 				3BE9F0BA2E28C993001B14EB /* DetermineBasalJsonTests.swift */,
 				3B1DB90E2E63C14200BD814B /* DetermineBasalLowEventualGlucoseTests.swift */,
 				3B2A3BC22E2B19F700658FB9 /* DynamicISFTests.swift */,
@@ -5222,13 +5231,16 @@
 				3B8B5D3E2DF5240C00365ED3 /* TimeZoneForTests.swift in Sources */,
 				3B1DB90F2E63C14C00BD814B /* DetermineBasalLowEventualGlucoseTests.swift in Sources */,
 				3B5CD2CA2D4AECD500CE213C /* ProfileJavascriptTests.swift in Sources */,
+				3B3B4F542E661A1200B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift in Sources */,
 				3B5CD2CB2D4AECD500CE213C /* ProfileTargetsTests.swift in Sources */,
 				3BAC92CD2E57859D00B853DA /* SetTempBasalTests.swift in Sources */,
 				3B5CD2CD2D4AECD500CE213C /* ProfileIsfTests.swift in Sources */,
 				3B5CD2CE2D4AECD500CE213C /* ProfileBasalTests.swift in Sources */,
+				3B3B4F582E662B1E00B668E3 /* DetermineBasalIobGreaterThanMaxTests.swift in Sources */,
 				3BFA5BF92D989F510072B082 /* MockTDDStorage.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */,
+				3B3B4F562E661FD600B668E3 /* DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift in Sources */,
 				3BEF6AB32D97316F0076089D /* MealTotalTests.swift in Sources */,
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
 				3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */,

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

@@ -389,6 +389,63 @@ enum DeterminationGenerator {
             return determination
         }
 
+        let (
+            shouldSetTempBasalForGlucoseFallingFasterThanExpected,
+            glucoseFallingFasterThanExpectedDetermination
+        ) = try DosingEngine.glucoseFallingFasterThanExpected(
+            eventualGlucose: forecastResult.eventualGlucose,
+            minGlucose: adjustedGlucoseTargets.minGlucose,
+            minDelta: minDelta,
+            expectedDelta: expectedDelta,
+            glucoseStatus: glucoseStatus,
+            currentTemp: currentTemp,
+            basal: basal,
+            smbIsEnabled: smbIsEnabled,
+            profile: profile,
+            determination: determination
+        )
+        determination = glucoseFallingFasterThanExpectedDetermination
+        if shouldSetTempBasalForGlucoseFallingFasterThanExpected {
+            return determination
+        }
+
+        let (
+            shouldSetTempBasalEventualOrForecastGlucoseLessThanMax,
+            eventualOrForecastGlucoseLessThanMaxDetermination
+        ) = try DosingEngine.eventualOrForecastGlucoseLessThanMax(
+            eventualGlucose: forecastResult.eventualGlucose,
+            maxGlucose: adjustedGlucoseTargets.maxGlucose,
+            minForecastGlucose: forecastResult.minForecastedGlucose,
+            currentTemp: currentTemp,
+            basal: basal,
+            smbIsEnabled: smbIsEnabled,
+            profile: profile,
+            determination: determination
+        )
+        determination = eventualOrForecastGlucoseLessThanMaxDetermination
+        if shouldSetTempBasalEventualOrForecastGlucoseLessThanMax {
+            return determination
+        }
+
+        if forecastResult.eventualGlucose >= adjustedGlucoseTargets.maxGlucose {
+            determination
+                .reason +=
+                "Eventual BG \(DosingEngine.convertGlucose(profile: profile, glucose: forecastResult.eventualGlucose)) >= \(DosingEngine.convertGlucose(profile: profile, glucose: adjustedGlucoseTargets.maxGlucose)), "
+        }
+
+        let (shouldSetTempBasalForIobGreaterThanMax, iobGreaterThanMaxDetermination) = try DosingEngine.iobGreaterThanMax(
+            iob: currentIob,
+            maxIob: profile.maxIob,
+            currentTemp: currentTemp,
+            basal: basal,
+            profile: profile,
+            determination: determination
+        )
+        determination = iobGreaterThanMaxDetermination
+        if shouldSetTempBasalForIobGreaterThanMax {
+            return determination
+        }
+
         // TODO: how to handle output?
         // TODO: how to handle logging?
 

+ 145 - 1
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DosingEngine.swift

@@ -92,7 +92,7 @@ enum DosingEngine {
     }
 
     /// helper function for reason string glucose output
-    private static func convertGlucose(profile: Profile, glucose: Decimal) -> Decimal {
+    static func convertGlucose(profile: Profile, glucose: Decimal) -> Decimal {
         let units = profile.outUnits ?? .mgdL
         switch units {
         case .mgdL: return glucose.jsRounded()
@@ -523,4 +523,148 @@ enum DosingEngine {
             return (shouldSetTempBasal: true, determination: finalDetermination)
         }
     }
+
+    /// Handles the case where glucose is falling faster than expected.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func glucoseFallingFasterThanExpected(
+        eventualGlucose: Decimal,
+        minGlucose: Decimal,
+        minDelta: Decimal,
+        expectedDelta: Decimal,
+        glucoseStatus: GlucoseStatus,
+        currentTemp: TempBasal,
+        basal: Decimal,
+        smbIsEnabled: Bool,
+        profile: Profile,
+        determination: Determination
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        guard minDelta < expectedDelta else {
+            return (shouldSetTempBasal: false, determination: determination)
+        }
+
+        var newDetermination = determination
+
+        if !smbIsEnabled {
+            if glucoseStatus.delta < minDelta {
+                newDetermination
+                    .reason +=
+                    "Eventual BG \(convertGlucose(profile: profile, glucose: eventualGlucose)) > \(convertGlucose(profile: profile, glucose: minGlucose)) but Delta \(convertGlucose(profile: profile, glucose: glucoseStatus.delta)) < Exp. Delta \(convertGlucose(profile: profile, glucose: expectedDelta))"
+            } else {
+                newDetermination
+                    .reason +=
+                    "Eventual BG \(convertGlucose(profile: profile, glucose: eventualGlucose)) > \(convertGlucose(profile: profile, glucose: minGlucose)) but Min. Delta \(minDelta.jsRounded(scale: 2)) < Exp. Delta \(convertGlucose(profile: profile, glucose: expectedDelta))"
+            }
+
+            let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
+            let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
+
+            if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
+                newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
+                return (shouldSetTempBasal: true, determination: newDetermination)
+            } else {
+                newDetermination.reason += "; setting current basal of \(basal) as temp. "
+                let finalDetermination = try TempBasalFunctions.setTempBasal(
+                    rate: basal,
+                    duration: 30,
+                    profile: profile,
+                    determination: newDetermination,
+                    currentTemp: currentTemp
+                )
+                return (shouldSetTempBasal: true, determination: finalDetermination)
+            }
+        }
+
+        return (shouldSetTempBasal: false, determination: determination)
+    }
+
+    /// Handles the case where the eventual or forecasted glucose is less than the max glucose.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func eventualOrForecastGlucoseLessThanMax(
+        eventualGlucose: Decimal,
+        maxGlucose: Decimal,
+        minForecastGlucose: Decimal,
+        currentTemp: TempBasal,
+        basal: Decimal,
+        smbIsEnabled: Bool,
+        profile: Profile,
+        determination: Determination
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        guard min(eventualGlucose, minForecastGlucose) < maxGlucose else {
+            return (shouldSetTempBasal: false, determination: determination)
+        }
+
+        var newDetermination = determination
+        newDetermination.minPredBG = minForecastGlucose
+
+        if !smbIsEnabled {
+            newDetermination
+                .reason +=
+                "\(convertGlucose(profile: profile, glucose: eventualGlucose))-\(convertGlucose(profile: profile, glucose: minForecastGlucose)) in range: no temp required"
+
+            let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
+            let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
+
+            if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
+                newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
+                return (shouldSetTempBasal: true, determination: newDetermination)
+            } else {
+                newDetermination.reason += "; setting current basal of \(basal) as temp. "
+                let finalDetermination = try TempBasalFunctions.setTempBasal(
+                    rate: basal,
+                    duration: 30,
+                    profile: profile,
+                    determination: newDetermination,
+                    currentTemp: currentTemp
+                )
+                return (shouldSetTempBasal: true, determination: finalDetermination)
+            }
+        }
+
+        return (shouldSetTempBasal: false, determination: determination)
+    }
+
+    /// Handles the case where IOB is greater than the max IOB.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func iobGreaterThanMax(
+        iob: Decimal,
+        maxIob: Decimal,
+        currentTemp: TempBasal,
+        basal: Decimal,
+        profile: Profile,
+        determination: Determination
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        guard iob > maxIob else {
+            return (shouldSetTempBasal: false, determination: determination)
+        }
+
+        var newDetermination = determination
+        newDetermination.reason += "IOB \(iob.jsRounded(scale: 2)) > max_iob \(maxIob)"
+
+        let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
+        let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
+
+        if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
+            newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
+            return (shouldSetTempBasal: true, determination: newDetermination)
+        } else {
+            newDetermination.reason += "; setting current basal of \(basal) as temp. "
+            let finalDetermination = try TempBasalFunctions.setTempBasal(
+                rate: basal,
+                duration: 30,
+                profile: profile,
+                determination: newDetermination,
+                currentTemp: currentTemp
+            )
+            return (shouldSetTempBasal: true, determination: finalDetermination)
+        }
+    }
 }

+ 131 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift

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

+ 130 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalGlucoseFallingFasterThanExpectedTests.swift

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

+ 119 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalIobGreaterThanMaxTests.swift

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