Explorar el Código

Merge pull request #569 from nightscout/debugging-determine-basal-forcasts

Add DynamicISF support to determine basal forecasts + fix a bunch of inconsistencies
Deniz Cengiz hace 9 meses
padre
commit
9786d8b58a

+ 27 - 16
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasal+Helpers.swift

@@ -58,10 +58,8 @@ extension DeterminationGenerator {
             guard entry.glucose > 38 else { continue }
 
             let minutesAgo = (mostRecentGlucoseDate.timeIntervalSince(entry.date) / 60).rounded()
-            guard minutesAgo != 0 else { continue }
             // compute mg/dL per 5 m as a Decimal:
             let change = Decimal(mostRecentGlucoseReading - entry.glucose)
-            let avgDelta = (change / Decimal(minutesAgo)) * Decimal(5)
 
             // very-recent (<2.5 m) smooths "now"
             if minutesAgo > -2, minutesAgo <= 2.5 {
@@ -75,6 +73,7 @@ extension DeterminationGenerator {
             }
             // short window (~5–15 m)
             else if minutesAgo > 2.5, minutesAgo <= 17.5 {
+                let avgDelta = (change / Decimal(minutesAgo)) * Decimal(5)
                 shortDeltas.append(avgDelta)
                 if minutesAgo < 7.5 {
                     lastDeltas.append(avgDelta)
@@ -82,6 +81,7 @@ extension DeterminationGenerator {
             }
             // long window (~20–40 m)
             else if minutesAgo > 17.5, minutesAgo < 42.5 {
+                let avgDelta = (change / Decimal(minutesAgo)) * Decimal(5)
                 longDeltas.append(avgDelta)
             }
         }
@@ -231,14 +231,17 @@ extension DeterminationGenerator {
     }
 
     static func calculateSensitivityRatio(
+        currentGlucose: Decimal,
         profile: Profile,
         autosens: Autosens?,
         targetGlucose: Decimal,
-        temptargetSet: Bool
-    ) -> Decimal {
+        temptargetSet: Bool,
+        dynamicIsfResult: DynamicISFResult?
+    ) -> (Decimal, Bool) {
         let normalTarget: Decimal = 100
         let halfBasalTarget = profile.halfBasalExerciseTarget
         var ratio: Decimal = 1
+        var updateAutosensRatio = false
 
         // High temp target raises sensitivity or low temp lowers it
         if (profile.highTemptargetRaisesSensitivity && temptargetSet && targetGlucose > normalTarget) ||
@@ -250,16 +253,27 @@ extension DeterminationGenerator {
             } else {
                 ratio = c / (c + targetGlucose - normalTarget)
             }
-            ratio = min(ratio, profile.autosensMax)
-            // You can round here if needed: ratio = ratio.rounded(2)
-            return ratio
+            ratio = min(ratio, profile.autosensMax).jsRounded(scale: 2)
+        } else if let autosens = autosens {
+            // Use autosens if present
+            ratio = autosens.ratio
         }
-        // Use autosens if present
+
         if let autosens = autosens {
-            return autosens.ratio
+            // Increase the dynamic ratio when using a low temp target
+            if profile.temptargetSet == true, targetGlucose < normalTarget, let dynamicIsfResult = dynamicIsfResult,
+               currentGlucose >= targetGlucose
+            {
+                if ratio < dynamicIsfResult.ratio {
+                    ratio = dynamicIsfResult.ratio * (normalTarget / targetGlucose)
+                    // Use autosesns.max limit
+                    ratio = min(ratio, profile.autosensMax).jsRounded(scale: 2)
+                    updateAutosensRatio = true
+                }
+            }
         }
-        // Otherwise default to 1.0 (no adjustment)
-        return 1.0
+
+        return (ratio, updateAutosensRatio)
     }
 
     static func computeAdjustedBasal(currentBasalRate: Decimal, sensitivityRatio: Decimal) -> Decimal {
@@ -363,12 +377,9 @@ extension DeterminationGenerator {
         // FIXME: this is assuming 5min steps...
         // Activity is U/hr
         if withZeroTemp {
-            return iobDataSeries.map { iobWithZeroTemp in
-                -iobWithZeroTemp.activity * sensitivity * 5 }
+            return iobDataSeries.map { -$0.iobWithZeroTemp.activity * sensitivity * 5 }
         } else {
-            return iobDataSeries.map { iob in
-                -iob.activity * sensitivity * 5
-            }
+            return iobDataSeries.map { -$0.activity * sensitivity * 5 }
         }
     }
 }

+ 22 - 6
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -63,12 +63,22 @@ enum DeterminationGenerator {
                 timestamp: autosensData.timestamp
             )
         }
-        let sensitivityRatio = calculateSensitivityRatio(
+        let (sensitivityRatio, updateAutosensRatio) = calculateSensitivityRatio(
+            currentGlucose: currentGlucose,
             profile: profile,
             autosens: autosensData,
             targetGlucose: profile.profileTarget(trioCustomOrefVariables: trioCustomOrefVariables) ?? 120,
-            temptargetSet: profile.temptargetSet ?? false
+            temptargetSet: profile.temptargetSet ?? false,
+            dynamicIsfResult: dynamicIsfResult
         )
+        if updateAutosensRatio {
+            autosensData = Autosens(
+                ratio: sensitivityRatio,
+                newisf: autosensData.newisf,
+                deviationsUnsorted: autosensData.deviationsUnsorted,
+                timestamp: autosensData.timestamp
+            )
+        }
 
         let basal: Decimal
         if let dynamicIsfResult = dynamicIsfResult, profile.tddAdjBasal {
@@ -201,11 +211,17 @@ enum DeterminationGenerator {
 
         let forecastResult = ForecastGenerator.generate(
             glucose: currentGlucose,
+            glucoseStatus: glucoseStatus,
+            currentGlucoseImpact: currentGlucoseImpact,
             glucoseImpactSeries: glucoseImpactSeries,
             glucoseImpactSeriesWithZeroTemp: glucoseImpactSeriesWithZeroTemp,
             iobData: iobData,
             mealData: mealData,
             profile: profile,
+            preferences: preferences,
+            trioCustomOrefVariables: trioCustomOrefVariables,
+            dynamicIsfResult: dynamicIsfResult,
+            targetGlucose: adjustedGlucoseTargets.targetGlucose,
             adjustedSensitivity: sensitivity,
             sensitivityRatio: sensitivityRatio,
             naiveEventualGlucose: naiveEventualGlucose,
@@ -236,10 +252,10 @@ enum DeterminationGenerator {
             iob: iobData.first?.iob,
             cob: mealData.mealCOB,
             predictions: Predictions(
-                iob: forecastResult.iob.map { Int($0) },
-                zt: forecastResult.zt.map { Int($0) },
-                cob: forecastResult.cob.map { Int($0) },
-                uam: forecastResult.uam.map { Int($0) }
+                iob: forecastResult.iob.map { Int($0.jsRounded()) },
+                zt: forecastResult.zt.map { Int($0.jsRounded()) },
+                cob: forecastResult.cob?.map { Int($0.jsRounded()) },
+                uam: forecastResult.uam?.map { Int($0.jsRounded()) }
             ),
             deliverAt: currentTime,
             carbsReq: nil,

+ 24 - 6
Trio/Sources/APS/OpenAPSSwift/DynamicISF.swift

@@ -29,12 +29,7 @@ enum DynamicISF {
         currentGlucose: Decimal,
         trioCustomOrefVariables: TrioCustomOrefVariables
     ) -> DynamicISFResult? {
-        let tdd: Decimal
-        if profile.weightPercentage < 1, trioCustomOrefVariables.weightedAverage > 1 {
-            tdd = trioCustomOrefVariables.weightedAverage
-        } else {
-            tdd = trioCustomOrefVariables.currentTDD
-        }
+        let tdd = trioCustomOrefVariables.tdd(profile: profile)
 
         guard preferences.useNewFormula, tdd > 0, var sensitivity = profile.sens,
               let profileTarget = profile.profileTarget(trioCustomOrefVariables: trioCustomOrefVariables)
@@ -111,3 +106,26 @@ extension Decimal {
         Decimal(Foundation.log(Double(x)))
     }
 }
+
+extension TrioCustomOrefVariables {
+    func tdd(profile: Profile) -> Decimal {
+        if profile.weightPercentage < 1, weightedAverage > 1 {
+            return weightedAverage
+        } else {
+            return currentTDD
+        }
+    }
+}
+
+enum DynamicIsfState {
+    case off
+    case sigmoid
+    case logrithmic
+}
+
+extension Preferences {
+    func dynamicIsfState() -> DynamicIsfState {
+        guard useNewFormula else { return .off }
+        return sigmoid ? .sigmoid : .logrithmic
+    }
+}

+ 23 - 18
Trio/Sources/APS/OpenAPSSwift/Forecasts/CarbImpactParams.swift

@@ -1,38 +1,43 @@
 import Foundation
 
 struct CarbImpactParams {
-    let carbSensivityFactor: Decimal
     let cappedCarbImpact: Decimal
-    let remainingCarbAbsorptionTime: Decimal
+    let carbImpactDuration: Decimal
     let maxAbsorptionIntervals: Int
     let triangleIntervals: Int
     let remainingCarbImpactPeak: Decimal
+    let remainingCarbAbsorptionTime: Decimal
 
     static func calculate(
-        adjustedSensitivity: Decimal,
+        carbSensitivityFactor: Decimal,
         profile: Profile,
         mealData: ComputedCarbs,
         carbImpact: Decimal,
         sensitivityRatio: Decimal,
         currentTime: Date
     ) -> CarbImpactParams {
-        let carbSensivityFactor = adjustedSensitivity / (profile.carbRatio ?? profile.carbRatioFor(time: currentTime))
-
-        // Initial carb impact in mg/dL per 5m
-        let initialCarbImpact = carbImpact * carbSensivityFactor
         let maxCarbAbsorptionRate: Decimal = 30 // g/h
-        let maxCarbImpact = (maxCarbAbsorptionRate * carbSensivityFactor * 5 / 60).rounded(toPlaces: 1)
-        let cappedCarbImpact = min(initialCarbImpact, maxCarbImpact)
+        let maxCarbImpact = (maxCarbAbsorptionRate * carbSensitivityFactor * 5 / 60).rounded(toPlaces: 1)
+        let cappedCarbImpact = min(carbImpact, maxCarbImpact)
 
-        let computedRemainingCarbAbsorptionTime = ForecastGenerator.calculateRemainingCarbAbsorptionTime(
+        let remainingCarbAbsorptionTime = ForecastGenerator.calculateRemainingCarbAbsorptionTime(
             sensitivityRatio: sensitivityRatio,
             maxMealAbsorptionTime: profile.maxMealAbsorptionTime,
             mealCOB: mealData.mealCOB,
-            lastCarbTime: Date(timeIntervalSince1970: mealData.lastCarbTime),
+            lastCarbTime: Date(timeIntervalSince1970: mealData.lastCarbTime / 1000),
             currentTime: currentTime
         )
-        // Clamp remainingTime for more robustness
-        let remainingCarbAbsorptionTime = min(computedRemainingCarbAbsorptionTime, profile.maxMealAbsorptionTime)
+
+        let carbImpactDuration: Decimal
+        if carbImpact == 0 {
+            carbImpactDuration = 0
+        } else {
+            // cid = Math.min(remainingCATime*60/5/2,Math.max(0, meal_data.mealCOB * csf / ci ));
+            carbImpactDuration = min(
+                remainingCarbAbsorptionTime * 60 / 5 / 2,
+                max(0, mealData.mealCOB * carbSensitivityFactor / carbImpact)
+            )
+        }
 
         // Convert remainingCarbAbsorptionTime (hours) to intervals (each 5m):
         let dynamicAbsorptionIntervals = Int((remainingCarbAbsorptionTime * 60) / 5)
@@ -44,7 +49,7 @@ struct CarbImpactParams {
         // Total CI (mg/dL)
         let totalCarbImpact = max(0, cappedCarbImpact / 5 * 60 * remainingCarbAbsorptionTime / 2)
         // Total carbs absorbed from CI (g)
-        let totalCarbsAbsorbed: Decimal = totalCarbImpact / carbSensivityFactor
+        let totalCarbsAbsorbed: Decimal = totalCarbImpact / carbSensitivityFactor
 
         // Remaining carbs cap/fraction logic
         let remainingCarbsCap = min(90, profile.remainingCarbsCap)
@@ -58,18 +63,18 @@ struct CarbImpactParams {
         // Peak impact (mg/dL per 5m) of the *remaining* carbs
         let remainingCarbImpactPeak: Decimal
         if remainingCarbAbsorptionTime > 0 {
-            remainingCarbImpactPeak = (remainingCarbs * carbSensivityFactor * 5 / 60) / (remainingCarbAbsorptionTime / 2)
+            remainingCarbImpactPeak = (remainingCarbs * carbSensitivityFactor * 5 / 60) / (remainingCarbAbsorptionTime / 2)
         } else {
             remainingCarbImpactPeak = 0
         }
 
         return CarbImpactParams(
-            carbSensivityFactor: carbSensivityFactor,
             cappedCarbImpact: cappedCarbImpact,
-            remainingCarbAbsorptionTime: remainingCarbAbsorptionTime,
+            carbImpactDuration: carbImpactDuration,
             maxAbsorptionIntervals: maxAbsorptionIntervals,
             triangleIntervals: triangleIntervals,
-            remainingCarbImpactPeak: remainingCarbImpactPeak
+            remainingCarbImpactPeak: remainingCarbImpactPeak,
+            remainingCarbAbsorptionTime: remainingCarbAbsorptionTime
         )
     }
 }

+ 126 - 71
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator+Forecasts.swift

@@ -6,141 +6,196 @@ extension ForecastGenerator {
     static func forecastIOB(
         startingGlucose: Decimal,
         glucoseImpactSeries: [Decimal],
-        deviation: Decimal,
+        iobData: [IobResult],
+        carbImpact: Decimal,
+        dynamicIsfState: DynamicIsfState,
+        insulinFactor: Decimal?,
+        tdd: Decimal,
+        adjustmentFactorLogrithmic: Decimal
     ) -> [Decimal] {
         var result = [startingGlucose]
-        for (count, glucoseImpact) in glucoseImpactSeries.enumerated() {
-            let forecastedDeviation = deviation * (1 - min(1, Decimal(count) / (60 / 5)))
-            let next = result.last! + glucoseImpact + forecastedDeviation
-            result.append(next.clamp(lowerBound: 39, upperBound: 401))
+        for (glucoseImpact, iob) in zip(glucoseImpactSeries, iobData) {
+            let forecastedDeviation = carbImpact * (1 - min(1, Decimal(result.count) / (60 / 5)))
+            let lastForecast = result.last!
+            let next: Decimal
+            if let insulinFactor = insulinFactor, dynamicIsfState == .logrithmic {
+                let adjustedGlucoseImpact = adjustedGlucoseImpactForLogrithmicDynamicIsf(
+                    lastForecast: lastForecast,
+                    insulinFactor: insulinFactor,
+                    tdd: tdd,
+                    adjustmentFactorLogrithmic: adjustmentFactorLogrithmic,
+                    iobActivity: iob.activity
+                )
+                next = lastForecast + adjustedGlucoseImpact + forecastedDeviation
+            } else {
+                next = lastForecast + glucoseImpact.jsRounded(scale: 2) + forecastedDeviation
+            }
+            if result.count < 48 { result.append(next) }
         }
-        return ForecastGenerator.trimFlatTails(result, lookback: 90 / 5)
+        let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 401) }
+        return ForecastGenerator.trimFlatTails(clampedResult, lookback: 13)
     }
 
     static func forecastCOB(
         startingGlucose: Decimal,
         glucoseImpactSeries: [Decimal],
-        mealData: ComputedCarbs,
-        profile: Profile,
         carbImpact: Decimal,
-        deviation: Decimal,
-        adjustedSensitivity: Decimal,
-        sensitivityRatio: Decimal,
-        currentTime: Date
+        carbImpactParams: CarbImpactParams
     ) -> [Decimal] {
         // Start with the current BG
         var result = [startingGlucose]
 
-        let carbImpactParams = CarbImpactParams.calculate(
-            adjustedSensitivity: adjustedSensitivity,
-            profile: profile,
-            mealData: mealData,
-            carbImpact: carbImpact,
-            sensitivityRatio: sensitivityRatio,
-            currentTime: currentTime
-        )
-
-        // How many intervals we spread the initial CI decay over?
-        // We use twice the absorption window (so that by 2x the window, CI has decayed to zero).
-        let decayIntervals = max(carbImpactParams.maxAbsorptionIntervals * 2, 1)
-
-        // Helper: negative deviation only (never positive)
-        let forecastedDeviation = min(0, deviation)
-
         // Build forecast out to glucoseImpactSeries.count (usually 48)
-        for seriesCount in 1 ..< glucoseImpactSeries.count {
-            let insulinEffect = glucoseImpactSeries[seriesCount]
+        for glucoseImpact in glucoseImpactSeries {
+            let forecastedDeviation = carbImpact * (1 - min(1, Decimal(result.count) / (60 / 5)))
 
             // Linearly decay the *observed* carb impact from initialCI → 0
-            let decayFactor = max(0, 1 - seriesCount / decayIntervals)
-            let forecastedCarbImpact = carbImpactParams.cappedCarbImpact * Decimal(decayFactor)
+            // var predCI = Math.max(0, Math.max(0,ci) * ( 1 - COBpredBGs.length/Math.max(cid*2,1) ) );
+            let decayFactor = max(0, 1 - Decimal(result.count) / max(carbImpactParams.carbImpactDuration * 2, Decimal(1)))
+            let forecastedCarbImpact = max(0, max(0, carbImpact) * decayFactor)
 
             // Add a simple triangle bump for remaining carbs:
             // – ramp up linearly to peak over the first half of the window,
             // – ramp down linearly over the second half,
             // – zero afterwards.
-            let triangle: Decimal
-            if carbImpactParams.triangleIntervals > 0, seriesCount <= carbImpactParams.triangleIntervals {
-                // FIXME: integer division here might be slightly off for odd number intervals.
-                // FIXME: For perfect symmetry we could use let halfTriangle = (triangleIntervals + 1) / 2 — Change this?!
-                let halfTriangle = carbImpactParams.triangleIntervals / 2
-                if seriesCount <= halfTriangle {
-                    // Ramp up
-                    triangle = carbImpactParams.remainingCarbImpactPeak * Decimal(seriesCount) / Decimal(halfTriangle)
-                } else {
-                    // Ramp down
-                    triangle = carbImpactParams
-                        .remainingCarbImpactPeak * Decimal(carbImpactParams.triangleIntervals - seriesCount) /
-                        Decimal(halfTriangle)
-                }
-            } else {
-                triangle = 0
-            }
+
+            // var intervals = Math.min( COBpredBGs.length, (remainingCATime*12)-COBpredBGs.length );
+            // var remainingCI = Math.max(0, intervals / (remainingCATime/2*12) * remainingCIpeak );
+            let intervals = min(Decimal(result.count), carbImpactParams.remainingCarbAbsorptionTime * 12 - Decimal(result.count))
+            let triangle = max(
+                0,
+                intervals / (carbImpactParams.remainingCarbAbsorptionTime / 2 * 12) * carbImpactParams.remainingCarbImpactPeak
+            )
 
             let next = result.last!
-                + insulinEffect
-                + forecastedDeviation
+                + glucoseImpact.jsRounded(scale: 2)
+                + min(0, forecastedDeviation)
                 + forecastedCarbImpact
                 + triangle
 
-            result.append(next.clamp(lowerBound: 39, upperBound: 1500))
+            if result.count < 48 { result.append(next) }
         }
 
-        return ForecastGenerator.trimFlatTails(result, lookback: 12)
+        let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 1500) }
+        return ForecastGenerator.trimFlatTails(clampedResult, lookback: 13)
     }
 
     static func forecastUAM(
         startingGlucose: Decimal,
         glucoseImpactSeries: [Decimal],
         mealData: ComputedCarbs,
+        uamCarbImpact: Decimal,
         carbImpact: Decimal,
-        deviation: Decimal
+        iobData: [IobResult],
+        dynamicIsfState: DynamicIsfState,
+        insulinFactor: Decimal?,
+        tdd: Decimal,
+        adjustmentFactorLogrithmic: Decimal
     ) -> [Decimal] {
         var result = [startingGlucose]
 
-        let slopeFromDeviations = mealData.slopeFromMinDeviation
+        let slopeFromDeviations = min(
+            mealData.slopeFromMaxDeviation.jsRounded(scale: 2),
+            -mealData.slopeFromMinDeviation.jsRounded(scale: 2) / 3
+        )
         let ticksInThreeHours: Decimal = 36 // 3 * 60 / 5
 
-        let unannouncedCarbImpact = carbImpact
+        let unannouncedCarbImpact = uamCarbImpact
 
-        for glucoseImpact in 1 ..< glucoseImpactSeries.count {
-            let insulinEffect = glucoseImpactSeries[glucoseImpact]
-            let forecastedDeviaton = min(0, deviation)
+        for (glucoseImpact, iob) in zip(glucoseImpactSeries, iobData) {
+            let forecastedDeviation = carbImpact * (1 - min(1, Decimal(result.count) / (60 / 5)))
 
             // In JS: predUCIslope = max(0, uci + (tick * slopeFromDeviations))
             let forecastedUnannouncedCarbImpactSlope = max(
                 0,
-                unannouncedCarbImpact + Decimal(glucoseImpact) * slopeFromDeviations
+                unannouncedCarbImpact + Decimal(result.count) * slopeFromDeviations
             )
 
             // In JS: predUCImax = max(0, uci * (1 - tick / ticksInThreeHours))
             let maxForecastedUnannouncedCarbImpact = max(
                 0,
-                unannouncedCarbImpact * (1 - Decimal(glucoseImpact) / ticksInThreeHours)
+                unannouncedCarbImpact * (1 - Decimal(result.count) / ticksInThreeHours)
             )
             let forecastedUnannouncedCarbImpact = min(
                 forecastedUnannouncedCarbImpactSlope,
                 maxForecastedUnannouncedCarbImpact
             )
 
-            let next = result.last! + insulinEffect + forecastedDeviaton + forecastedUnannouncedCarbImpact
+            let lastForecast = result.last!
+            let next: Decimal
+            if let insulinFactor = insulinFactor, dynamicIsfState == .logrithmic {
+                let adjustedGlucoseImpact = adjustedGlucoseImpactForLogrithmicDynamicIsf(
+                    lastForecast: lastForecast,
+                    insulinFactor: insulinFactor,
+                    tdd: tdd,
+                    adjustmentFactorLogrithmic: adjustmentFactorLogrithmic,
+                    iobActivity: iob.activity
+                )
+                next = lastForecast + adjustedGlucoseImpact + min(0, forecastedDeviation) + forecastedUnannouncedCarbImpact
+            } else {
+                next = lastForecast + glucoseImpact
+                    .jsRounded(scale: 2) + min(0, forecastedDeviation) + forecastedUnannouncedCarbImpact
+            }
 
-            result.append(next.clamp(lowerBound: 39, upperBound: 401))
+            if result.count < 48 { result.append(next) }
         }
 
-        return ForecastGenerator.trimFlatTails(result, lookback: 12)
+        let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 401) }
+        return ForecastGenerator.trimFlatTails(clampedResult, lookback: 13)
     }
 
     static func forecastZT(
         startingGlucose: Decimal,
         glucoseImpactSeriesWithZeroTemp: [Decimal],
-        deviation: Decimal
+        targetBG: Decimal,
+        iobData: [IobResult],
+        dynamicIsfState: DynamicIsfState,
+        insulinFactor: Decimal?,
+        tdd: Decimal,
+        adjustmentFactorLogrithmic: Decimal
     ) -> [Decimal] {
-        // essentially insulin effect only, but with zero-temp ISF if needed
-        Self.forecastIOB(
-            startingGlucose: startingGlucose,
-            glucoseImpactSeries: glucoseImpactSeriesWithZeroTemp,
-            deviation: deviation
-        )
+        var result = [startingGlucose]
+        // Potential bug: ZT doesn't use forecastedDeviation like IoB does
+        for (glucoseImpact, iob) in zip(glucoseImpactSeriesWithZeroTemp, iobData) {
+            let lastForecast = result.last!
+            let next: Decimal
+            if let insulinFactor = insulinFactor, dynamicIsfState == .logrithmic {
+                let adjustedGlucoseImpact = adjustedGlucoseImpactForLogrithmicDynamicIsf(
+                    lastForecast: lastForecast,
+                    insulinFactor: insulinFactor,
+                    tdd: tdd,
+                    adjustmentFactorLogrithmic: adjustmentFactorLogrithmic,
+                    iobActivity: iob.iobWithZeroTemp.activity
+                )
+                next = lastForecast + adjustedGlucoseImpact
+            } else {
+                next = lastForecast + glucoseImpact.jsRounded(scale: 2)
+            }
+
+            if result.count < 48 { result.append(next) }
+        }
+        let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 401) }
+        return ForecastGenerator.trimZTTails(series: clampedResult, targetBG: targetBG)
+    }
+
+    static func adjustedGlucoseImpactForLogrithmicDynamicIsf(
+        lastForecast: Decimal,
+        insulinFactor: Decimal,
+        tdd: Decimal,
+        adjustmentFactorLogrithmic: Decimal,
+        iobActivity: Decimal
+    ) -> Decimal {
+        // The JS code is extremely difficult to understand, so I tried
+        // to break down the components with the JS snippet listed above
+
+        // (Math.max( ZTpredBGs[ZTpredBGs.length-1],39) / insulinFactor )
+        let adjustedLastForecast = max(lastForecast, 39) / insulinFactor
+        // ( tdd * adjustmentFactor * (Math.log(adjustedLastForecast + 1 ) ) )
+        let adjustedTdd = tdd * adjustmentFactorLogrithmic * Decimal.log(adjustedLastForecast + 1)
+        // For ZT:
+        // round(( -iobTick.iobWithZeroTemp.activity * (1800 / adjustedTdd) * 5 ),2)
+        // For UAM and IOB:
+        // round(( -iobTick.activity * (1800 / adjustedTdd) * 5 ),2)
+        return (-iobActivity * (1800 / adjustedTdd) * 5).jsRounded(scale: 2)
     }
 }

+ 118 - 44
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift

@@ -4,11 +4,17 @@ import Foundation
 enum ForecastGenerator {
     public static func generate(
         glucose: Decimal,
+        glucoseStatus: GlucoseStatus,
+        currentGlucoseImpact: Decimal,
         glucoseImpactSeries: [Decimal],
         glucoseImpactSeriesWithZeroTemp: [Decimal],
-        iobData _: [IobResult],
+        iobData: [IobResult],
         mealData: ComputedCarbs,
         profile: Profile,
+        preferences: Preferences,
+        trioCustomOrefVariables: TrioCustomOrefVariables,
+        dynamicIsfResult: DynamicISFResult?,
+        targetGlucose: Decimal,
         adjustedSensitivity: Decimal,
         sensitivityRatio: Decimal,
         naiveEventualGlucose _: Decimal,
@@ -16,42 +22,78 @@ enum ForecastGenerator {
         threshold: Decimal,
         currentTime: Date
     ) -> ForecastResult {
-        let currentDeviation = mealData.currentDeviation ?? 0
-        let carbImpact = currentDeviation * (profile.carbRatio ?? profile.carbRatioFor(time: currentTime)) /
-            (profile.sens ?? profile.sensitivityFor(time: currentTime))
-        let deviation = currentDeviation
+        let profileCarbRatio = profile.carbRatio ?? profile.carbRatioFor(time: currentTime)
+        let adjustedCarbRatio: Decimal
+        if trioCustomOrefVariables.useOverride, trioCustomOrefVariables.cr || trioCustomOrefVariables.isfAndCr {
+            let overrideFactor = trioCustomOrefVariables.overridePercentage / 100
+            adjustedCarbRatio = profileCarbRatio / overrideFactor
+        } else {
+            adjustedCarbRatio = profileCarbRatio
+        }
+
+        let carbSensitivityFactor = adjustedSensitivity / adjustedCarbRatio
+        let minDelta = min(glucoseStatus.delta, glucoseStatus.shortAvgDelta)
+        // this carbImpact is `ci` in JS
+        var carbImpact = (minDelta - currentGlucoseImpact).jsRounded(scale: 1)
+        let maxCarbAbsorptionRate = Decimal(30)
+        let maxCI = (maxCarbAbsorptionRate * carbSensitivityFactor * Decimal(5) / Decimal(60)).jsRounded(scale: 1)
+        if carbImpact > maxCI {
+            carbImpact = maxCI
+        }
+
+        let carbImpactParams = CarbImpactParams.calculate(
+            carbSensitivityFactor: carbSensitivityFactor,
+            profile: profile,
+            mealData: mealData,
+            carbImpact: carbImpact,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
+        )
+
+        // this is `uci` in JS, it isn't limited by maxCI
+        let uamCarbImpact = (minDelta - currentGlucoseImpact).jsRounded(scale: 1)
 
         // JS oref initializes all xxxPredBGs array with current glucose, we do the same, then generate
-        let iobForecast = [glucose] + forecastIOB(
+        let iobForecast = forecastIOB(
             startingGlucose: glucose,
             glucoseImpactSeries: glucoseImpactSeries,
-            deviation: deviation,
+            iobData: iobData,
+            carbImpact: carbImpact,
+            dynamicIsfState: preferences.dynamicIsfState(),
+            insulinFactor: dynamicIsfResult?.insulinFactor,
+            tdd: trioCustomOrefVariables.tdd(profile: profile),
+            adjustmentFactorLogrithmic: profile.adjustmentFactor
         )
 
-        let cobForecast = [glucose] + forecastCOB(
+        let cobForecast = forecastCOB(
             startingGlucose: glucose,
             glucoseImpactSeries: glucoseImpactSeries,
-            mealData: mealData,
-            profile: profile,
             carbImpact: carbImpact,
-            deviation: deviation,
-            adjustedSensitivity: adjustedSensitivity,
-            sensitivityRatio: sensitivityRatio,
-            currentTime: currentTime
+            carbImpactParams: carbImpactParams
         )
 
-        let uamForecast = [glucose] + forecastUAM(
+        let uamForecast = forecastUAM(
             startingGlucose: glucose,
             glucoseImpactSeries: glucoseImpactSeries,
             mealData: mealData,
+            uamCarbImpact: uamCarbImpact,
             carbImpact: carbImpact,
-            deviation: deviation
+            iobData: iobData,
+            dynamicIsfState: preferences.dynamicIsfState(),
+            insulinFactor: dynamicIsfResult?.insulinFactor,
+            tdd: trioCustomOrefVariables.tdd(profile: profile),
+            adjustmentFactorLogrithmic: profile.adjustmentFactor
         )
 
-        let ztForecast = [glucose] + forecastZT(
+        let ztForecast = forecastZT(
             startingGlucose: glucose,
             glucoseImpactSeriesWithZeroTemp: glucoseImpactSeriesWithZeroTemp,
-            deviation: deviation
+            targetBG: targetGlucose,
+            iobData: iobData,
+            dynamicIsfState: preferences.dynamicIsfState(),
+            insulinFactor: dynamicIsfResult?.insulinFactor,
+            tdd: trioCustomOrefVariables.tdd(profile: profile),
+            adjustmentFactorLogrithmic: profile.adjustmentFactor
         )
 
         let computedForecastSelection = Self.computeForecastSelection(
@@ -62,26 +104,12 @@ enum ForecastGenerator {
             currentGlucose: glucose
         )
 
-        let carbImpactParams = CarbImpactParams.calculate(
-            adjustedSensitivity: adjustedSensitivity,
-            profile: profile,
-            mealData: mealData,
-            carbImpact: carbImpact,
-            sensitivityRatio: sensitivityRatio,
-            currentTime: currentTime
-        )
-
-        let carbImpactDuration = carbImpact > 0 ? min(
-            carbImpactParams.remainingCarbAbsorptionTime * 60 / 5 / 2,
-            max(0, mealData.mealCOB * carbImpactParams.carbSensivityFactor / carbImpact)
-        ) : 0
-
         let blendedForecasts = Self.blendForecasts(
             selectionResult: computedForecastSelection,
             carbs: mealData.carbs,
             mealCOB: mealData.mealCOB,
             enableUAM: profile.enableUAM,
-            carbImpactDuration: carbImpactDuration,
+            carbImpactDuration: carbImpactParams.carbImpactDuration,
             remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
             fractionCarbsLeft: mealData.carbs > 0 ? mealData.mealCOB / mealData.carbs : 0,
             threshold: threshold,
@@ -89,10 +117,31 @@ enum ForecastGenerator {
             currentGlucose: glucose
         )
 
+        // FIXME: Revisit this after I get predBG working
+        /*
+         var eventualGlucose = eventualGlucose
+         if let finalCOBGlucose = cobForecast.last {
+             eventualGlucose = max(eventualGlucose, finalCOBGlucose)
+         }
+         if let finalUAMGlucose = uamForecast.last {
+             eventualGlucose = max(eventualGlucose, finalUAMGlucose)
+         }
+          */
+
+        var finalCobForecast: [Decimal]?
+        if mealData.mealCOB > 0, carbImpact > 0 || carbImpactParams.remainingCarbImpactPeak > 0 {
+            finalCobForecast = cobForecast
+        }
+
+        var finalUamForecast: [Decimal]?
+        if profile.enableUAM, carbImpact > 0 || carbImpactParams.remainingCarbImpactPeak > 0 {
+            finalUamForecast = uamForecast
+        }
+
         return ForecastResult(
             iob: iobForecast,
-            cob: cobForecast,
-            uam: uamForecast,
+            cob: finalCobForecast,
+            uam: finalUamForecast,
             zt: ztForecast,
             eventualGlucose: eventualGlucose,
             minForecastedGlucose: blendedForecasts.minForecastedGlucose,
@@ -109,25 +158,28 @@ enum ForecastGenerator {
     /// - Returns: Remaining CA time in hours (Decimal)
     static func calculateRemainingCarbAbsorptionTime(
         sensitivityRatio: Decimal,
-        maxMealAbsorptionTime: Decimal,
+        maxMealAbsorptionTime _: Decimal,
         mealCOB: Decimal,
         lastCarbTime: Date?,
         currentTime: Date
     ) -> Decimal {
-        var minRemainingCarbAbsorptionTime: Decimal = min(3, maxMealAbsorptionTime) // hours
+        var minRemainingCarbAbsorptionTime: Decimal = 3 // hours
         if sensitivityRatio > 0 {
             minRemainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime / sensitivityRatio
         }
+
+        var remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime
         if mealCOB > 0 {
             let assumedCarbAbsorptionRate: Decimal = 20 // g/h
             minRemainingCarbAbsorptionTime = max(minRemainingCarbAbsorptionTime, mealCOB / assumedCarbAbsorptionRate)
+            if let lastCarbTime = lastCarbTime {
+                let lastCarbAgeMin = Decimal(currentTime.timeIntervalSince(lastCarbTime) / 60).jsRounded()
+                remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime + (1.5 * lastCarbAgeMin) / 60
+                remainingCarbAbsorptionTime = remainingCarbAbsorptionTime.jsRounded(scale: 1)
+            }
         }
-        var remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime
-        if let lastCarbTime = lastCarbTime {
-            let lastCarbAgeMin = Decimal(currentTime.timeIntervalSince(lastCarbTime) / 60)
-            remainingCarbAbsorptionTime += 1.5 * (lastCarbAgeMin / 60)
-        }
-        return remainingCarbAbsorptionTime.rounded(toPlaces: 1)
+
+        return remainingCarbAbsorptionTime
     }
 
     static func computeForecastSelection(
@@ -284,7 +336,7 @@ enum ForecastGenerator {
             return series
         }
         let maxToRemove = series.count - lookback
-        let reversedSeries = series.reversed()
+        let reversedSeries = series.map({ $0.jsRounded() }).reversed()
         var removeCount = 0
         for (curr, next) in zip(reversedSeries, reversedSeries.dropFirst()) {
             guard curr == next else {
@@ -297,4 +349,26 @@ enum ForecastGenerator {
 
         return Array(series.dropLast(removeCount))
     }
+
+    /// Trims trailing ZT points once they are rising and above target
+    public static func trimZTTails(series: [Decimal], targetBG: Decimal) -> [Decimal] {
+        let lookback = 7 // i > 6 in JS
+
+        guard series.count > lookback else {
+            return series
+        }
+        let maxToRemove = series.count - lookback
+        let reversedSeries = series.map({ $0.jsRounded() }).reversed()
+        var removeCount = 0
+        for (curr, next) in zip(reversedSeries, reversedSeries.dropFirst()) {
+            if next >= curr || curr <= targetBG {
+                break
+            }
+            removeCount += 1
+        }
+
+        removeCount = min(maxToRemove, removeCount)
+
+        return Array(series.dropLast(removeCount))
+    }
 }

+ 32 - 6
Trio/Sources/APS/OpenAPSSwift/Logging/JSONCompare.swift

@@ -249,18 +249,27 @@ enum JSONCompare {
     private static func compareDict(
         function: OrefFunction,
         swiftDict: [String: Any],
-        jsDict: [String: Any]
+        jsDict: [String: Any],
+        path: String = ""
     ) -> [String: ValueDifference] {
         var differences: [String: ValueDifference] = [:]
         let approximateKeys = function.approximateMatchingNumbers()
+        let flexibleArrayKeys = function.flexibleArrayKeys()
 
         // Check all keys present in either dictionary
         Set(jsDict.keys).union(swiftDict.keys).forEach { key in
+            let currentPath = path.isEmpty ? key : "\(path).\(key)"
             let jsValue = jsDict[key].map(convertToJSONValue) ?? .null
             let swiftValue = swiftDict[key].map(convertToJSONValue) ?? .null
 
-            if !valuesAreEqual(jsValue, swiftValue, approximately: approximateKeys[key], approximateKeys: approximateKeys) {
-                differences[key] = ValueDifference(
+            if !valuesAreEqual(
+                jsValue, swiftValue,
+                approximately: approximateKeys[key],
+                approximateKeys: approximateKeys,
+                flexibleArrayKeys: flexibleArrayKeys,
+                currentPath: currentPath
+            ) {
+                differences[currentPath] = ValueDifference(
                     js: jsValue,
                     swift: swiftValue,
                     jsKeyMissing: !jsDict.keys.contains(key),
@@ -302,7 +311,9 @@ enum JSONCompare {
         _ value1: JSONValue,
         _ value2: JSONValue,
         approximately: Double?,
-        approximateKeys: [String: Double]
+        approximateKeys: [String: Double],
+        flexibleArrayKeys: [String],
+        currentPath: String
     ) -> Bool {
         switch (value1, value2) {
         case (.null, .null):
@@ -321,13 +332,28 @@ enum JSONCompare {
         case let (.boolean(b1), .boolean(b2)):
             return b1 == b2
         case let (.array(a1), .array(a2)):
+            if flexibleArrayKeys.contains(currentPath), !a1.isEmpty, !a2.isEmpty {
+                let shortestCount = min(a1.count, a2.count)
+                return zip(a1.prefix(shortestCount), a2.prefix(shortestCount)).allSatisfy { v1, v2 in
+                    valuesAreEqual(
+                        v1, v2, approximately: approximately, approximateKeys: approximateKeys,
+                        flexibleArrayKeys: flexibleArrayKeys, currentPath: currentPath
+                    )
+                }
+            }
             return a1.count == a2.count && zip(a1, a2).allSatisfy { v1, v2 in
-                valuesAreEqual(v1, v2, approximately: approximately, approximateKeys: approximateKeys)
+                valuesAreEqual(
+                    v1, v2, approximately: approximately, approximateKeys: approximateKeys,
+                    flexibleArrayKeys: flexibleArrayKeys, currentPath: currentPath
+                )
             }
         case let (.object(o1), .object(o2)):
             return o1.keys == o2.keys && o1.keys.allSatisfy { key in
                 guard let v1 = o1[key], let v2 = o2[key] else { return false }
-                return valuesAreEqual(v1, v2, approximately: approximateKeys[key], approximateKeys: approximateKeys)
+                return valuesAreEqual(
+                    v1, v2, approximately: approximateKeys[key], approximateKeys: approximateKeys,
+                    flexibleArrayKeys: flexibleArrayKeys, currentPath: "\(currentPath).\(key)"
+                )
             }
         default:
             return false

+ 15 - 3
Trio/Sources/APS/OpenAPSSwift/Logging/OrefFunction.swift

@@ -73,8 +73,7 @@ enum OrefFunction: String, Codable {
                 "timestamp",
                 "minGuardBG",
                 "minPredBG",
-                // We haven't implemented DynamicISF support for forecasting yet
-                "predBGs",
+                // In both but not ready to test yet
                 "eventualBG"
             ])
         }
@@ -117,7 +116,11 @@ enum OrefFunction: String, Codable {
         case .determineBasal:
             return [
                 "sensitivityRatio": 0.011,
-                "expectedDelta": 0.11
+                "expectedDelta": 0.11,
+                "IOB": 1.1,
+                "ZT": 1.1,
+                "UAM": 1.1,
+                "COB": 1.1
             ]
         }
     }
@@ -136,4 +139,13 @@ enum OrefFunction: String, Codable {
             return .dictionary
         }
     }
+
+    func flexibleArrayKeys() -> [String] {
+        switch self {
+        case .determineBasal:
+            return ["predBGs.UAM", "predBGs.COB", "predBGs.ZT", "predBGs.IOB"]
+        default:
+            return []
+        }
+    }
 }

+ 2 - 2
Trio/Sources/APS/OpenAPSSwift/Models/ForecastResult.swift

@@ -2,8 +2,8 @@ import Foundation
 
 struct ForecastResult {
     public let iob: [Decimal]
-    public let cob: [Decimal]
-    public let uam: [Decimal]
+    public let cob: [Decimal]?
+    public let uam: [Decimal]?
     public let zt: [Decimal]
     public let eventualGlucose: Decimal
     public let minForecastedGlucose: Decimal

+ 63 - 0
TrioTests/OpenAPSSwiftTests/DetermineBasalJsonTests.swift

@@ -179,10 +179,73 @@ import Testing
 
         if comparison.resultType == .valueDifference {
             print(comparison.differences!.prettyPrintedJSON!)
+            printForecasts(comparison.differences)
         }
 
         #expect(comparison.resultType == .matching)
 
         timeZoneForTests.resetTimezone()
     }
+
+    func printForecasts(_ values: [String: Any]?) {
+        guard let values = values else { return }
+        guard let forecasts = values["predBGs"] as? Trio.ValueDifference else { return }
+        let js = forecasts.js.toDictionary()
+        let swift = forecasts.swift.toDictionary()
+
+        for forecastType in ["IOB", "ZT", "UAM", "COB"] {
+            print("")
+            guard let swiftForecast = swift[forecastType]?.toIntArray(),
+                  let jsForecast = js[forecastType]?.toIntArray()
+            else {
+                print("missing \(forecastType) forecast, skipping")
+                continue
+            }
+            if swiftForecast.count == jsForecast.count {
+                print(forecastType)
+            } else {
+                print("\(forecastType) has length mismatch ❌")
+            }
+            print("Row\tSft\tJS\tMatch")
+            print("--------------")
+            for (row, values) in zip(swiftForecast, jsForecast).enumerated() {
+                let pass: String
+                if abs(values.0 - values.1) <= 1 {
+                    pass = "✅"
+                } else {
+                    pass = "❌"
+                }
+                print("\(row)\t\(values.0)\t\(values.1)\t\(pass)")
+            }
+        }
+    }
+}
+
+extension JSONValue {
+    func toDictionary() -> [String: Trio.JSONValue] {
+        switch self {
+        case let .object(dict):
+            return dict
+        default:
+            fatalError()
+        }
+    }
+
+    func toIntArray() -> [Int] {
+        switch self {
+        case let .array(array):
+            return array.map { $0.toInt() }
+        default:
+            fatalError()
+        }
+    }
+
+    func toInt() -> Int {
+        switch self {
+        case let .number(number):
+            return Int(number)
+        default:
+            fatalError()
+        }
+    }
 }

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
TrioTests/OpenAPSSwiftTests/javascript/bundle/determine-basal.js