Selaa lähdekoodia

Port determine-basal enable_smb() to Swift w/ unit tests

Deniz Cengiz 11 kuukautta sitten
vanhempi
commit
19f339e83c

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -634,6 +634,7 @@
 		DD30B9CA2E062A3400DA677C /* ForecastGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */; };
 		DD30B9CC2E062A7000DA677C /* ForecastResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9CB2E062A7000DA677C /* ForecastResult.swift */; };
 		DD30B9CE2E062AA300DA677C /* SingleForecasting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9CD2E062AA300DA677C /* SingleForecasting.swift */; };
+		DD30B9FE2E0742E200DA677C /* SMBEnablementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9FD2E0742E200DA677C /* SMBEnablementTests.swift */; };
 		DD32CF982CC82463003686D6 /* TrioRemoteControl+Bolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */; };
 		DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */; };
 		DD32CF9C2CC82499003686D6 /* TrioRemoteControl+TempTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */; };
@@ -1539,6 +1540,7 @@
 		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 /* SingleForecasting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleForecasting.swift; sourceTree = "<group>"; };
+		DD30B9FD2E0742E200DA677C /* SMBEnablementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBEnablementTests.swift; sourceTree = "<group>"; };
 		DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Bolus.swift"; sourceTree = "<group>"; };
 		DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Meal.swift"; sourceTree = "<group>"; };
 		DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+TempTarget.swift"; sourceTree = "<group>"; };
@@ -2868,6 +2870,7 @@
 		3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */ = {
 			isa = PBXGroup;
 			children = (
+				DD30B9FD2E0742E200DA677C /* SMBEnablementTests.swift */,
 				3BF92F2C2D86DEE9006B545A /* javascript */,
 				3B1C5C3C2D68E269004E9273 /* json */,
 				3BFA5BF72D989F380072B082 /* mocks */,
@@ -5116,6 +5119,7 @@
 				3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */,
 				3BEF6AB32D97316F0076089D /* MealTotalTests.swift in Sources */,
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
+				DD30B9FE2E0742E200DA677C /* SMBEnablementTests.swift in Sources */,
 				3BEF6AB12D9731660076089D /* MealHistoryTests.swift in Sources */,
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,

+ 121 - 31
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -1,15 +1,5 @@
 import Foundation
 
-struct DeterminationInputs {
-    let profile: Profile
-    let currentTemp: TempBasal
-    let iobData: IobResult
-    let autosensData: Autosens
-    let mealData: ComputedCarbs?
-    let reservoirData: Reservoir
-    let currentTime: Date
-}
-
 protocol OverrideHandler {
     func overrideProfileParameters(profile: Profile, override: Override?) throws -> Profile
 
@@ -17,17 +7,18 @@ protocol OverrideHandler {
     /// This could also possibly be handled via an extension of our existing `ProfileGenerator` (?)
 }
 
-struct DeterminationGenerator {
-    let profileGenerator: ProfileGenerator
-    let iobGenerator: IobGenerator
-    let autosensGenerator: AutosensGenerator
-    let mealProcessor: MealTotal
-
+enum DeterminationGenerator {
     // override data can just be fetched from the DB
     // handling via overrideManager ?
 
-    func generate(
-        request _: DeterminationInputs
+    static func generate(
+        profile _: Profile,
+        currentTemp _: TempBasal,
+        iobData _: IobResult?,
+        mealData _: ComputedCarbs?,
+        autosensData _: Autosens,
+        reservoirData _: Reservoir,
+        currentTime _: Date
     ) throws -> Determination? {
         // FIXME: implement... (return type will not be Optional; just to shut up the compiler)
 
@@ -66,17 +57,16 @@ struct DeterminationGenerator {
         /// 7. Ignore Forecast & but guard-BG
         /// 8. Compute carbsReq → we could move this to MEAL
         /// 9. Decide temp basal → we could do a tempBasalGenerator ?
-        
 
         // TODO: how to handle output?
         // TODO: how to handle logging?
-        
+
         nil
     }
 }
 
 extension DeterminationGenerator {
-    func calculateExpectedDelta(
+    public static func calculateExpectedDelta(
         targetGlucose: Decimal,
         eventualGlucose: Decimal,
         glucoseImpact: Decimal
@@ -85,21 +75,121 @@ extension DeterminationGenerator {
         // adjusted by the rate at which glucose would need to rise/fall
         // to move eventual glucose to target over a 2 hr window
         // TODO: expects that glucose can only be available in 5min chunks. do we need to change this handling?
-        
+
         let fiveMinuteBlocks = (2 * 60) / 5
         let delta = targetGlucose - eventualGlucose
         return glucoseImpact + Decimal(Int(delta) / fiveMinuteBlocks).rounded(toPlaces: 1)
-
     }
-    
-    func isSMBEnabled(
-        glucose _: BloodGlucose,
-        profile _: Profile,
+
+    /// 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.
+    public static func isSMBEnabled(
+        glucose: BloodGlucose,
+        profile: Profile,
         autosens _: Autosens,
-        date _: Date
+        mealData: ComputedCarbs?,
+        override: Override?,
+        shouldProtectDueToHIGH: Bool,
+        currentTime: Date
     ) -> Bool {
-        // TODO: handle oref JS's enable_smb() logic
-
-        true
+        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
     }
 }

+ 259 - 0
TrioTests/OpenAPSSwiftTests/SMBEnablementTests.swift

@@ -0,0 +1,259 @@
+
+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
+        )
+    }
+}