Parcourir la source

Implement low glucose suspend and skip neutral temp dosing logic

This commit includes logic for low glucose suspend and skip neutral
temp dosing logic checks. We also ported two more unit tests from JS
over to Swift to cover this logic.
Sam King il y a 8 mois
Parent
commit
10a03c6750

+ 43 - 8
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -298,11 +298,15 @@ enum DeterminationGenerator {
             clock: currentTime
         )
 
-        // TODO: STOPPING at LINE 1264
+        let smbIsEnabled = smbDecision.isEnabled
+        var reason = dosingInputs.reason
+        if let smbReason = smbDecision.reason {
+            reason += smbReason
+        }
 
         var determination = Determination(
             id: UUID(),
-            reason: dosingInputs.reason,
+            reason: reason,
             units: nil,
             insulinReq: nil,
             eventualBG: Int(forecastResult.eventualGlucose.jsRounded()),
@@ -326,19 +330,50 @@ enum DeterminationGenerator {
             timestamp: currentTime,
             tdd: nil,
             current_target: nil,
-            insulinForManualBolus: nil,
-            manualBolusErrorString: nil,
+            insulinForManualBolus: smbDecision.insulinForManualBolus,
+            manualBolusErrorString: smbDecision.manualBolusError.map { Decimal($0) },
             minDelta: nil,
             expectedDelta: expectedDelta,
-            minGuardBG: forecastResult.minGuardGlucose,
+            minGuardBG: smbDecision.minGuardGlucose ?? forecastResult.minGuardGlucose,
             minPredBG: forecastResult.minForecastedGlucose,
             threshold: threshold.jsRounded(),
             carbRatio: forecastResult.adjustedCarbRatio.jsRounded(scale: 1),
-            received: false,
+            received: false
         )
 
-        // TODO: how to handle output?
-        // TODO: how to handle logging?
+        // MARK: - Core dosing logic
+
+        let (setTempBasalForLowGlucoseSuspend, lowGlucoseSuspendDetermination) = try DosingEngine.lowGlucoseSuspend(
+            currentGlucose: currentGlucose,
+            minGuardGlucose: forecastResult.minGuardGlucose,
+            iob: currentIob,
+            minDelta: minDelta,
+            expectedDelta: expectedDelta,
+            threshold: threshold,
+            overrideFactor: trioCustomOrefVariables.overrideFactor(),
+            profile: profile,
+            eventualGlucose: eventualGlucose,
+            adjustedSensitivity: adjustedSensitivity,
+            targetGlucose: adjustedGlucoseTargets.targetGlucose,
+            currentTemp: currentTemp,
+            determination: determination
+        )
+        determination = lowGlucoseSuspendDetermination
+        if setTempBasalForLowGlucoseSuspend {
+            return determination
+        }
+
+        let (setTempBasalForSkipNeutralTemp, skipNeutralTempDetermination) = try DosingEngine.skipNeutralTempBasal(
+            smbIsEnabled: smbIsEnabled,
+            profile: profile,
+            clock: currentTime,
+            currentTemp: currentTemp,
+            determination: determination
+        )
+        determination = skipNeutralTempDetermination
+        if setTempBasalForSkipNeutralTemp {
+            return determination
+        }
 
         return determination
     }

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

@@ -271,4 +271,123 @@ enum DosingEngine {
 
         return nil
     }
+
+    /// Determines if a low glucose suspend is warranted.
+    ///
+    /// This function checks for low glucose conditions and may modify the determination object
+    /// with a suspend recommendation and an updated reason string.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `setTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func lowGlucoseSuspend(
+        currentGlucose: Decimal,
+        minGuardGlucose: Decimal,
+        iob: Decimal,
+        minDelta: Decimal,
+        expectedDelta: Decimal,
+        threshold: Decimal,
+        overrideFactor: Decimal,
+        profile: Profile,
+        eventualGlucose: Decimal,
+        adjustedSensitivity: Decimal,
+        targetGlucose: Decimal,
+        currentTemp: TempBasal,
+        determination: Determination
+    ) throws -> (setTempBasal: Bool, determination: Determination) {
+        var newDetermination = determination
+
+        guard let currentBasal = profile.currentBasal else {
+            // Should have been checked earlier
+            throw TempBasalFunctionError.invalidBasalRateOnProfile
+        }
+
+        let suspendThreshold = -currentBasal * overrideFactor * 20 / 60
+        if currentGlucose < threshold, iob < suspendThreshold, minDelta > 0, minDelta > expectedDelta {
+            let iobString = String(describing: iob)
+            let suspendString = String(describing: suspendThreshold.jsRounded(scale: 2))
+            let minDeltaString = String(describing: convertGlucose(profile: profile, glucose: minDelta))
+            let expectedDeltaString = String(describing: convertGlucose(profile: profile, glucose: expectedDelta))
+
+            newDetermination
+                .reason +=
+                "IOB \(iobString) < \(suspendString) and minDelta \(minDeltaString) > expectedDelta \(expectedDeltaString); "
+            return (setTempBasal: false, determination: newDetermination)
+        } else if currentGlucose < threshold || minGuardGlucose < threshold {
+            let minGuardGlucoseString = String(describing: convertGlucose(profile: profile, glucose: minGuardGlucose))
+            let thresholdString = String(describing: convertGlucose(profile: profile, glucose: threshold))
+            newDetermination.reason += "minGuardBG \(minGuardGlucoseString) < \(thresholdString)"
+
+            let bgUndershoot = targetGlucose - minGuardGlucose
+            if minGuardGlucose < threshold {
+                newDetermination.manualBolusErrorString = 2
+                newDetermination.minGuardBG = minGuardGlucose
+            }
+
+            newDetermination.insulinForManualBolus = ((eventualGlucose - targetGlucose) / adjustedSensitivity)
+                .rounded(toPlaces: 2)
+
+            let worstCaseInsulinReq = bgUndershoot / adjustedSensitivity
+            var durationReq = (60 * worstCaseInsulinReq / (currentBasal * overrideFactor)).rounded()
+            durationReq = (durationReq / 30).rounded() * 30
+            durationReq = max(30, min(120, durationReq))
+
+            let finalDetermination = try TempBasalFunctions.setTempBasal(
+                rate: 0,
+                duration: durationReq,
+                profile: profile,
+                determination: newDetermination,
+                currentTemp: currentTemp
+            )
+            return (setTempBasal: true, determination: finalDetermination)
+        }
+
+        return (setTempBasal: false, determination: determination)
+    }
+
+    /// Determines if a neutral temp basal should be skipped to avoid pump alerts.
+    ///
+    /// - Returns: A tuple containing:
+    ///   - `setTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
+    ///   - `determination`: The (potentially modified) determination object.
+    static func skipNeutralTempBasal(
+        smbIsEnabled: Bool,
+        profile: Profile,
+        clock: Date,
+        currentTemp: TempBasal,
+        determination: Determination
+    ) throws -> (setTempBasal: Bool, determination: Determination) {
+        guard profile.skipNeutralTemps else {
+            return (setTempBasal: false, determination: determination)
+        }
+        guard let totalMinutes = clock.minutesSinceMidnight else {
+            throw CalendarError.invalidCalendar
+        }
+
+        let minute = totalMinutes % 60
+        guard minute >= 55 else {
+            return (setTempBasal: false, determination: determination)
+        }
+
+        if !smbIsEnabled {
+            var newDetermination = determination
+            let minutesLeft = 60 - minute
+            newDetermination
+                .reason +=
+                "; Canceling temp at \(minutesLeft)min before turn of the hour to avoid beeping of MDT. SMB are disabled anyways."
+
+            let finalDetermination = try TempBasalFunctions.setTempBasal(
+                rate: 0,
+                duration: 0,
+                profile: profile,
+                determination: newDetermination,
+                currentTemp: currentTemp
+            )
+            return (setTempBasal: true, determination: finalDetermination)
+        } else {
+            // In the JS, this path logs to the console but does not modify determination.
+            // We will do nothing here to match that behavior.
+            return (setTempBasal: false, determination: determination)
+        }
+    }
 }

+ 2 - 2
Trio/Sources/Models/Determination.swift

@@ -29,8 +29,8 @@ struct Determination: JSON, Equatable {
     var tdd: Decimal?
 
     var current_target: Decimal?
-    let insulinForManualBolus: Decimal?
-    let manualBolusErrorString: Decimal?
+    var insulinForManualBolus: Decimal?
+    var manualBolusErrorString: Decimal?
     var minDelta: Decimal?
     var expectedDelta: Decimal?
     var minGuardBG: Decimal?

+ 84 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalEarlyExitTests.swift

@@ -467,4 +467,88 @@ import Testing
             )
         }
     }
+
+    // Test 9 from JS
+    @Test("should low-temp if BG is below threshold") func lowGlucoseSuspend() throws {
+        var (
+            profile,
+            preferences,
+            currentTemp,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            _,
+            trioCustomOrefVariables,
+            currentTime
+        ) = createDefaultInputs()
+
+        let glucoseStatus = GlucoseStatus(
+            delta: 0,
+            glucose: 70,
+            noise: 1,
+            shortAvgDelta: 0,
+            longAvgDelta: 0.1,
+            date: currentTime,
+            lastCalIndex: nil,
+            device: "test"
+        )
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect((result?.duration ?? 0) >= 30)
+        #expect(result?.reason.contains("minGuardBG") == true)
+    }
+
+    // Test 10 from JS
+    @Test("should cancel temp before the hour if not doing SMB") func skipNeutralTemp() throws {
+        var (
+            profile,
+            preferences,
+            currentTemp,
+            iobData,
+            mealData,
+            autosensData,
+            reservoirData,
+            glucoseStatus,
+            trioCustomOrefVariables,
+            _
+        ) = createDefaultInputs()
+
+        profile.skipNeutralTemps = true
+
+        // Create a date that is 56 minutes past the hour
+        var components = Calendar.current.dateComponents(in: .current, from: Date())
+        components.minute = 56
+        let currentTime = Calendar.current.date(from: components)!
+
+        let result = try DeterminationGenerator.determineBasal(
+            profile: profile,
+            preferences: preferences,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            mealData: mealData,
+            autosensData: autosensData,
+            reservoirData: reservoirData,
+            glucoseStatus: glucoseStatus,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            currentTime: currentTime
+        )
+
+        #expect(result?.rate == 0)
+        #expect(result?.duration == 0)
+        #expect(result?.reason.contains("Canceling temp") == true)
+    }
 }