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

Merge pull request #576 from nightscout/oref-swift-temp-basal-functions

Port the TempBasalFunctions to Swift
Deniz Cengiz 8 месяцев назад
Родитель
Сommit
6793a5b6b8

+ 8 - 0
Trio.xcodeproj/project.pbxproj

@@ -302,6 +302,8 @@
 		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 */; };
+		3BAC929D2E56A85400B853DA /* TempBasalFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAC929C2E56A84E00B853DA /* TempBasalFunctions.swift */; };
+		3BAC92CD2E57859D00B853DA /* SetTempBasalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAC92CC2E57859600B853DA /* SetTempBasalTests.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 */; };
@@ -1226,6 +1228,8 @@
 		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>"; };
+		3BAC929C2E56A84E00B853DA /* TempBasalFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempBasalFunctions.swift; sourceTree = "<group>"; };
+		3BAC92CC2E57859600B853DA /* SetTempBasalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetTempBasalTests.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>"; };
@@ -2954,6 +2958,7 @@
 				3B5CD2C42D4AECD500CE213C /* ProfileIsfTests.swift */,
 				3B5CD2C52D4AECD500CE213C /* ProfileJavascriptTests.swift */,
 				3B5CD2C62D4AECD500CE213C /* ProfileTargetsTests.swift */,
+				3BAC92CC2E57859600B853DA /* SetTempBasalTests.swift */,
 			);
 			path = OpenAPSSwiftTests;
 			sourceTree = "<group>";
@@ -3748,6 +3753,7 @@
 				DD30BA052E07667000DA677C /* DetermineBasal+Helpers.swift */,
 				DD30B9C62E06257300DA677C /* DetermineBasalGenerator.swift */,
 				3BAE876D2E47F12900FCA8D2 /* DosingEngine.swift */,
+				3BAC929C2E56A84E00B853DA /* TempBasalFunctions.swift */,
 			);
 			path = DetermineBasal;
 			sourceTree = "<group>";
@@ -4744,6 +4750,7 @@
 				388358C825EEF6D200E024B2 /* BasalProfileEntry.swift in Sources */,
 				DDA9AC092D672CF100E6F1A9 /* AppVersionChecker.swift in Sources */,
 				3811DE0B25C9D32F00A708ED /* BaseView.swift in Sources */,
+				3BAC929D2E56A85400B853DA /* TempBasalFunctions.swift in Sources */,
 				3811DE3225C9D49500A708ED /* HomeDataFlow.swift in Sources */,
 				DD32CFA22CC824E2003686D6 /* TrioRemoteControl+Helpers.swift in Sources */,
 				CE1856F52ADC4858007E39C7 /* AddCarbPresetIntent.swift in Sources */,
@@ -5201,6 +5208,7 @@
 				3B8B5D3E2DF5240C00365ED3 /* TimeZoneForTests.swift in Sources */,
 				3B5CD2CA2D4AECD500CE213C /* ProfileJavascriptTests.swift in Sources */,
 				3B5CD2CB2D4AECD500CE213C /* ProfileTargetsTests.swift in Sources */,
+				3BAC92CD2E57859D00B853DA /* SetTempBasalTests.swift in Sources */,
 				3B5CD2CD2D4AECD500CE213C /* ProfileIsfTests.swift in Sources */,
 				3B5CD2CE2D4AECD500CE213C /* ProfileBasalTests.swift in Sources */,
 				3BFA5BF92D989F510072B082 /* MockTDDStorage.swift in Sources */,

+ 114 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/TempBasalFunctions.swift

@@ -0,0 +1,114 @@
+import Foundation
+
+enum TempBasalFunctionError: LocalizedError, Equatable {
+    case invalidBasalRateOnProfile
+
+    var errorDescription: String? {
+        switch self {
+        case .invalidBasalRateOnProfile:
+            return "The currentBasal, maxBasal, or maxDailyBasal wasn't set on Profile"
+        }
+    }
+}
+
+enum TempBasalFunctions {
+    /// Rounds basal rates to match the basal increment for the pump as the basal rate increases
+    static func roundBasal(profile: Profile, basalRate: Decimal) -> Decimal {
+        // FIXME: Should we just call the pumpManager here?
+
+        let lowestRateScale: Decimal
+        if let model = profile.model, model.hasSuffix("54") || model.hasSuffix("23") {
+            lowestRateScale = 40
+        } else {
+            lowestRateScale = 20
+        }
+
+        let roundedBasal: Decimal
+        if basalRate < 1 {
+            roundedBasal = (basalRate * lowestRateScale).jsRounded() / lowestRateScale
+        } else if basalRate < 10 {
+            roundedBasal = (basalRate * 20).jsRounded() / 20
+        } else {
+            roundedBasal = (basalRate * 10).jsRounded() / 10
+        }
+
+        return roundedBasal
+    }
+
+    /// defines the max safe basal rate given a profile
+    static func getMaxSafeBasalRate(profile: Profile) throws -> Decimal {
+        // use default values if either of these are NaN
+        let maxDailySafetyMultiplier = profile.maxDailySafetyMultiplier.isNaN ? 3 : profile.maxDailySafetyMultiplier
+        let currentBasalSafetyMultiplier = profile.currentBasalSafetyMultiplier.isNaN ? 4 : profile.currentBasalSafetyMultiplier
+
+        guard let currentBasal = profile.currentBasal, let maxDailyBasal = profile.maxDailyBasal,
+              let maxBasal = profile.maxBasal
+        else {
+            throw TempBasalFunctionError.invalidBasalRateOnProfile
+        }
+
+        return min(
+            maxBasal,
+            maxDailySafetyMultiplier * maxDailyBasal,
+            currentBasalSafetyMultiplier * currentBasal
+        )
+    }
+
+    static func setTempBasal(
+        rate: Decimal,
+        duration: Decimal,
+        profile: Profile,
+        determination: Determination,
+        currentTemp: TempBasal
+    ) throws -> Determination {
+        var determination = determination
+        let maxSafeBasal = try getMaxSafeBasalRate(profile: profile)
+
+        var rate = rate
+        if rate < 0 {
+            rate = 0
+        } else if rate > maxSafeBasal {
+            rate = maxSafeBasal
+        }
+
+        let suggestedRate = roundBasal(profile: profile, basalRate: rate)
+
+        if Decimal(currentTemp.duration) > (duration - 10),
+           currentTemp.duration <= 120,
+           suggestedRate <= currentTemp.rate * 1.2,
+           suggestedRate >= currentTemp.rate * 0.8,
+           duration > 0
+        {
+            determination
+                .reason += " \(currentTemp.duration)m left and \(currentTemp.rate) ~ req \(suggestedRate)U/hr: no temp required"
+            return determination
+        }
+
+        if suggestedRate == profile.currentBasal {
+            if profile.skipNeutralTemps {
+                if currentTemp.duration > 0 {
+                    determination
+                        .reason = determination.reason +
+                        "Suggested rate is same as profile rate, a temp basal is active, canceling current temp"
+                    determination.duration = 0
+                    determination.rate = 0
+                    return determination
+                } else {
+                    determination
+                        .reason = determination.reason +
+                        "Suggested rate is same as profile rate, no temp basal is active, doing nothing"
+                    return determination
+                }
+            } else {
+                determination.reason = determination.reason + "Setting neutral temp basal of \(profile.currentBasal ?? 0)U/hr"
+                determination.duration = duration
+                determination.rate = suggestedRate
+                return determination
+            }
+        } else {
+            determination.duration = duration
+            determination.rate = suggestedRate
+            return determination
+        }
+    }
+}

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

@@ -7,8 +7,8 @@ struct Determination: JSON, Equatable {
     let insulinReq: Decimal?
     var eventualBG: Int?
     let sensitivityRatio: Decimal?
-    let rate: Decimal?
-    let duration: Decimal?
+    var rate: Decimal?
+    var duration: Decimal?
     let iob: Decimal?
     let cob: Decimal?
     var predictions: Predictions?

+ 305 - 0
TrioTests/OpenAPSSwiftTests/SetTempBasalTests.swift

@@ -0,0 +1,305 @@
+import Foundation
+import Testing
+@testable import Trio
+
+/// A direct port of the Javascript `set-temp-basal.test.js` tests
+@Suite("Set Temp Basal Tests") struct SetTempBasalTests {
+    /// Helper to create a default profile for tests.
+    private func createProfile(
+        currentBasal: Decimal = 0.8,
+        maxDailyBasal: Decimal = 1.3,
+        maxBasal: Decimal = 3.0,
+        skipNeutralTemps: Bool = false,
+        maxDailySafetyMultiplier: Decimal = 3,
+        currentBasalSafetyMultiplier: Decimal = 4,
+        model: String? = nil
+    ) -> Profile {
+        var profile = Profile()
+        profile.currentBasal = currentBasal
+        profile.maxDailyBasal = maxDailyBasal
+        profile.maxBasal = maxBasal
+        profile.skipNeutralTemps = skipNeutralTemps
+        profile.maxDailySafetyMultiplier = maxDailySafetyMultiplier
+        profile.currentBasalSafetyMultiplier = currentBasalSafetyMultiplier
+        profile.model = model
+        return profile
+    }
+
+    /// Helper to create a default determination object.
+    private func createDetermination() -> Determination {
+        Determination(
+            id: UUID(),
+            reason: "",
+            units: nil,
+            insulinReq: nil,
+            eventualBG: nil,
+            sensitivityRatio: nil,
+            rate: nil,
+            duration: nil,
+            iob: nil,
+            cob: nil,
+            predictions: nil,
+            deliverAt: Date(),
+            carbsReq: nil,
+            temp: .absolute,
+            bg: nil,
+            reservoir: nil,
+            isf: nil,
+            timestamp: Date(),
+            tdd: nil,
+            current_target: nil,
+            insulinForManualBolus: nil,
+            manualBolusErrorString: nil,
+            minDelta: nil,
+            expectedDelta: nil,
+            minGuardBG: nil,
+            minPredBG: nil,
+            threshold: nil,
+            carbRatio: nil,
+            received: false
+        )
+    }
+
+    /// Helper to create a TempBasal object
+    private func createCurrentTemp(rate: Decimal = 0, duration: Decimal = 0) -> TempBasal {
+        TempBasal(
+            duration: Int(truncating: duration as NSNumber),
+            rate: rate,
+            temp: .absolute,
+            timestamp: Date()
+        )
+    }
+
+    @Test("should cancel temp") func cancelTemp() throws {
+        let profile = createProfile()
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp()
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 0,
+            duration: 0,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.rate == 0)
+        #expect(requestedTemp.duration == 0)
+    }
+
+    @Test("should set zero temp") func setZeroTemp() throws {
+        let profile = createProfile()
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp()
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 0,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.rate == 0)
+        #expect(requestedTemp.duration == 30)
+    }
+
+    @Test("should set high temp") func setHighTemp() throws {
+        let profile = createProfile()
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp()
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 2,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.rate == 2)
+        #expect(requestedTemp.duration == 30)
+    }
+
+    @Test("should not set basal on skip neutral mode") func skipNeutralMode() throws {
+        // Test case 1: Current temp is active
+        var profile = createProfile(currentBasal: 0.8, skipNeutralTemps: true)
+        var determination = createDetermination()
+        var currentTemp = createCurrentTemp(duration: 10)
+
+        var requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 0.8,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.duration == 0)
+
+        // Test case 2: No current temp
+        determination = createDetermination()
+        currentTemp = createCurrentTemp() // duration = 0
+        requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 0.8,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.reason.contains("no temp basal is active, doing nothing") == true)
+    }
+
+    @Test("should limit high temp to max_basal") func limitToMaxBasal() throws {
+        let profile = createProfile(maxBasal: 3.0)
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp()
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 4,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.rate == 3.0)
+        #expect(requestedTemp.duration == 30)
+    }
+
+    @Test("should limit high temp to 3 * max_daily_basal") func limitToMaxDailyBasal() throws {
+        let profile = createProfile(currentBasal: 1.0, maxDailyBasal: 1.3, maxBasal: 10.0)
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp()
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 6,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.rate == 3.9)
+        #expect(requestedTemp.duration == 30)
+    }
+
+    @Test("should limit high temp to 4 * current_basal") func limitToCurrentBasal() throws {
+        let profile = createProfile(currentBasal: 0.7, maxDailyBasal: 1.3, maxBasal: 10.0)
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp()
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 6,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.rate == 2.8)
+        #expect(requestedTemp.duration == 30)
+    }
+
+    @Test("should temp to 0 when requested rate is less than 0") func rateLessThanZero() throws {
+        let profile = createProfile(currentBasal: 0.7, maxDailyBasal: 1.3, maxBasal: 10.0)
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp()
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: -1,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.rate == 0)
+        #expect(requestedTemp.duration == 30)
+    }
+
+    @Test("should limit high temp to 4 * max_daily_basal when overridden") func limitWithOverrideMaxDaily() throws {
+        let profile = createProfile(currentBasal: 2.0, maxDailyBasal: 1.3, maxBasal: 10.0, maxDailySafetyMultiplier: 4)
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp()
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 6,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.rate == 5.2)
+        #expect(requestedTemp.duration == 30)
+    }
+
+    @Test("should limit high temp to 5 * current_basal when overridden") func limitWithOverrideCurrentBasal() throws {
+        let profile = createProfile(currentBasal: 0.7, maxDailyBasal: 1.3, maxBasal: 10.0, currentBasalSafetyMultiplier: 5)
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp()
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 6,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.rate == 3.5)
+        #expect(requestedTemp.duration == 30)
+    }
+
+    @Test("should allow small basal change when current temp is also small") func allowSmallChange() throws {
+        let profile = createProfile(
+            currentBasal: 0.075,
+            maxDailyBasal: 1.3,
+            maxBasal: 10.0,
+            currentBasalSafetyMultiplier: 5,
+            model: "523"
+        )
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp(rate: 0.025, duration: 24)
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 0,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.rate == 0)
+        #expect(requestedTemp.duration == 30)
+    }
+
+    @Test("should not allow small basal change when current temp is large") func disallowSmallChange() throws {
+        let profile = createProfile(
+            currentBasal: 10.075,
+            maxDailyBasal: 11.3,
+            maxBasal: 50.0,
+            currentBasalSafetyMultiplier: 5,
+            model: "523"
+        )
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp(rate: 10.1, duration: 24)
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 10.125,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+        #expect(requestedTemp.reason.contains("no temp required") == true)
+    }
+
+    @Test("should set neutral temp") func setNeutralTemp() throws {
+        let profile = createProfile(currentBasal: 0.8, skipNeutralTemps: false)
+        let determination = createDetermination()
+        let currentTemp = createCurrentTemp()
+
+        let requestedTemp = try TempBasalFunctions.setTempBasal(
+            rate: 0.8,
+            duration: 30,
+            profile: profile,
+            determination: determination,
+            currentTemp: currentTemp
+        )
+
+        #expect(requestedTemp.rate == 0.8)
+        #expect(requestedTemp.duration == 30)
+        #expect(requestedTemp.reason == "Setting neutral temp basal of 0.8U/hr")
+    }
+}