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

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -303,6 +303,7 @@
 		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 */; };
@@ -1228,6 +1229,7 @@
 		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>"; };
@@ -2956,6 +2958,7 @@
 				3B5CD2C42D4AECD500CE213C /* ProfileIsfTests.swift */,
 				3B5CD2C52D4AECD500CE213C /* ProfileJavascriptTests.swift */,
 				3B5CD2C62D4AECD500CE213C /* ProfileTargetsTests.swift */,
+				3BAC92CC2E57859600B853DA /* SetTempBasalTests.swift */,
 			);
 			path = OpenAPSSwiftTests;
 			sourceTree = "<group>";
@@ -5205,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 */,

+ 18 - 7
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/TempBasalFunctions.swift

@@ -53,8 +53,14 @@ enum TempBasalFunctions {
             currentBasalSafetyMultiplier * currentBasal
         )
     }
-    
-    static func setTempBasal(rate: Decimal, duration: Decimal, profile: Profile, determination: Determination, currentTemp: TempBasal) throws -> Determination {
+
+    static func setTempBasal(
+        rate: Decimal,
+        duration: Decimal,
+        profile: Profile,
+        determination: Determination,
+        currentTemp: TempBasal
+    ) throws -> Determination {
         var determination = determination
         let maxSafeBasal = try getMaxSafeBasalRate(profile: profile)
 
@@ -67,29 +73,34 @@ enum TempBasalFunctions {
 
         let suggestedRate = roundBasal(profile: profile, basalRate: rate)
 
-        if currentTemp.duration > (duration - 10),
+        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"
+            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
+                        .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"
+                    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)U/hr"
+                determination.reason = determination.reason + "Setting neutral temp basal of \(profile.currentBasal ?? 0)U/hr"
                 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")
+    }
+}