Parcourir la source

Port eventual or forecast glucose less than max

Sam King il y a 8 mois
Parent
commit
e0f99226eb

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -226,6 +226,7 @@
 		3B2F77882D7E5387005ED9FA /* CurrentTDDSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */; };
 		3B31D5742E0E26C00047D32D /* ReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B31D5732E0E26BB0047D32D /* ReplayTests.swift */; };
 		3B3B4F542E661A1200B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3B4F532E6619FF00B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift */; };
+		3B3B4F562E661FD600B668E3 /* DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3B4F552E661FC700B668E3 /* DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift */; };
 		3B3B57C92DA07B3400849D16 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3B57C82DA07B3400849D16 /* GoogleService-Info.plist */; };
 		3B4196E02D8C4BC00091DFF7 /* HomeStateModel+CGM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4196DF2D8C4BBB0091DFF7 /* HomeStateModel+CGM.swift */; };
 		3B4550532D862C0000551B0D /* PumpHistoryEvent+Duplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4550522D862BF200551B0D /* PumpHistoryEvent+Duplicates.swift */; };
@@ -1177,6 +1178,7 @@
 		3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentTDDSetup.swift; sourceTree = "<group>"; };
 		3B31D5732E0E26BB0047D32D /* ReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayTests.swift; sourceTree = "<group>"; };
 		3B3B4F532E6619FF00B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalGlucoseFallingFasterThanExpectedTests.swift; sourceTree = "<group>"; };
+		3B3B4F552E661FC700B668E3 /* DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift; sourceTree = "<group>"; };
 		3B3B57C82DA07B3400849D16 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
 		3B4196DF2D8C4BBB0091DFF7 /* HomeStateModel+CGM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeStateModel+CGM.swift"; sourceTree = "<group>"; };
 		3B4550522D862BF200551B0D /* PumpHistoryEvent+Duplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PumpHistoryEvent+Duplicates.swift"; sourceTree = "<group>"; };
@@ -2953,6 +2955,7 @@
 				DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */,
 				3B8221B12E5882D900585156 /* DetermineBasalEarlyExitTests.swift */,
 				3BAC929A2E55FF4F00B853DA /* DetermineBasalEnableSmbTests.swift */,
+				3B3B4F552E661FC700B668E3 /* DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift */,
 				3B3B4F532E6619FF00B668E3 /* DetermineBasalGlucoseFallingFasterThanExpectedTests.swift */,
 				3BE9F0BA2E28C993001B14EB /* DetermineBasalJsonTests.swift */,
 				3B1DB90E2E63C14200BD814B /* DetermineBasalLowEventualGlucoseTests.swift */,
@@ -5233,6 +5236,7 @@
 				3BFA5BF92D989F510072B082 /* MockTDDStorage.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */,
+				3B3B4F562E661FD600B668E3 /* DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift in Sources */,
 				3BEF6AB32D97316F0076089D /* MealTotalTests.swift in Sources */,
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
 				3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */,

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

@@ -409,6 +409,30 @@ enum DeterminationGenerator {
             return determination
         }
 
+        let (
+            shouldSetTempBasalEventualOrForecastGlucoseLessThanMax,
+            eventualOrForecastGlucoseLessThanMaxDetermination
+        ) = try DosingEngine.eventualOrForecastGlucoseLessThanMax(
+            eventualGlucose: forecastResult.eventualGlucose,
+            maxGlucose: adjustedGlucoseTargets.maxGlucose,
+            minForecastGlucose: forecastResult.minForecastedGlucose,
+            currentTemp: currentTemp,
+            basal: basal,
+            smbIsEnabled: smbIsEnabled,
+            profile: profile,
+            determination: determination
+        )
+        determination = eventualOrForecastGlucoseLessThanMaxDetermination
+        if shouldSetTempBasalEventualOrForecastGlucoseLessThanMax {
+            return determination
+        }
+
+        if forecastResult.eventualGlucose >= adjustedGlucoseTargets.maxGlucose {
+            determination
+                .reason +=
+                "Eventual BG \(DosingEngine.convertGlucose(profile: profile, glucose: forecastResult.eventualGlucose)) >= \(DosingEngine.convertGlucose(profile: profile, glucose: adjustedGlucoseTargets.maxGlucose)), "
+        }
+
         // TODO: how to handle output?
         // TODO: how to handle logging?
 

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

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

+ 131 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalEventualOrForecastGlucoseLessThanMaxTests.swift

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