Browse Source

Merge pull request #575 from nightscout/oref-swift-enable-smb

A port of the enable_smb and associated SMB enabling logic to Swift
Deniz Cengiz 9 months ago
parent
commit
05ed8c2d74

+ 2 - 0
Model/JSONImporter.swift

@@ -528,6 +528,7 @@ extension Determination: Codable {
         case carbRatio = "CR"
         case received
         case receivedAlt = "recieved"
+        case enableSMB
     }
 
     init(from decoder: Decoder) throws {
@@ -604,6 +605,7 @@ extension Determination: Codable {
         try container.encodeIfPresent(threshold, forKey: .threshold)
         try container.encodeIfPresent(carbRatio, forKey: .carbRatio)
         try container.encodeIfPresent(received, forKey: .received) // always encode the correct spelling
+        try container.encodeIfPresent(enableSMB, forKey: .enableSMB)
     }
 
     func checkForRequiredFields() throws {

+ 4 - 4
Trio.xcodeproj/project.pbxproj

@@ -301,6 +301,7 @@
 		3B997DD32DC02AEF006B6BB2 /* glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B997DD12DC02AEF006B6BB2 /* glucose.json */; };
 		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 */; };
 		3BAD36B22D7CDC1A00CC298D /* MainLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */; };
 		3BAD36CC2D7D420E00CC298D /* CoreDataInitializationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */; };
 		3BAE876E2E47F12900FCA8D2 /* DosingEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAE876D2E47F12900FCA8D2 /* DosingEngine.swift */; };
@@ -648,7 +649,6 @@
 		DD30B9CA2E062A3400DA677C /* ForecastGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */; };
 		DD30B9CC2E062A7000DA677C /* ForecastResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9CB2E062A7000DA677C /* ForecastResult.swift */; };
 		DD30B9CE2E062AA300DA677C /* ForecastGenerator+Forecasts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9CD2E062AA300DA677C /* ForecastGenerator+Forecasts.swift */; };
-		DD30B9FE2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */; };
 		DD30BA002E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */; };
 		DD30BA022E074F0F00DA677C /* GlucoseStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */; };
 		DD30BA062E07667000DA677C /* DetermineBasal+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA052E07667000DA677C /* DetermineBasal+Helpers.swift */; };
@@ -1225,6 +1225,7 @@
 		3B997DD12DC02AEF006B6BB2 /* glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = glucose.json; 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>"; };
 		3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainLoadingView.swift; sourceTree = "<group>"; };
 		3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataInitializationCoordinator.swift; sourceTree = "<group>"; };
 		3BAE876D2E47F12900FCA8D2 /* DosingEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingEngine.swift; sourceTree = "<group>"; };
@@ -1578,7 +1579,6 @@
 		DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastGenerator.swift; sourceTree = "<group>"; };
 		DD30B9CB2E062A7000DA677C /* ForecastResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastResult.swift; sourceTree = "<group>"; };
 		DD30B9CD2E062AA300DA677C /* ForecastGenerator+Forecasts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ForecastGenerator+Forecasts.swift"; sourceTree = "<group>"; };
-		DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalSMBEnablementTests.swift; sourceTree = "<group>"; };
 		DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalDeltaCalculationTests.swift; sourceTree = "<group>"; };
 		DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatus.swift; sourceTree = "<group>"; };
 		DD30BA052E07667000DA677C /* DetermineBasal+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DetermineBasal+Helpers.swift"; sourceTree = "<group>"; };
@@ -2933,8 +2933,8 @@
 				3B8B5D3B2DF523B800365ED3 /* AutosensJsonTests.swift */,
 				3BBC22622DF5B93900169236 /* AutosensTests.swift */,
 				DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */,
+				3BAC929A2E55FF4F00B853DA /* DetermineBasalEnableSmbTests.swift */,
 				3BE9F0BA2E28C993001B14EB /* DetermineBasalJsonTests.swift */,
-				DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */,
 				3B2A3BC22E2B19F700658FB9 /* DynamicISFTests.swift */,
 				3B1C5C352D68E269004E9273 /* IobCalculateTests.swift */,
 				3B2CE68A2E24ADF3005EF782 /* IobGenerateTests.swift */,
@@ -5208,7 +5208,6 @@
 				3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */,
 				3BEF6AB32D97316F0076089D /* MealTotalTests.swift in Sources */,
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
-				DD30B9FE2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift in Sources */,
 				3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */,
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
@@ -5227,6 +5226,7 @@
 				BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */,
 				BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */,
 				3BAAE60C2DE7766C0049589B /* DynamicISFEnableTests.swift in Sources */,
+				3BAC929B2E55FF5300B853DA /* DetermineBasalEnableSmbTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,
 				3B1C5C432D68E269004E9273 /* Extensions.swift in Sources */,
 				3BC26E552D7418830066ACD6 /* IobSuspendTests.swift in Sources */,

+ 2 - 1
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -877,7 +877,8 @@ final class OpenAPS {
             preferences: preferences,
             basalProfile: basalProfile,
             trioCustomOrefVariables: trioCustomOrefVariables,
-            clock: clock
+            clock: clock,
+            includeDebugOutputs: false
         )
         let swiftDuration = Date().timeIntervalSince(startSwiftAt)
 

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

@@ -118,118 +118,6 @@ extension DeterminationGenerator {
         return (glucoseImpact + (delta / fiveMinuteBlocks)).jsRounded(scale: 1)
     }
 
-    /// Determines whether SMBs are enabled based on profile settings,
-    /// computed meal data, CGM conditions, and any active overrides.
-    ///
-    /// Mirrors the JavaScript oref's `enable_smb()` logic.
-    ///
-    /// - Parameters:
-    ///   - glucose: The latest blood glucose reading.
-    ///   - profile: The user profile containing SMB preferences and temp-target flags.
-    ///   - autosens: The autosens data (not used in this logic).
-    ///   - mealData: Computed carbs-on-board and related meal information.
-    ///   - override: An optional override controlling SMB scheduling and hard-off flags.
-    ///   - shouldProtectDueToHIGH: `true` if CGM indicates a HIGH reading requiring SMB disable.
-    ///   - currentTime: The current system time for scheduled-off evaluation.
-    /// - Returns: `true` if SMBs should be enabled, `false` otherwise.
-    static func isSMBEnabled(
-        glucose: BloodGlucose,
-        profile: Profile,
-        autosens _: Autosens,
-        mealData: ComputedCarbs?,
-        override: Override?,
-        shouldProtectDueToHIGH: Bool,
-        currentTime: Date
-    ) -> Bool {
-        if let override = override {
-            if override.smbIsScheduledOff {
-                let startHour = override.start
-                let endHour = override.end
-                let hour = Calendar.current.component(.hour, from: currentTime)
-
-                // disable SMB during the scheduled-off window [start, end)
-                if startHour < endHour {
-                    if hour >= Int(startHour), hour < Int(endHour) {
-                        return false
-                    }
-                }
-                // disable SMB if window wraps midnight
-                else if startHour > endHour {
-                    if hour >= Int(startHour) || hour < Int(endHour) {
-                        return false
-                    }
-                }
-                // special cases: off all day or single-hour off
-                else {
-                    if startHour == 0, endHour == 0 {
-                        return false
-                    }
-                    if hour == Int(startHour) {
-                        return false
-                    }
-                }
-            } else if override.smbIsOff {
-                // hard-off override disables SMB entirely
-                return false
-            }
-        }
-
-        if let hasActiveTempTarget = profile.temptargetSet, hasActiveTempTarget {
-            // disable SMB when a high temp target is active and not allowed
-            if !profile.allowSMBWithHighTemptarget,
-               let targetGlucose = profile.targetBg,
-               targetGlucose > 100
-            {
-                return false
-            }
-
-            // enable SMB when a low temp target is active
-            if profile.enableSMBWithTemptarget,
-               let targetGlucose = profile.targetBg,
-               targetGlucose < 100
-            {
-                return true
-            }
-        }
-
-        // disable SMB for invalid CGM readings (HIGH)
-        if shouldProtectDueToHIGH {
-            return false
-        }
-
-        // enable SMB unconditionally if always-on preference is set
-        if profile.enableSMBAlways {
-            return true
-        }
-
-        // enable SMB when carbs-on-board (COB) exists
-        if profile.enableSMBWithCOB,
-           let cob = mealData?.mealCOB,
-           cob > 0
-        {
-            return true
-        }
-
-        // enable SMB for the full post-carb window
-        if profile.enableSMBAfterCarbs,
-           let carbs = mealData?.carbs,
-           carbs > 0
-        {
-            return true
-        }
-
-        // enable SMB when BG exceeds the high-BG threshold
-        if profile.enableSMBHighBg,
-           let glucoseVal = glucose.glucose ?? glucose.sgv,
-           glucoseVal >= Int(profile.enableSMBHighBgTarget)
-        {
-            return true
-        }
-
-        // no enable condition met → disable SMB
-        return false
-    }
-
     static func calculateSensitivityRatio(
         currentGlucose: Decimal,
         profile: Profile,

+ 103 - 83
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -21,7 +21,8 @@ enum DeterminationGenerator {
         reservoirData _: Decimal,
         glucose: [BloodGlucose],
         trioCustomOrefVariables: TrioCustomOrefVariables,
-        currentTime: Date
+        currentTime: Date,
+        includeDebugOutputs: Bool
     ) throws -> Determination? {
         var autosensData = autosensData
         let glucoseStatus = try Self.getGlucoseStatus(glucoseReadings: glucose)
@@ -100,50 +101,6 @@ enum DeterminationGenerator {
             trioCustomOrefVariables: trioCustomOrefVariables
         )
 
-        // Safety check: current temp vs. last temp in iob
-        guard let lastTempTarget = iobData.first?.lastTemp else {
-            throw DeterminationError.missingIob
-        }
-        if !checkCurrentTempBasalRateSafety(
-            currentTemp: currentTemp,
-            lastTempTarget: lastTempTarget,
-            currentTime: currentTime
-        ) {
-            let reason =
-                "Safety check: currentTemp does not match lastTemp in IOB or lastTemp ended long ago; canceling temp basal."
-            return Determination(
-                id: UUID(),
-                reason: reason,
-                units: nil,
-                insulinReq: nil,
-                eventualBG: nil,
-                sensitivityRatio: nil,
-                rate: 0,
-                duration: 0,
-                iob: iobData.first?.iob,
-                cob: nil,
-                predictions: nil,
-                deliverAt: currentTime,
-                carbsReq: nil,
-                temp: .absolute,
-                bg: glucoseStatus.glucose,
-                reservoir: nil,
-                isf: profile.sens,
-                timestamp: currentTime,
-                tdd: nil,
-                current_target: profile.targetBg,
-                insulinForManualBolus: nil,
-                manualBolusErrorString: nil,
-                minDelta: nil,
-                expectedDelta: nil,
-                minGuardBG: nil,
-                minPredBG: nil,
-                threshold: nil,
-                carbRatio: profile.carbRatio,
-                received: false
-            )
-        }
-
         let (adjustedGlucoseTargets, threshold) = adjustGlucoseTargets(
             profile: profile,
             autosens: autosensData,
@@ -254,50 +211,113 @@ enum DeterminationGenerator {
             targetLog: "" // Placeholder
         )
 
+        let smbDecision = try DosingEngine.makeSMBDosingDecision(
+            profile: profile,
+            meal: mealData,
+            currentGlucose: currentGlucose,
+            adjustedTargetGlucose: adjustedGlucoseTargets.targetGlucose,
+            adjustedSensitivity: adjustedSensitivity,
+            minGuardGlucose: forecastResult.minGuardGlucose,
+            eventualGlucose: eventualGlucose,
+            threshold: threshold,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            clock: currentTime
+        )
+
         // TODO: STOPPING at LINE 1264
 
-        // FIXME: properly populate all fields!
-        let temporaryResult = Determination(
-            id: UUID(),
-            reason: dosingInputs.reason,
-            units: nil,
-            insulinReq: nil,
-            eventualBG: Int(forecastResult.eventualGlucose),
-            sensitivityRatio: sensitivityRatio, // this would only the AS-adjusted one for now
-            rate: nil,
-            duration: nil,
-            iob: iobData.first?.iob,
-            cob: mealData.mealCOB,
-            predictions: Predictions(
-                iob: forecastResult.iob.map { Int($0.jsRounded()) },
-                zt: forecastResult.zt.map { Int($0.jsRounded()) },
-                cob: forecastResult.cob?.map { Int($0.jsRounded()) },
-                uam: forecastResult.uam?.map { Int($0.jsRounded()) }
-            ),
-            deliverAt: currentTime,
-            carbsReq: dosingInputs.carbsRequired?.carbs,
-            temp: nil,
-            bg: currentGlucose,
-            reservoir: nil,
-            isf: nil,
-            timestamp: currentTime,
-            tdd: nil,
-            current_target: nil,
-            insulinForManualBolus: nil,
-            manualBolusErrorString: nil,
-            minDelta: nil,
-            expectedDelta: expectedDelta,
-            minGuardBG: forecastResult.minGuardGlucose,
-            minPredBG: forecastResult.minForecastedGlucose,
-            threshold: threshold.jsRounded(),
-            carbRatio: forecastResult.adjustedCarbRatio.jsRounded(scale: 1),
-            received: false,
-        )
+        var determination: Determination
+        // Safety check: current temp vs. last temp in iob
+        guard let lastTempTarget = iobData.first?.lastTemp else {
+            throw DeterminationError.missingIob
+        }
+        if !checkCurrentTempBasalRateSafety(
+            currentTemp: currentTemp,
+            lastTempTarget: lastTempTarget,
+            currentTime: currentTime
+        ) {
+            let reason =
+                "Safety check: currentTemp does not match lastTemp in IOB or lastTemp ended long ago; canceling temp basal."
+            determination = Determination(
+                id: UUID(),
+                reason: reason,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: 0,
+                duration: 0,
+                iob: iobData.first?.iob,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: .absolute,
+                bg: glucoseStatus.glucose,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: profile.targetBg,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: nil,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: profile.carbRatio,
+                received: false
+            )
+        } else {
+            // FIXME: properly populate all fields!
+            determination = Determination(
+                id: UUID(),
+                reason: dosingInputs.reason,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: Int(forecastResult.eventualGlucose),
+                sensitivityRatio: sensitivityRatio, // this would only the AS-adjusted one for now
+                rate: nil,
+                duration: nil,
+                iob: iobData.first?.iob,
+                cob: mealData.mealCOB,
+                predictions: Predictions(
+                    iob: forecastResult.iob.map { Int($0.jsRounded()) },
+                    zt: forecastResult.zt.map { Int($0.jsRounded()) },
+                    cob: forecastResult.cob?.map { Int($0.jsRounded()) },
+                    uam: forecastResult.uam?.map { Int($0.jsRounded()) }
+                ),
+                deliverAt: currentTime,
+                carbsReq: dosingInputs.carbsRequired?.carbs,
+                temp: nil,
+                bg: currentGlucose,
+                reservoir: nil,
+                isf: nil,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: nil,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: nil,
+                expectedDelta: expectedDelta,
+                minGuardBG: forecastResult.minGuardGlucose,
+                minPredBG: forecastResult.minForecastedGlucose,
+                threshold: threshold.jsRounded(),
+                carbRatio: forecastResult.adjustedCarbRatio.jsRounded(scale: 1),
+                received: false,
+            )
+        }
+
+        if includeDebugOutputs {
+            determination.enableSMB = smbDecision.isEnabled
+        }
 
         // TODO: how to handle output?
         // TODO: how to handle logging?
 
-        return temporaryResult
+        return determination
     }
 
     static func checkDeterminationInputs(

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

@@ -6,6 +6,159 @@ enum DosingEngine {
         let carbsRequired: (carbs: Decimal, minutes: Decimal)?
     }
 
+    /// struct to keep the relevant state needed for the output of the SMB decision logic
+    struct SMBDecision {
+        let isEnabled: Bool
+        let manualBolusError: Int?
+        let insulinForManualBolus: Decimal?
+        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 trioCustomOrefVariables.shouldProtectDueToHIGH {
+            return false
+        }
+
+        if !profile.allowSMBWithHighTemptarget, profile.temptargetSet == true, adjustedTargetGlucose > 100 {
+            return false
+        }
+
+        if profile.enableSMBAlways {
+            return true
+        }
+
+        if profile.enableSMBWithCOB, meal.mealCOB > 0 {
+            return true
+        }
+
+        if profile.enableSMBAfterCarbs, meal.carbs > 0 {
+            return true
+        }
+
+        if profile.enableSMBWithTemptarget, profile.temptargetSet == true, adjustedTargetGlucose < 100 {
+            return true
+        }
+
+        if profile.enableSMBHighBg, currentGlucose >= profile.enableSMBHighBgTarget {
+            return true
+        }
+
+        return false
+    }
+
+    /// helper function to check if SMB is scheduled off given the current timezone
+    private static func isSmbScheduledOff(trioCustomOrefVariables: TrioCustomOrefVariables, clock: Date) throws -> Bool {
+        guard trioCustomOrefVariables.smbIsScheduledOff else {
+            return false
+        }
+
+        guard let currentHour = clock.hourInLocalTime.map({ Decimal($0) }) else {
+            throw CalendarError.invalidCalendarHourOnly
+        }
+        let startHour = trioCustomOrefVariables.start
+        let endHour = trioCustomOrefVariables.end
+
+        // SMBs will be disabled from [start, end) local time
+        if startHour < endHour, currentHour >= startHour && currentHour < endHour {
+            // disable when the schedule does not wrap around midnight
+            return true
+        } else if startHour > endHour, currentHour >= startHour || currentHour < endHour {
+            // disable when the schedule does wrap around midnight
+            return true
+        } else if startHour == 0, endHour == 0 {
+            // schedule specifies the entire day
+            return true
+        } else if startHour == endHour, currentHour == startHour {
+            // one hour of scheduled off SMB
+            return true
+        }
+
+        return false
+    }
+
+    /// helper function for reason string glucose output
+    private static func convertGlucose(profile: Profile, glucose: Decimal) -> Decimal {
+        let units = profile.outUnits ?? .mgdL
+        switch units {
+        case .mgdL: return glucose.jsRounded()
+        case .mmolL: return glucose.asMmolL
+        }
+    }
+
+    /// Top level smb enabling logic
+    ///
+    /// This function includes both the profile / customOrefVariable checks from JS `enable_smb` as
+    /// well as some of the later checks from `determineBasal` that can disable SMB
+    static func makeSMBDosingDecision(
+        profile: Profile,
+        meal: ComputedCarbs,
+        currentGlucose: Decimal,
+        adjustedTargetGlucose: Decimal,
+        adjustedSensitivity: Decimal,
+        minGuardGlucose: Decimal,
+        eventualGlucose: 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 manualBolusError: Int?
+        var insulinForManualBolus: Decimal?
+        var minGuardGlucoseDecision: Decimal?
+        var reason: String?
+        if smbIsEnabled, minGuardGlucose < threshold {
+            manualBolusError = 1
+            insulinForManualBolus = ((eventualGlucose - adjustedTargetGlucose) / adjustedSensitivity).jsRounded(scale: 2)
+            minGuardGlucoseDecision = minGuardGlucose
+            smbIsEnabled = false
+        }
+
+        let maxDeltaGlucoseThreshold = min(profile.maxDeltaBgThreshold, 0.4)
+        if glucoseStatus.maxDelta > maxDeltaGlucoseThreshold * currentGlucose {
+            reason =
+                "maxDelta \(convertGlucose(profile: profile, glucose: glucoseStatus.maxDelta)) > \(100 * maxDeltaGlucoseThreshold)% of BG \(convertGlucose(profile: profile, glucose: currentGlucose)) - SMB disabled!, "
+            smbIsEnabled = false
+        }
+
+        return SMBDecision(
+            isEnabled: smbIsEnabled,
+            manualBolusError: manualBolusError,
+            insulinForManualBolus: insulinForManualBolus,
+            minGuardGlucose: minGuardGlucoseDecision,
+            reason: reason
+        )
+    }
+
     static func prepareDosingInputs(
         profile: Profile,
         mealData: ComputedCarbs,

+ 12 - 2
Trio/Sources/APS/OpenAPSSwift/Extensions/Date+MinutesFromMidnight.swift

@@ -1,17 +1,27 @@
 import Foundation
 
-enum MinutesFromMidnightError: LocalizedError, Equatable {
+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
@@ -51,7 +61,7 @@ extension Date {
     /// - 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 MinutesFromMidnightError.invalidCalendar
+            throw CalendarError.invalidCalendar
         }
         return currentMinutes >= lowerBound && currentMinutes < upperBound
     }

+ 3 - 3
Trio/Sources/APS/OpenAPSSwift/Iob/IobHistory.swift

@@ -301,7 +301,7 @@ struct IobHistory {
         // be small but at least it matches
         // the fix it to use minutesSinceMidnightWithPrecision
         guard let startMinutes = tempBasal.timestamp.minutesSinceMidnight.map({ Decimal($0) }) else {
-            throw MinutesFromMidnightError.invalidCalendar
+            throw CalendarError.invalidCalendar
         }
 
         guard let duration = tempBasal.duration else {
@@ -327,7 +327,7 @@ struct IobHistory {
         }
 
         guard let startMinutes = tempBasal.timestamp.minutesSinceMidnightWithPrecision else {
-            throw MinutesFromMidnightError.invalidCalendar
+            throw CalendarError.invalidCalendar
         }
 
         let endMinutes = startMinutes + duration
@@ -344,7 +344,7 @@ struct IobHistory {
     private static func splitAtMidnight(tempBasal: ComputedPumpHistoryEvent) throws -> [ComputedPumpHistoryEvent] {
         let minutesPerDay = Decimal(24 * 60)
         guard let startMinutes = tempBasal.timestamp.minutesSinceMidnightWithPrecision else {
-            throw MinutesFromMidnightError.invalidCalendar
+            throw CalendarError.invalidCalendar
         }
 
         guard let duration = tempBasal.duration else {

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

@@ -20,4 +20,9 @@ public struct GlucoseStatus: Codable {
     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)
+    }
 }

+ 1 - 1
Trio/Sources/APS/OpenAPSSwift/Models/Profile.swift

@@ -40,7 +40,7 @@ struct Profile: Codable {
     var temptargetSet: Bool?
     var autosensMax: Decimal = 1.2
     var autosensMin: Decimal = 0.7
-    var outUnits: String?
+    var outUnits: GlucoseUnits?
 
     // Additional properties
     var maxMealAbsorptionTime: Decimal = 6.0

+ 4 - 2
Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -54,7 +54,8 @@ struct OpenAPSSwift {
         preferences: JSON,
         basalProfile: JSON,
         trioCustomOrefVariables: JSON,
-        clock: Date
+        clock: Date,
+        includeDebugOutputs: Bool
     ) -> (OrefFunctionResult, DetermineBasalInputs?) {
         var determineBasalInputs: DetermineBasalInputs?
 
@@ -104,7 +105,8 @@ struct OpenAPSSwift {
                 reservoirData: reservoir ?? 100,
                 glucose: glucose,
                 trioCustomOrefVariables: trioCustomOrefVariables,
-                currentTime: clock
+                currentTime: clock,
+                includeDebugOutputs: includeDebugOutputs
             )
 
             return (try .success(JSONBridge.to(rawDetermination)), determineBasalInputs)

+ 1 - 1
Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift

@@ -168,7 +168,7 @@ enum ProfileGenerator {
             }
         }
 
-        profile.outUnits = bgTargets.userPreferredUnits.rawValue
+        profile.outUnits = bgTargets.userPreferredUnits
         let (updatedTargets, range) = try Targets.bgTargetsLookup(targets: bgTargets, tempTargets: tempTargets, profile: profile)
         profile.minBg = range.minBg?.rounded()
         profile.maxBg = range.maxBg?.rounded()

+ 4 - 0
Trio/Sources/Models/Determination.swift

@@ -34,6 +34,9 @@ struct Determination: JSON, Equatable {
     var threshold: Decimal?
     let carbRatio: Decimal?
     let received: Bool?
+
+    // only used for debugging
+    var enableSMB: Bool? = nil
 }
 
 struct Predictions: JSON, Equatable {
@@ -74,6 +77,7 @@ extension Determination {
         case threshold
         case carbRatio = "CR"
         case received
+        case enableSMB
     }
 }
 

+ 442 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalEnableSmbTests.swift

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

+ 4 - 2
TrioTests/OpenAPSSwiftTests/DetermineBasalJsonTests.swift

@@ -55,7 +55,8 @@ import Testing
             preferences: determineBasalInput.preferences,
             basalProfile: determineBasalInput.basalProfile,
             trioCustomOrefVariables: determineBasalInput.trioCustomOrefVariables,
-            clock: determineBasalInput.clock
+            clock: determineBasalInput.clock,
+            includeDebugOutputs: true
         )
 
         let determineBasalResultJavascript = try await openAps.determineBasalJavascript(
@@ -130,7 +131,8 @@ import Testing
             preferences: determineBasalInput.preferences,
             basalProfile: determineBasalInput.basalProfile,
             trioCustomOrefVariables: determineBasalInput.trioCustomOrefVariables,
-            clock: determineBasalInput.clock
+            clock: determineBasalInput.clock,
+            includeDebugOutputs: true
         )
 
         print("Swift result")

+ 0 - 259
TrioTests/OpenAPSSwiftTests/DetermineBasalSMBEnablementTests.swift

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

File diff suppressed because it is too large
+ 1 - 1
TrioTests/OpenAPSSwiftTests/javascript/bundle/determine-basal.js