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

This commit decomposes the dosing logic into three functions within DosingEngine.
It also:
- Adds tests for all three functions for both JS and Swift
- Adds overrideFactor to profile basal rate calculations
- Adds a floor for microBolus to match JS

Sam King 5 месяцев назад
Родитель
Сommit
7bbf91cb91

+ 4 - 1
Trio.xcodeproj/project.pbxproj

@@ -305,6 +305,7 @@
 		3B997DCB2DC00849006B6BB2 /* JSONImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B997DCA2DC00849006B6BB2 /* JSONImporter.swift */; };
 		3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */; };
 		3B997DD32DC02AEF006B6BB2 /* glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B997DD12DC02AEF006B6BB2 /* glucose.json */; };
+		3BA643EA2ED9FAD8007BC31F /* DetermineBasalAggressiveDosingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA643E92ED9FAD8007BC31F /* DetermineBasalAggressiveDosingTests.swift */; };
 		3BA8D1B32DDB87150006191F /* DecimalExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA8D1B22DDB870F0006191F /* DecimalExtensions.swift */; };
 		3BAAE60C2DE7766C0049589B /* DynamicISFEnableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */; };
 		3BAC929B2E55FF5300B853DA /* DetermineBasalEnableSmbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAC929A2E55FF4F00B853DA /* DetermineBasalEnableSmbTests.swift */; };
@@ -353,7 +354,6 @@
 		3BF92F382D86E10B006B545A /* OpenAPSFixed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF92F372D86E106006B545A /* OpenAPSFixed.swift */; };
 		3BF92F3A2D86F1AA006B545A /* iob-error-log.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BF92F392D86F1AA006B545A /* iob-error-log.json */; };
 		3BFA5BF92D989F510072B082 /* MockTDDStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BFA5BF82D989F460072B082 /* MockTDDStorage.swift */; };
-		3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; };
 		3E28F2AB2EB5337F00FB9EEB /* ConnectIQ in Frameworks */ = {isa = PBXBuildFile; productRef = 3E28F2AA2EB5337F00FB9EEB /* ConnectIQ */; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
 		45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; };
@@ -1242,6 +1242,7 @@
 		3B997DCA2DC00849006B6BB2 /* JSONImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONImporter.swift; sourceTree = "<group>"; };
 		3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONImporterTests.swift; sourceTree = "<group>"; };
 		3B997DD12DC02AEF006B6BB2 /* glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = glucose.json; sourceTree = "<group>"; };
+		3BA643E92ED9FAD8007BC31F /* DetermineBasalAggressiveDosingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalAggressiveDosingTests.swift; sourceTree = "<group>"; };
 		3BA8D1B22DDB870F0006191F /* DecimalExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecimalExtensions.swift; sourceTree = "<group>"; };
 		3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicISFEnableTests.swift; sourceTree = "<group>"; };
 		3BAC929A2E55FF4F00B853DA /* DetermineBasalEnableSmbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalEnableSmbTests.swift; sourceTree = "<group>"; };
@@ -2967,6 +2968,7 @@
 				3B4D17122E1D89FE007FB180 /* AutosensJsonExtraTests.swift */,
 				3B8B5D3B2DF523B800365ED3 /* AutosensJsonTests.swift */,
 				3BBC22622DF5B93900169236 /* AutosensTests.swift */,
+				3BA643E92ED9FAD8007BC31F /* DetermineBasalAggressiveDosingTests.swift */,
 				DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */,
 				3B8221B12E5882D900585156 /* DetermineBasalEarlyExitTests.swift */,
 				3BAC929A2E55FF4F00B853DA /* DetermineBasalEnableSmbTests.swift */,
@@ -5306,6 +5308,7 @@
 				3BEF6AB72D9750780076089D /* MealJsonTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				3B8221B22E5882E300585156 /* DetermineBasalEarlyExitTests.swift in Sources */,
+				3BA643EA2ED9FAD8007BC31F /* DetermineBasalAggressiveDosingTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				DD30BA002E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,

+ 40 - 159
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -449,172 +449,53 @@ enum DeterminationGenerator {
             return determination
         }
 
-        var insulinRequired = (
-            (
-                min(forecastResult.minForecastedGlucose, forecastResult.eventualGlucose) - adjustedGlucoseTargets
-                    .targetGlucose
-            )
-                / adjustedSensitivity
-        )
-        .jsRounded(scale: 2)
-
-        if insulinRequired > profile.maxIob - currentIob {
-            determination.reason += "max_iob \(profile.maxIob), "
-            insulinRequired = (profile.maxIob - currentIob).jsRounded(scale: 2)
-        }
-
-        var rate = basal + (2 * insulinRequired)
-        rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
-
-        determination.insulinReq = insulinRequired
-
-        // 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
-        }
-
-        let minIOBForecastedGlucose = forecastResult.iob.min() ?? 0
-
-        // only allow microboluses with COB or low temp targets, or within DIA hours of a bolus
-        if microBolusAllowed, smbIsEnabled, currentGlucose > threshold {
-            let carbRatio = profile.carbRatio ?? profile.carbRatioFor(time: currentTime)
-            let currentBasal = profile.currentBasal ?? profile.basalFor(time: currentTime)
-            let mealInsulinRequired = (mealData.mealCOB / carbRatio).jsRounded(scale: 3)
-
-            let maxBolusCondition: Bool = currentIob > mealInsulinRequired && currentIob > 0
-            let maxBolus: Decimal =
-                (
-                    maxBolusCondition ? (currentBasal * profile.maxUAMSMBBasalMinutes / 60) :
-                        (currentBasal * profile.maxSMBBasalMinutes / 60)
-                ).jsRounded(scale: 1)
-
-            // FIXME: we technically do no longer need this NaN check for the 2 basal minutes settings -> our swift coding is much safer, value will always be present -- below code ported straight from JS in Swift syntax
-
-//                    if profile.maxSMBBasalMinutes.isNaN {
-//                        maxBolus = (currentBasal * 30 / 60).jsRounded(scale: 1)
-//                    } else if currentIob > mealInsulinRequired, currentIob > 0 {
-//                        if profile.maxUAMSMBBasalMinutes.isNaN {
-//                            maxBolus = (currentBasal * 30 / 60).jsRounded(scale: 1)
-//                        } else {
-//                            maxBolus = (currentBasal * profile.maxUAMSMBBasalMinutes / 60).jsRounded(scale: 1)
-//                        }
-//                    } else {
-//                        maxBolus = (currentBasal * profile.maxSMBBasalMinutes / 60).jsRounded(scale: 1)
-//                    }
-
-            // FIXME: round this to allowed pump increments?
-            let microBolus = min(insulinRequired / 2, maxBolus)
-
-            let smbTarget = adjustedGlucoseTargets.targetGlucose
-            let worstCaseInsulinRequired = (smbTarget - (naiveEventualGlucose + minIOBForecastedGlucose) / 2) /
-                adjustedSensitivity
-            var durationRequired = (60 * worstCaseInsulinRequired / currentBasal).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 {
-                smbLowTempRequired = (basal * durationRequired / 30).jsRounded(scale: 2)
-                durationRequired = 30
-            }
+        // MARK: - Aggressive dosing logic (SMB, High Temps)
 
-            determination.reason += " insulinReq \(insulinRequired)"
-            if microBolus >= maxBolus {
-                determination.reason += "; maxBolus \(maxBolus)"
-            }
-            if durationRequired > 0 {
-                determination.reason += "; setting \(durationRequired)m low temp of \(smbLowTempRequired)U/h"
-            }
-            determination.reason += ". "
-
-            var smbInterval: Decimal = 3
-            if !profile.smbInterval.isNaN {
-                smbInterval = min(10, max(1, profile.smbInterval))
-            }
-
-            if let lastBolusAge {
-                let nextBolusMinutes = smbInterval - lastBolusAge
-                let nextBolusSeconds = (Int(smbInterval - lastBolusAge) * 60) % 60
-
-                if lastBolusAge > smbInterval {
-                    if microBolus > 0 {
-                        determination.units = microBolus
-                        determination.reason += "Microbolusing \(microBolus)U. "
-                    }
-                } else {
-                    determination.reason += "Waiting \(nextBolusMinutes)m \(nextBolusSeconds)s to microbolus again. "
-                }
-            }
-
-            if durationRequired > 0 {
-                determination.rate = smbLowTempRequired
-                determination.duration = durationRequired
-                return determination
-            }
-        }
-
-        let maxSafeBasal = try TempBasalFunctions.getMaxSafeBasalRate(profile: profile)
-
-        if rate > maxSafeBasal {
-            determination.reason += "adj. req. rate: \(rate) to maxSafeBasal: \(maxSafeBasal), "
-            rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: maxSafeBasal)
-        }
-
-        let insulinScheduled = Decimal(currentTemp.duration) * (currentTemp.rate - basal) / 60
-        if insulinScheduled >= insulinRequired * 2 {
-            determination.reason +=
-                "\(currentTemp.duration)m@\(currentTemp.rate.jsRounded(scale: 2)) > 2 * insulinReq. Setting temp basal of \(rate)U/hr. "
-            let finalDetermination = try TempBasalFunctions.setTempBasal(
-                rate: rate,
-                duration: 30,
-                profile: profile,
-                determination: determination,
-                currentTemp: currentTemp
-            )
-            return finalDetermination
-        }
-
-        if currentTemp.duration == 0 {
-            determination.reason += "no temp, setting \(rate)U/hr. "
-            let finalDetermination = try TempBasalFunctions.setTempBasal(
-                rate: rate,
-                duration: 30,
-                profile: profile,
-                determination: determination,
-                currentTemp: currentTemp
-            )
-            return finalDetermination
-        }
-
-        let roundedRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
-        let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
+        // 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
 
-        if currentTemp.duration > 5, roundedRate <= roundedCurrentRate {
-            determination.reason += "temp \(currentTemp.rate) >~ req \(rate)U/hr. "
+        // SMB Delivery
+        let (shouldSetTempBasalForSMB, smbDetermination) = try DosingEngine.determineSMBDelivery(
+            insulinRequired: insulinRequired,
+            microBolusAllowed: microBolusAllowed,
+            smbIsEnabled: smbIsEnabled,
+            currentGlucose: currentGlucose,
+            threshold: threshold,
+            profile: profile,
+            mealData: mealData,
+            iobData: iobData,
+            currentTime: currentTime,
+            targetGlucose: adjustedGlucoseTargets.targetGlucose,
+            naiveEventualGlucose: naiveEventualGlucose,
+            minIOBForecastedGlucose: forecastResult.iob.min() ?? 0,
+            adjustedSensitivity: adjustedSensitivity,
+            overrideFactor: trioCustomOrefVariables.overrideFactor(),
+            adjustedCarbRatio: forecastResult.adjustedCarbRatio,
+            basal: basal,
+            determination: determination
+        )
+        determination = smbDetermination
+        if shouldSetTempBasalForSMB {
             return determination
         }
 
-        determination.reason += "temp \(currentTemp.rate)<\(rate)U/hr. "
-        let finalDetermination = try TempBasalFunctions.setTempBasal(
-            rate: rate,
-            duration: 30,
+        // High Temp Basal (Fallback)
+        return try DosingEngine.determineHighTempBasal(
+            insulinRequired: insulinRequired,
+            basal: basal,
             profile: profile,
-            determination: determination,
-            currentTemp: currentTemp
+            currentTemp: currentTemp,
+            determination: determination
         )
-        return finalDetermination
     }
 
     static func checkDeterminationInputs(

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

@@ -663,4 +663,215 @@ enum DosingEngine {
             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), "
+            insulinRequired = (maxIob - currentIob).jsRounded(scale: 2)
+        }
+        newDetermination.insulinReq = insulinRequired
+        return (insulinRequired, newDetermination)
+    }
+
+    /// 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,
+        mealData: ComputedCarbs,
+        iobData: [IobResult],
+        currentTime: Date,
+        targetGlucose: Decimal,
+        naiveEventualGlucose: Decimal,
+        minIOBForecastedGlucose: Decimal,
+        adjustedSensitivity: Decimal,
+        overrideFactor: 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 mealInsulinRequired = (mealData.mealCOB / adjustedCarbRatio).jsRounded(scale: 3)
+
+        let maxBolusCondition: Bool = currentIob > mealInsulinRequired && currentIob > 0
+        let maxBolus: Decimal = (
+            maxBolusCondition ? (currentBasal * overrideFactor * profile.maxUAMSMBBasalMinutes / 60) :
+                (currentBasal * overrideFactor * profile.maxSMBBasalMinutes / 60)
+        ).jsRounded(scale: 1)
+
+        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 * 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 {
+            let nextBolusMinutes = smbInterval - lastBolusAge
+            let nextBolusSeconds = (Int(smbInterval - lastBolusAge) * 60) % 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), "
+            rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: maxSafeBasal)
+        }
+
+        let insulinScheduled = Decimal(currentTemp.duration) * (currentTemp.rate - basal) / 60
+        if insulinScheduled >= insulinRequired * 2 {
+            newDetermination.reason +=
+                "\(currentTemp.duration)m@\(currentTemp.rate.jsRounded(scale: 2)) > 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
+    }
 }

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

@@ -25,6 +25,11 @@ extension Decimal {
         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 {
         jsRounded(scale: 0)
     }

+ 929 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalAggressiveDosingTests.swift

@@ -0,0 +1,929 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("DetermineBasalAggressiveDosingTests") struct DetermineBasalAggressiveDosingTests {
+    private func callCalculateInsulinRequired(
+        minForecastGlucose: Decimal,
+        eventualGlucose: Decimal,
+        targetGlucose: Decimal,
+        adjustedSensitivity: Decimal,
+        maxIob: Decimal,
+        currentIob: Decimal
+    ) -> (insulinRequired: Decimal, determination: Determination) {
+        let determination = Determination(
+            id: UUID(),
+            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 DosingEngine.calculateInsulinRequired(
+            minForecastGlucose: minForecastGlucose,
+            eventualGlucose: eventualGlucose,
+            targetGlucose: targetGlucose,
+            adjustedSensitivity: adjustedSensitivity,
+            maxIob: maxIob,
+            currentIob: currentIob,
+            determination: determination
+        )
+    }
+
+    @Test("should calculate insulin required based on minPredBG when it is lower") func testCalculateBasedOnMinForecast() {
+        // minPredBG (150) < eventualBG (180)
+        // (150 - 100) / 50 = 1.0 U
+        let result = callCalculateInsulinRequired(
+            minForecastGlucose: 150,
+            eventualGlucose: 180,
+            targetGlucose: 100,
+            adjustedSensitivity: 50,
+            maxIob: 5,
+            currentIob: 0
+        )
+        #expect(result.insulinRequired == 1.0)
+        #expect(result.determination.insulinReq == 1.0)
+    }
+
+    @Test("should calculate insulin required based on eventualBG when it is lower") func testCalculateBasedOnEventual() {
+        // eventualBG (140) < minPredBG (160)
+        // (140 - 100) / 40 = 1.0 U
+        let result = callCalculateInsulinRequired(
+            minForecastGlucose: 160,
+            eventualGlucose: 140,
+            targetGlucose: 100,
+            adjustedSensitivity: 40,
+            maxIob: 5,
+            currentIob: 0
+        )
+        #expect(result.insulinRequired == 1.0)
+        #expect(result.determination.insulinReq == 1.0)
+    }
+
+    @Test("should cap insulinReq at max_iob - current_iob") func testCapAtMaxIOB() {
+        // (200 - 100) / 20 = 5.0 U required
+        // max_iob (3) - current_iob (1) = 2.0 U available space
+        let result = callCalculateInsulinRequired(
+            minForecastGlucose: 200,
+            eventualGlucose: 200,
+            targetGlucose: 100,
+            adjustedSensitivity: 20,
+            maxIob: 3,
+            currentIob: 1
+        )
+        #expect(result.insulinRequired == 2.0)
+        #expect(result.determination.reason.contains("max_iob 3"))
+    }
+
+    @Test("should not cap if insulinReq is within max_iob limits") func testNoCapWithinLimits() {
+        // (140 - 100) / 20 = 2.0 U required
+        // max_iob (5) - current_iob (1) = 4.0 U available space
+        let result = callCalculateInsulinRequired(
+            minForecastGlucose: 140,
+            eventualGlucose: 140,
+            targetGlucose: 100,
+            adjustedSensitivity: 20,
+            maxIob: 5,
+            currentIob: 1
+        )
+        #expect(result.insulinRequired == 2.0)
+        #expect(!result.determination.reason.contains("max_iob"))
+    }
+
+    @Test("should handle negative IOB increasing available space") func testNegativeIOBIncreasesSpace() {
+        // (200 - 100) / 20 = 5.0 U required
+        // max_iob (3) - current_iob (-1) = 4.0 U available space
+        let result = callCalculateInsulinRequired(
+            minForecastGlucose: 200,
+            eventualGlucose: 200,
+            targetGlucose: 100,
+            adjustedSensitivity: 20,
+            maxIob: 3,
+            currentIob: -1
+        )
+        #expect(result.insulinRequired == 4.0)
+        #expect(result.determination.reason.contains("max_iob 3"))
+    }
+
+    @Test("should handle negative insulinReq correctly") func testNegativeInsulinReq() {
+        // (90 - 100) / 50 = -0.2 U
+        let result = callCalculateInsulinRequired(
+            minForecastGlucose: 90,
+            eventualGlucose: 95,
+            targetGlucose: 100,
+            adjustedSensitivity: 50,
+            maxIob: 5,
+            currentIob: 0
+        )
+        #expect(result.insulinRequired == -0.2)
+    }
+
+    @Test("should round calculations to 2 decimal places") func testRounding() {
+        // (133 - 100) / 30 = 1.1
+        let result = callCalculateInsulinRequired(
+            minForecastGlucose: 133,
+            eventualGlucose: 133,
+            targetGlucose: 100,
+            adjustedSensitivity: 30,
+            maxIob: 5,
+            currentIob: 0
+        )
+        #expect(result.insulinRequired == 1.1)
+    }
+
+    private func callDetermineSMBDelivery(
+        insulinRequired: Decimal,
+        microBolusAllowed: Bool = true,
+        smbIsEnabled: Bool = true,
+        currentGlucose: Decimal = 120,
+        threshold: Decimal = 60,
+        profile: Profile,
+        mealData: ComputedCarbs = ComputedCarbs(
+            carbs: 0,
+            mealCOB: 0,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [],
+            lastCarbTime: 0
+        ),
+        iobData: [IobResult],
+        overrideFactor: Decimal = 1.0,
+        adjustedCarbRatio: Decimal = 10,
+        basal: Decimal = 1.0,
+        naiveEventualGlucose: Decimal = 120,
+        minIOBForecastedGlucose: Decimal = 120
+    ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
+        let determination = Determination(
+            id: UUID(),
+            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.determineSMBDelivery(
+            insulinRequired: insulinRequired,
+            microBolusAllowed: microBolusAllowed,
+            smbIsEnabled: smbIsEnabled,
+            currentGlucose: currentGlucose,
+            threshold: threshold,
+            profile: profile,
+            mealData: mealData,
+            iobData: iobData,
+            currentTime: Date(),
+            targetGlucose: profile.targetBg ?? 100,
+            naiveEventualGlucose: naiveEventualGlucose,
+            minIOBForecastedGlucose: minIOBForecastedGlucose,
+            adjustedSensitivity: profile.sens ?? 40,
+            overrideFactor: overrideFactor,
+            adjustedCarbRatio: adjustedCarbRatio,
+            basal: basal,
+            determination: determination
+        )
+    }
+
+    @Test("should calculate correct microbolus with rounding") func testMicroBolusRounding() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxSMBBasalMinutes = 30
+        profile.smbDeliveryRatio = 0.5
+        profile.bolusIncrement = 0.1
+        profile.sens = 40
+        profile.targetBg = 100
+
+        let now = Date()
+        let lastBolusTime = UInt64(now.addingTimeInterval(-600).timeIntervalSince1970 * 1000) // 10 minutes ago
+
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date(),
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: lastBolusTime,
+            lastTemp: nil
+        )]
+
+        // insulinReq = 1.55
+        // maxBolus = 1.0 * 30/60 = 0.5
+        // smb = min(1.55 * 0.5, 0.5) = min(0.775, 0.5) = 0.5
+        // 0.5 is already rounded.
+        // Let's try a case where maxBolus is higher.
+
+        profile.maxSMBBasalMinutes = 60
+        // maxBolus = 1.0 * 60/60 = 1.0
+        // smb = min(1.55 * 0.5, 1.0) = 0.775
+        // rounded down to 0.1 increment: 0.7
+
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 1.55,
+            profile: profile,
+            iobData: iobData
+        )
+
+        #expect(result.determination.units == 0.7)
+    }
+
+    @Test("should apply override factor to maxBolus") func testOverrideFactorMaxBolus() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxSMBBasalMinutes = 30
+        profile.smbDeliveryRatio = 0.5
+        profile.bolusIncrement = 0.1
+        profile.sens = 40
+        profile.targetBg = 100
+
+        let now = Date()
+        let lastBolusTime = UInt64(now.addingTimeInterval(-600).timeIntervalSince1970 * 1000) // 10 minutes ago
+
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date(),
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: lastBolusTime,
+            lastTemp: nil
+        )]
+
+        // Override factor 2.0 (200%)
+        // maxBolus = 1.0 * 2.0 * 30/60 = 1.0
+        // insulinReq = 3.0
+        // smb = min(3.0 * 0.5, 1.0) = min(1.5, 1.0) = 1.0
+
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 3.0,
+            profile: profile,
+            iobData: iobData,
+            overrideFactor: 2.0
+        )
+
+        #expect(result.determination.units == 1.0)
+    }
+
+    @Test("should use UAM max minutes when appropriate") func testUAMMaxMinutes() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxSMBBasalMinutes = 30 // 0.5U
+        profile.maxUAMSMBBasalMinutes = 60 // 1.0U
+        profile.smbDeliveryRatio = 0.5
+        profile.bolusIncrement = 0.1
+        profile.sens = 40
+        profile.targetBg = 100
+
+        let now = Date()
+        let lastBolusTime = UInt64(now.addingTimeInterval(-600).timeIntervalSince1970 * 1000) // 10 minutes ago
+
+        // IOB > mealInsulinReq
+        // mealCOB = 10, CR = 10 => mealInsulinReq = 1.0
+        // iob = 1.5
+        let mealData = ComputedCarbs(
+            carbs: 0,
+            mealCOB: 10,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [],
+            lastCarbTime: 0
+        )
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 1.5,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date(),
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: lastBolusTime,
+            lastTemp: nil
+        )]
+
+        // insulinReq = 3.0
+        // smb = min(3.0 * 0.5, 1.0) = 1.0 (uses UAM limit)
+
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 3.0,
+            profile: profile,
+            mealData: mealData,
+            iobData: iobData
+        )
+
+        #expect(result.determination.units == 1.0)
+    }
+
+    @Test("should not bolus if within SMB interval") func testSMBInterval() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxSMBBasalMinutes = 30
+        profile.smbDeliveryRatio = 0.5
+        profile.bolusIncrement = 0.1
+        profile.smbInterval = 3
+        profile.sens = 40
+        profile.targetBg = 100
+
+        // Last bolus 1 minute ago
+        let now = Date()
+        let lastBolusTime = UInt64(now.addingTimeInterval(-60).timeIntervalSince1970 * 1000)
+
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: now,
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: lastBolusTime,
+            lastTemp: nil
+        )]
+
+        // Setup conditions where temp basal is required so it returns true, but NO units
+        // worstCase > 0 => low pred
+        // (100 - (90+90)/2) / 40 = 0.25
+        // duration = 60 * 0.25 / 1 = 15m
+        // 15m -> <30m -> sets smbLowTempReq -> returns true
+
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 1.0,
+            profile: profile,
+            iobData: iobData,
+            naiveEventualGlucose: 90,
+            minIOBForecastedGlucose: 90
+        )
+
+        #expect(result.shouldSetTempBasal == true)
+        #expect(result.determination.units == nil)
+        #expect(result.determination.reason.contains("Waiting"))
+    }
+
+    @Test("should return false if SMB conditions not met") func testGuardConditions() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date(),
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: nil,
+            lastTemp: nil
+        )]
+
+        // bg (100) < threshold (110)
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 1.0,
+            currentGlucose: 100,
+            threshold: 110,
+            profile: profile,
+            iobData: iobData
+        )
+
+        #expect(result.shouldSetTempBasal == false)
+    }
+
+    private func callDetermineHighTempBasal(
+        insulinRequired: Decimal,
+        basal: Decimal,
+        profile: Profile,
+        currentTemp: TempBasal
+    ) throws -> Determination {
+        let determination = Determination(
+            id: UUID(),
+            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.determineHighTempBasal(
+            insulinRequired: insulinRequired,
+            basal: basal,
+            profile: profile,
+            currentTemp: currentTemp,
+            determination: determination
+        )
+    }
+
+    @Test("should set high temp if no temp is running") func testSetHighTempNoTemp() throws {
+        var profile = Profile()
+        profile.maxBasal = 5.0
+        profile.maxDailyBasal = 5.0
+        profile.currentBasal = 1.0 // Unused by logic but good for completeness
+
+        // insulinReq = 1.0. basal = 1.0. rate = 1.0 + 2*1.0 = 3.0.
+        let result = try callDetermineHighTempBasal(
+            insulinRequired: 1.0,
+            basal: 1.0,
+            profile: profile,
+            currentTemp: TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: Date())
+        )
+
+        #expect(result.rate == 3.0)
+        #expect(result.duration == 30)
+        #expect(result.reason.contains("no temp, setting 3U/hr"))
+    }
+
+    @Test("should cap rate at maxSafeBasal") func testCapAtMaxSafeBasal() throws {
+        var profile = Profile()
+        profile.maxBasal = 2.0 // Restrict max basal
+        profile.maxDailyBasal = 2.0
+        profile.currentBasalSafetyMultiplier = 4
+        profile.maxDailySafetyMultiplier = 3
+        profile.currentBasal = 1.0
+
+        // insulinReq = 1.0. basal = 1.0. rate = 3.0. Max = 2.0.
+        let result = try callDetermineHighTempBasal(
+            insulinRequired: 1.0,
+            basal: 1.0,
+            profile: profile,
+            currentTemp: TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: Date())
+        )
+
+        #expect(result.rate == 2.0)
+        #expect(result.reason.contains("adj. req. rate: 3"))
+        #expect(result.reason.contains("maxSafeBasal: 2"))
+    }
+
+    @Test("should reduce temp if current temp delivers >2x required insulin") func testReduceTempIfScheduledTooHigh() throws {
+        var profile = Profile()
+        profile.maxBasal = 5.0
+        profile.maxDailyBasal = 5.0
+        profile.currentBasal = 1.0
+
+        // insulinReq = 0.5. 2x = 1.0 U.
+        // basal = 1.0. rate = 1.0 + 1.0 = 2.0.
+        // Current temp: rate 4.0, duration 30m.
+        // insulinScheduled = 30 * (4.0 - 1.0) / 60 = 1.5 U.
+
+        let result = try callDetermineHighTempBasal(
+            insulinRequired: 0.5,
+            basal: 1.0,
+            profile: profile,
+            currentTemp: TempBasal(duration: 30, rate: 4.0, temp: .absolute, timestamp: Date())
+        )
+
+        #expect(result.rate == 2.0)
+        #expect(result.reason.contains("> 2 * insulinReq"))
+    }
+
+    @Test("should do nothing if current temp is sufficient") func testDoNothingIfSufficient() throws {
+        var profile = Profile()
+        profile.maxBasal = 5.0
+        profile.maxDailyBasal = 5.0
+        profile.currentBasal = 1.0
+
+        // insulinReq = 1.0. rate = 3.0.
+        // Current temp: rate 3.0, duration 30m.
+
+        let result = try callDetermineHighTempBasal(
+            insulinRequired: 1.0,
+            basal: 1.0,
+            profile: profile,
+            currentTemp: TempBasal(duration: 30, rate: 3.0, temp: .absolute, timestamp: Date())
+        )
+
+        // Should return determination without setting rate/duration (nil implies unchanged in this context check?)
+        // Wait, determineHighTempBasal returns a Determination. If it calls setTempBasal, rate/duration are set.
+        // If it falls through, it returns 'determination' (which has nil rate/duration).
+
+        #expect(result.rate == nil)
+        #expect(result.duration == nil)
+        #expect(result.reason.contains("temp 3 >~ req 3U/hr"))
+    }
+
+    @Test("should set new temp if current temp is insufficient") func testSetNewTempIfInsufficient() throws {
+        var profile = Profile()
+        profile.maxBasal = 5.0
+        profile.maxDailyBasal = 5.0
+        profile.currentBasal = 1.0
+
+        // insulinReq = 1.0. rate = 3.0.
+        // Current temp: rate 2.0.
+
+        let result = try callDetermineHighTempBasal(
+            insulinRequired: 1.0,
+            basal: 1.0,
+            profile: profile,
+            currentTemp: TempBasal(duration: 30, rate: 2.0, temp: .absolute, timestamp: Date())
+        )
+
+        #expect(result.rate == 3.0)
+        #expect(result.duration == 30)
+        #expect(result.reason.contains("temp 2<3U/hr"))
+    }
+
+    @Test("should set 30m zero temp if durationReq is between 30 and 45") func testSet30mZeroTemp() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxSMBBasalMinutes = 30
+        profile.smbDeliveryRatio = 0.5
+        profile.bolusIncrement = 0.1
+        profile.sens = 50
+        profile.targetBg = 100
+
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date(),
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: nil,
+            lastTemp: nil
+        )]
+
+        // worstCaseInsulinReq needs to result in durationReq ~ 35
+        // duration = 60 * worst / basal => 35 = 60 * worst / 1.0 => worst = 0.583
+        // worst = (100 - avg)/50 => avg = 70.85
+
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 1.0,
+            profile: profile,
+            iobData: iobData,
+            naiveEventualGlucose: 70.85,
+            minIOBForecastedGlucose: 70.85
+        )
+
+        #expect(result.shouldSetTempBasal == true)
+        #expect(result.determination.rate == 0)
+        #expect(result.determination.duration == 30)
+    }
+
+    @Test("should set 60m zero temp if durationReq is > 45") func testSet60mZeroTemp() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxSMBBasalMinutes = 30
+        profile.smbDeliveryRatio = 0.5
+        profile.bolusIncrement = 0.1
+        profile.sens = 50
+        profile.targetBg = 100
+
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date(),
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: nil,
+            lastTemp: nil
+        )]
+
+        // worstCaseInsulinReq needs to result in durationReq ~ 50
+        // 50 = 60 * worst / 1.0 => worst = 0.833
+        // worst = (100 - avg)/50 => avg = 58.35
+
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 1.0,
+            profile: profile,
+            iobData: iobData,
+            naiveEventualGlucose: 58.35,
+            minIOBForecastedGlucose: 58.35
+        )
+
+        #expect(result.shouldSetTempBasal == true)
+        #expect(result.determination.rate == 0)
+        #expect(result.determination.duration == 60)
+    }
+
+    @Test("should cap zero temp duration at 60m") func testCapZeroTempAt60m() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxSMBBasalMinutes = 30
+        profile.smbDeliveryRatio = 0.5
+        profile.bolusIncrement = 0.1
+        profile.sens = 50
+        profile.targetBg = 100
+
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date(),
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: nil,
+            lastTemp: nil
+        )]
+
+        // worstCaseInsulinReq needs to result in durationReq > 75
+        // 100 = 60 * worst / 1.0 => worst = 1.66
+        // worst = (100 - avg)/50 => avg = 17
+
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 1.0,
+            profile: profile,
+            iobData: iobData,
+            naiveEventualGlucose: 17,
+            minIOBForecastedGlucose: 17
+        )
+
+        #expect(result.shouldSetTempBasal == true)
+        #expect(result.determination.rate == 0)
+        #expect(result.determination.duration == 60)
+    }
+
+    @Test("should set low temp if durationReq < 30") func testSetLowTemp() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxSMBBasalMinutes = 30
+        profile.smbDeliveryRatio = 0.5
+        profile.bolusIncrement = 0.1
+        profile.sens = 50
+        profile.targetBg = 100
+
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date(),
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: nil,
+            lastTemp: nil
+        )]
+
+        // worstCaseInsulinReq needs to result in durationReq = 15
+        // 15 = 60 * worst / 1.0 => worst = 0.25
+        // worst = (100 - avg)/50 => avg = 87.5
+
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 1.0,
+            profile: profile,
+            iobData: iobData,
+            basal: 1.0,
+            naiveEventualGlucose: 87.5,
+            minIOBForecastedGlucose: 87.5
+        )
+
+        // Rate = basal * 15/30 = 1.0 * 0.5 = 0.5
+        #expect(result.shouldSetTempBasal == true)
+        #expect(result.determination.rate == 0.5)
+        #expect(result.determination.duration == 30)
+    }
+
+    @Test("should not set temp if insulinReq > 0 but microBolus < increment") func testNoTempIfMicroBolusTooSmall() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxSMBBasalMinutes = 30
+        profile.smbDeliveryRatio = 0.5
+        profile.bolusIncrement = 0.1
+        profile.sens = 50
+        profile.targetBg = 100
+
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date(),
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: nil,
+            lastTemp: nil
+        )]
+
+        // insulinReq = 0.05 (positive but small)
+        // microBolus < 0.1
+        // durationReq = 15m (via predictions)
+
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 0.05,
+            profile: profile,
+            iobData: iobData,
+            basal: 1.0,
+            naiveEventualGlucose: 87.5,
+            minIOBForecastedGlucose: 87.5
+        )
+
+        #expect(result.shouldSetTempBasal == false)
+    }
+
+    @Test("should not set temp if durationReq <= 0") func testNoTempIfDurationReqNegative() throws {
+        var profile = Profile()
+        profile.currentBasal = 1.0
+        profile.maxSMBBasalMinutes = 30
+        profile.smbDeliveryRatio = 0.5
+        profile.bolusIncrement = 0.1
+        profile.sens = 50
+        profile.targetBg = 100
+
+        let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date()
+        )
+        let iobData = [IobResult(
+            iob: 0,
+            activity: 0,
+            basaliob: 0,
+            bolusiob: 0,
+            netbasalinsulin: 0,
+            bolusinsulin: 0,
+            time: Date(),
+            iobWithZeroTemp: dummyIobWithZeroTemp,
+            lastBolusTime: nil,
+            lastTemp: nil
+        )]
+
+        // High predictions => negative worstCase => negative durationReq
+        // avg = 150 > target 100
+
+        let result = try callDetermineSMBDelivery(
+            insulinRequired: 1.0,
+            profile: profile,
+            iobData: iobData,
+            basal: 1.0,
+            naiveEventualGlucose: 150,
+            minIOBForecastedGlucose: 150
+        )
+
+        #expect(result.shouldSetTempBasal == false)
+    }
+}

+ 1 - 2
TrioTests/OpenAPSSwiftTests/DetermineBasalSmbMicroBolusTests.swift

@@ -117,8 +117,7 @@ import Testing
             start: 0,
             end: 0,
             smbMinutes: 30,
-            uamMinutes: 30,
-            shouldProtectDueToHIGH: false
+            uamMinutes: 30
         )
 
         return (