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

Initial port of DynamicISF

This implementation of DynamicISF. It returns a new ratio, tddRatio,
and insulinFactor, all of which are used downstream.

In a future commit we need to add the dynamic ISF results to our
forecasting functions.
Sam King 10 месяцев назад
Родитель
Сommit
f79417c78a

+ 8 - 0
Trio.xcodeproj/project.pbxproj

@@ -218,6 +218,8 @@
 		3B1C5C472D68E269004E9273 /* IobCalculateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C352D68E269004E9273 /* IobCalculateTests.swift */; };
 		3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C362D68E269004E9273 /* IobHistoryTests.swift */; };
 		3B214EEA2E29632900046304 /* determine-basal-prepare.js in Resources */ = {isa = PBXBuildFile; fileRef = 3B214EE92E29631F00046304 /* determine-basal-prepare.js */; };
+		3B2A3BC12E2B19C600658FB9 /* DynamicISF.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2A3BC02E2B19C600658FB9 /* DynamicISF.swift */; };
+		3B2A3BC32E2B19F700658FB9 /* DynamicISFTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2A3BC22E2B19F700658FB9 /* DynamicISFTests.swift */; };
 		3B2CE68B2E24ADF7005EF782 /* IobGenerateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2CE68A2E24ADF3005EF782 /* IobGenerateTests.swift */; };
 		3B2F77862D7E52ED005ED9FA /* TDD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77852D7E52ED005ED9FA /* TDD.swift */; };
 		3B2F77882D7E5387005ED9FA /* CurrentTDDSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */; };
@@ -1160,6 +1162,8 @@
 		3B1C5C3D2D68E269004E9273 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
 		3B1C5C3E2D68E269004E9273 /* IobJsonTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobJsonTypes.swift; sourceTree = "<group>"; };
 		3B214EE92E29631F00046304 /* determine-basal-prepare.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "determine-basal-prepare.js"; sourceTree = "<group>"; };
+		3B2A3BC02E2B19C600658FB9 /* DynamicISF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicISF.swift; sourceTree = "<group>"; };
+		3B2A3BC22E2B19F700658FB9 /* DynamicISFTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicISFTests.swift; sourceTree = "<group>"; };
 		3B2CE68A2E24ADF3005EF782 /* IobGenerateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobGenerateTests.swift; sourceTree = "<group>"; };
 		3B2F77852D7E52ED005ED9FA /* TDD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDD.swift; sourceTree = "<group>"; };
 		3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentTDDSetup.swift; sourceTree = "<group>"; };
@@ -2854,6 +2858,7 @@
 				3B5CD2B22D4AEA6600CE213C /* Models */,
 				3B5CD2972D4AEA3C00CE213C /* Profile */,
 				3B5CD2A02D4AEA5100CE213C /* Utils */,
+				3B2A3BC02E2B19C600658FB9 /* DynamicISF.swift */,
 				3B5CD1E92D4912A600CE213C /* OpenAPSSwift.swift */,
 				3B5CD1EA2D4912A600CE213C /* JSONBridge.swift */,
 			);
@@ -2926,6 +2931,7 @@
 				DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */,
 				3BE9F0BA2E28C993001B14EB /* DetermineBasalJsonTests.swift */,
 				DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */,
+				3B2A3BC22E2B19F700658FB9 /* DynamicISFTests.swift */,
 				3B1C5C352D68E269004E9273 /* IobCalculateTests.swift */,
 				3B2CE68A2E24ADF3005EF782 /* IobGenerateTests.swift */,
 				3B1C5C362D68E269004E9273 /* IobHistoryTests.swift */,
@@ -4780,6 +4786,7 @@
 				3B0B4E6C2DE1439F005C6627 /* LockedResolver.swift in Sources */,
 				3811DE5C25C9D4D500A708ED /* Formatters.swift in Sources */,
 				3871F39F25ED895A0013ECB5 /* Decimal+Extensions.swift in Sources */,
+				3B2A3BC12E2B19C600658FB9 /* DynamicISF.swift in Sources */,
 				CEE9A6592BBB418300EB5194 /* CalibrationsDataFlow.swift in Sources */,
 				3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */,
 				38E98A2925F52C9300C0CED0 /* Error+Extensions.swift in Sources */,
@@ -5208,6 +5215,7 @@
 				3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */,
 				3B31D5742E0E26C00047D32D /* ReplayTests.swift in Sources */,
 				3B8B5D3C2DF523C000365ED3 /* AutosensJsonTests.swift in Sources */,
+				3B2A3BC32E2B19F700658FB9 /* DynamicISFTests.swift in Sources */,
 				BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */,
 				BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */,
 				3BAAE60C2DE7766C0049589B /* DynamicISFEnableTests.swift in Sources */,

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

@@ -13,14 +13,17 @@ enum DeterminationGenerator {
 
     static func generate(
         profile: Profile,
+        preferences: Preferences,
         currentTemp: TempBasal,
         iobData: [IobResult],
         mealData: ComputedCarbs,
         autosensData: Autosens,
         reservoirData _: Decimal,
         glucose: [BloodGlucose],
+        trioCustomOrefVariables: TrioCustomOrefVariables,
         currentTime: Date
     ) throws -> Determination? {
+        var autosensData = autosensData
         let glucoseStatus = try Self.getGlucoseStatus(glucoseReadings: glucose)
 
         try checkDeterminationInputs(
@@ -44,17 +47,44 @@ enum DeterminationGenerator {
             return errorDetermination
         }
 
-        let sensitivityRatio = calculateSensitivityRatio(
+        let sensitivityRatio: Decimal
+        let dynamicIsfResult = DynamicISF.calculate(
             profile: profile,
-            autosens: autosensData,
-            targetGlucose: profile.targetBg ?? 120,
-            temptargetSet: profile.temptargetSet ?? false
+            preferences: preferences,
+            currentGlucose: currentGlucose,
+            trioCustomOrefVariables: trioCustomOrefVariables
         )
 
-        let basal = computeAdjustedBasal(
-            currentBasalRate: profile.currentBasal ?? profile.basalFor(time: currentTime),
-            sensitivityRatio: sensitivityRatio
-        )
+        // TODO: We need to add the dynamicIsfResult to our forcasting functions
+        if let dynamicIsfResult = dynamicIsfResult {
+            sensitivityRatio = dynamicIsfResult.ratio
+            autosensData = Autosens(
+                ratio: dynamicIsfResult.ratio,
+                newisf: autosensData.newisf,
+                deviationsUnsorted: autosensData.deviationsUnsorted,
+                timestamp: autosensData.timestamp
+            )
+        } else {
+            sensitivityRatio = calculateSensitivityRatio(
+                profile: profile,
+                autosens: autosensData,
+                targetGlucose: profile.targetBg ?? 120,
+                temptargetSet: profile.temptargetSet ?? false
+            )
+        }
+
+        let basal: Decimal
+        if let dynamicIsfResult = dynamicIsfResult, profile.tddAdjBasal {
+            basal = computeAdjustedBasal(
+                currentBasalRate: profile.currentBasal ?? profile.basalFor(time: currentTime),
+                sensitivityRatio: dynamicIsfResult.tddRatio
+            )
+        } else {
+            basal = computeAdjustedBasal(
+                currentBasalRate: profile.currentBasal ?? profile.basalFor(time: currentTime),
+                sensitivityRatio: sensitivityRatio
+            )
+        }
         let sensitivity = computeAdjustedSensitivity(
             sensitivity: profile.sens ?? profile.sensitivityFor(time: currentTime),
             sensitivityRatio: sensitivityRatio

+ 123 - 0
Trio/Sources/APS/OpenAPSSwift/DynamicISF.swift

@@ -0,0 +1,123 @@
+import Foundation
+
+/// Represents the successful output of a dynamic ISF calculation.
+struct DynamicISFResult {
+    /// The final sensitivity ratio, after all calculations and clamping.
+    let ratio: Decimal
+    /// The ratio of 24h TDD to the 14-day average TDD, clamped by autosens limits.
+    let tddRatio: Decimal
+    /// The calculated insulin factor (120 - peak time), used in the logarithmic formula.
+    let insulinFactor: Decimal
+}
+
+enum DynamicISF {
+    /// Calculates the dynamic ISF ratio and related values.
+    ///
+    /// This function ports the core logic from `determine-basal.js` for dynamic ISF.
+    /// - Parameters:
+    ///   - profile: The user's profile, containing settings like autosens limits and insulin curve type.
+    ///   - preferences: The user's preferences, containing feature flags like `useNewFormula` and `sigmoid`.
+    ///   - currentGlucose: The most recent glucose reading.
+    ///   - tdd: The total daily dose of insulin, used as a key input for the logarithmic formula.
+    ///   - profileTarget: The effective, override-adjusted blood glucose target. Used in the sigmoid formula.
+    ///   - sensitivity: The effective, override-adjusted insulin sensitivity (ISF). Used in the logarithmic formula.
+    ///   - trioCustomOrefVariables: Custom variables containing TDD averages needed for the TDD ratio calculation.
+    /// - Returns: A `DynamicISFResult` struct on success, or `nil` if the feature is disabled or preconditions are not met.
+    static func calculate(
+        profile: Profile,
+        preferences: Preferences,
+        currentGlucose: Decimal,
+        trioCustomOrefVariables: TrioCustomOrefVariables
+    ) -> DynamicISFResult? {
+        let tdd: Decimal
+        if profile.weightPercentage < 1, trioCustomOrefVariables.weightedAverage > 1 {
+            tdd = trioCustomOrefVariables.weightedAverage
+        } else {
+            tdd = trioCustomOrefVariables.currentTDD
+        }
+
+        guard preferences.useNewFormula, tdd > 0, var sensitivity = profile.sens, var profileTarget = profile.minBg else {
+            return nil
+        }
+
+        if trioCustomOrefVariables.useOverride {
+            let overrideFactor = trioCustomOrefVariables.overridePercentage / 100
+            if trioCustomOrefVariables.isfAndCr || trioCustomOrefVariables.isf {
+                sensitivity = sensitivity / overrideFactor
+            }
+        }
+
+        let overrideTarget = trioCustomOrefVariables.overrideTarget
+        if overrideTarget != 0, overrideTarget != 6, trioCustomOrefVariables
+            .useOverride, !(profile.temptargetSet ?? false)
+        {
+            profileTarget = overrideTarget
+        }
+
+        let minLimit = min(profile.autosensMin, profile.autosensMax)
+        let maxLimit = max(profile.autosensMin, profile.autosensMax)
+
+        // If the limits are invalid, disable dynamicISF
+        guard maxLimit > minLimit, maxLimit >= 1, minLimit <= 1 else {
+            return nil
+        }
+
+        let bg = currentGlucose
+
+        var tdd24h_14d_Ratio: Decimal
+        if trioCustomOrefVariables.average_total_data > 0 {
+            tdd24h_14d_Ratio = trioCustomOrefVariables.weightedAverage / trioCustomOrefVariables.average_total_data
+        } else {
+            tdd24h_14d_Ratio = 1
+        }
+
+        let clampedTddRatio = tdd24h_14d_Ratio.clamp(lowerBound: minLimit, upperBound: maxLimit).rounded(scale: 2)
+
+        let insulinFactor: Decimal
+        if preferences.useCustomPeakTime {
+            insulinFactor = 120 - profile.insulinPeakTime
+        } else {
+            switch profile.curve {
+            case .rapidActing: insulinFactor = 120 - 65
+            case .ultraRapid: insulinFactor = 120 - 50
+            default: insulinFactor = 120 - 65
+            }
+        }
+
+        var newRatio: Decimal
+        if preferences.sigmoid {
+            let autosensInterval = maxLimit - minLimit
+            let bgDev = (bg - profileTarget) * 0.0555
+            let tddFactor = clampedTddRatio
+            var maxMinusOne = maxLimit - 1
+            // BUG: Note this fudge factor is to avoid a divide by zero but produces
+            // unintuitive (and incorrect) results. See the unit tests for an example
+            if maxLimit == 1 { maxMinusOne = maxLimit + 0.01 - 1 }
+            let fixOffset = Decimal.log10(1 / maxMinusOne - minLimit / maxMinusOne) / Decimal(Foundation.log10(M_E))
+            let exponent = bgDev * preferences.adjustmentFactorSigmoid * tddFactor + fixOffset
+            newRatio = autosensInterval / (1 + Decimal.exp(-exponent)) + minLimit
+        } else {
+            newRatio = sensitivity * preferences.adjustmentFactor * tdd * (Decimal.log((bg / insulinFactor) + 1) / 1800)
+        }
+
+        return DynamicISFResult(
+            ratio: newRatio.clamp(lowerBound: minLimit, upperBound: maxLimit),
+            tddRatio: clampedTddRatio,
+            insulinFactor: insulinFactor
+        )
+    }
+}
+
+extension Decimal {
+    static func exp(_ x: Decimal) -> Decimal {
+        Decimal(Foundation.exp(Double(x)))
+    }
+
+    static func log10(_ x: Decimal) -> Decimal {
+        Decimal(Foundation.log10(Double(x)))
+    }
+
+    static func log(_ x: Decimal) -> Decimal {
+        Decimal(Foundation.log(Double(x)))
+    }
+}

+ 2 - 0
Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -96,12 +96,14 @@ struct OpenAPSSwift {
 
             let rawDetermination = try DeterminationGenerator.generate(
                 profile: profile,
+                preferences: preferences,
                 currentTemp: currentTemp,
                 iobData: iob,
                 mealData: mealData,
                 autosensData: autosensData,
                 reservoirData: reservoir ?? 100,
                 glucose: glucose,
+                trioCustomOrefVariables: trioCustomOrefVariables,
                 currentTime: clock
             )
 

+ 196 - 0
TrioTests/OpenAPSSwiftTests/DynamicISFTests.swift

@@ -0,0 +1,196 @@
+import Foundation
+import Testing
+@testable import Trio
+
+/// The corresponding Javascript tests to confirm these numbers are here:
+///  - https://github.com/kingst/trio-oref/blob/dev-fixes-for-swift-comparison/tests/dynamic-isf.test.js
+@Suite("DynamicISF Calculation Tests") struct DynamicISFTests {
+    // Helper to create common dependencies for tests
+    private func createDependencies(
+        useNewFormula: Bool = true,
+        tdd: Decimal = 30,
+        avgTDD: Decimal = 30,
+        sensitivity: Decimal = 50,
+        minAutosens: Decimal = 0.7,
+        maxAutosens: Decimal = 1.2,
+        useCustomPeakTime: Bool = false,
+        insulinCurve: InsulinCurve = .rapidActing
+    ) -> (Profile, Preferences, Decimal, TrioCustomOrefVariables) {
+        var preferences = Preferences()
+        preferences.useNewFormula = useNewFormula
+        preferences.sigmoid = false
+        preferences.adjustmentFactor = 0.8
+        preferences.adjustmentFactorSigmoid = 0.5
+        preferences.useCustomPeakTime = useCustomPeakTime
+        preferences.curve = insulinCurve
+
+        var profile = Profile()
+        profile.sens = sensitivity
+        profile.autosensMin = minAutosens
+        profile.autosensMax = maxAutosens
+        profile.minBg = 100
+        profile.curve = insulinCurve
+        profile.useCustomPeakTime = useCustomPeakTime
+        profile.insulinPeakTime = 60 // For custom peak time test
+
+        let glucose = Decimal(120)
+
+        let trioVars = TrioCustomOrefVariables(
+            average_total_data: avgTDD,
+            weightedAverage: tdd,
+            currentTDD: tdd,
+            past2hoursAverage: 0,
+            date: Date(),
+            overridePercentage: 100,
+            useOverride: false,
+            duration: 0,
+            unlimited: true,
+            overrideTarget: 0,
+            smbIsOff: false,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: true,
+            cr: true,
+            smbIsScheduledOff: false,
+            start: 0,
+            end: 0,
+            smbMinutes: 30,
+            uamMinutes: 30,
+            shouldProtectDueToHIGH: false
+        )
+
+        return (profile, preferences, glucose, trioVars)
+    }
+
+    @Test("Returns nil if dISF is disabled") func disabledReturnsNil() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies(useNewFormula: false)
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )
+
+        #expect(result == nil)
+    }
+
+    @Test("Returns nil for invalid autosens limits") func invalidLimitsReturnsNil() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies(minAutosens: 1.2, maxAutosens: 1.2)
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )
+        #expect(result == nil)
+    }
+
+    @Test("Logarithmic formula calculates all result fields correctly") func logarithmicFormula() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies()
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.insulinFactor == 55)
+        #expect(result.tddRatio.rounded(toPlaces: 2) == 1)
+        #expect(result.ratio.rounded(toPlaces: 2) == 0.77)
+    }
+
+    @Test("Sigmoid formula calculates all result fields correctly") func sigmoidFormula() throws {
+        var (profile, preferences, glucose, trioVars) = createDependencies()
+        preferences.sigmoid = true
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.insulinFactor == 55)
+        #expect(result.tddRatio == 1.0)
+        #expect(result.ratio.rounded(scale: 2) == Decimal(string: "1.06"))
+    }
+
+    @Test("Uses default TDD ratio when average TDD is zero") func defaultTddRatio() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies(avgTDD: 0)
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.tddRatio == 1.0)
+        #expect(result.ratio.rounded(toPlaces: 2) == 0.77)
+    }
+
+    @Test("Uses custom peak time when enabled") func customPeakTime() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies(useCustomPeakTime: true)
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        // 120 - profile.insulinPeakTime (60) = 60
+        #expect(result.insulinFactor == 60)
+    }
+
+    @Test("Uses ultra-rapid insulin factor correctly") func ultraRapidInsulin() throws {
+        let (profile, preferences, glucose, trioVars) = createDependencies(insulinCurve: .ultraRapid)
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.ratio.rounded(scale: 2) == Decimal(string: "0.7"))
+    }
+
+    @Test("Sigmoid handles maxLimit of 1 correctly") func sigmoidMaxLimitOne() throws {
+        var (profile, preferences, glucose, trioVars) = createDependencies(maxAutosens: 1.0)
+        preferences.sigmoid = true
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.insulinFactor == 55)
+        #expect(result.tddRatio == 1.0)
+        // BUG: you would expect this to be 1 but because of the fudge factor the
+        // JS code uses to avoid divide by 0 it 0.99
+        #expect(result.ratio.rounded(scale: 2) == Decimal(string: "0.99"))
+    }
+
+    @Test("Override with sigmoid adjusts target and ratio correctly") func overrideWithSigmoid() throws {
+        var (profile, preferences, glucose, trioVars) = createDependencies()
+        preferences.sigmoid = true
+        trioVars.useOverride = true
+        trioVars.overrideTarget = 80
+        trioVars.overridePercentage = 80
+
+        let result = DynamicISF.calculate(
+            profile: profile,
+            preferences: preferences,
+            currentGlucose: glucose,
+            trioCustomOrefVariables: trioVars
+        )!
+
+        #expect(result.ratio.rounded(toPlaces: 2) == Decimal(string: "1.11"))
+    }
+}