Bläddra i källkod

Add minPredBG minGuardBG 'selection' logic WIP

Deniz Cengiz 11 månader sedan
förälder
incheckning
698db068a7

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -643,6 +643,7 @@
 		DD30BA122E07764300DA677C /* Profile+TherapySettingGetter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA112E07763E00DA677C /* Profile+TherapySettingGetter.swift */; };
 		DD30BA162E0780A500DA677C /* AdjustedGlucoseTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA152E0780A500DA677C /* AdjustedGlucoseTargets.swift */; };
 		DD30BA182E078F8900DA677C /* ComputedInsulinSensitivities+Getter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA172E078F8100DA677C /* ComputedInsulinSensitivities+Getter.swift */; };
+		DD30BA1A2E08AB9F00DA677C /* CarbImpactParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA192E08AB9F00DA677C /* CarbImpactParams.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 */; };
@@ -1557,6 +1558,7 @@
 		DD30BA112E07763E00DA677C /* Profile+TherapySettingGetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+TherapySettingGetter.swift"; sourceTree = "<group>"; };
 		DD30BA152E0780A500DA677C /* AdjustedGlucoseTargets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustedGlucoseTargets.swift; sourceTree = "<group>"; };
 		DD30BA172E078F8100DA677C /* ComputedInsulinSensitivities+Getter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComputedInsulinSensitivities+Getter.swift"; sourceTree = "<group>"; };
+		DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbImpactParams.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>"; };
@@ -3712,6 +3714,7 @@
 		DD30B9C82E06295700DA677C /* Forecasts */ = {
 			isa = PBXGroup;
 			children = (
+				DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */,
 				DD30B9CD2E062AA300DA677C /* SingleForecasting.swift */,
 				DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */,
 			);
@@ -4556,6 +4559,7 @@
 				38B4F3CD25E5031100E76A18 /* Broadcaster.swift in Sources */,
 				383420D925FFEB3F002D46C1 /* Popup.swift in Sources */,
 				DD4C57AA2D73B3E2001BFF2C /* RestartLiveActivityIntentRequest.swift in Sources */,
+				DD30BA1A2E08AB9F00DA677C /* CarbImpactParams.swift in Sources */,
 				DD1745402C55BFC100211FAC /* AlgorithmAdvancedSettingsRootView.swift in Sources */,
 				3BCE75B32D4B38AE009E9453 /* InsulinSensitivities+Convert.swift in Sources */,
 				58645BA52CA2D347008AFCE7 /* ForecastSetup.swift in Sources */,

+ 23 - 13
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -99,19 +99,17 @@ enum DeterminationGenerator {
             )
         }
 
-        let glucoseImpactSeries = buildGlucoseImpactSeries(iobDataSeries: iobData, sensitivity: sensitivity)
-
-        let forecastGenerator = ForecastGenerator()
-        let forecastResult = forecastGenerator.generate(
-            glucose: currentGlucose,
-            glucoseImpactSeries: glucoseImpactSeries,
-            mealData: mealData,
+        let (adjustedGlucoseTargets, threshold) = adjustGlucoseTargets(
             profile: profile,
-            adjustedSensitivity: sensitivity,
-            sensitivityRatio: sensitivityRatio,
-            currentTime: currentTime
+            autosens: autosensData,
+            temptargetSet: profile.temptargetSet ?? false,
+            targetGlucose: profile.targetBg ?? 100, // TODO: grab from therapy settings
+            minGlucose: profile.minBg ?? 70, // TODO: can we force unwrap?
+            maxGlucose: profile.maxBg ?? 180,
+            noise: 1
         )
 
+        let glucoseImpactSeries = buildGlucoseImpactSeries(iobDataSeries: iobData, sensitivity: sensitivity)
         let currentGlucoseImpact = glucoseImpactSeries[0]
 
         let minDelta = min(glucoseStatus.delta, glucoseStatus.shortAvgDelta)
@@ -147,15 +145,27 @@ enum DeterminationGenerator {
             throw DeterminationError.eventualGlucoseCalculationError(sensitivity: sensitivity, deviation: deviation)
         }
 
+        let forecastGenerator = ForecastGenerator()
+        let forecastResult = forecastGenerator.generate(
+            glucose: currentGlucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            iobData: iobData,
+            mealData: mealData,
+            profile: profile,
+            adjustedSensitivity: sensitivity,
+            sensitivityRatio: sensitivityRatio,
+            naiveEventualGlucose: naiveEventualGlucose,
+            eventualGlucose: eventualGlucose,
+            threshold: threshold,
+            currentTime: currentTime
+        )
+
         let expectedDelta = calculateExpectedDelta(
             targetGlucose: profile.targetBg ?? 100,
             eventualGlucose: eventualGlucose,
             glucoseImpact: currentGlucoseImpact
         )
 
-        let minPredBG = forecastResult.iob.min()
-        let minGuardBG = minPredBG
-
         // TODO: STOPPING at LINE 734
         // L734ff handles forecasting, already handled (I hope)
         // continue at ~785

+ 75 - 0
Trio/Sources/APS/OpenAPSSwift/Forecasts/CarbImpactParams.swift

@@ -0,0 +1,75 @@
+import Foundation
+
+struct CarbImpactParams {
+    let carbSensivityFactor: Decimal
+    let cappedCarbImpact: Decimal
+    let remainingCarbAbsorptionTime: Decimal
+    let maxAbsorptionIntervals: Int
+    let triangleIntervals: Int
+    let remainingCarbImpactPeak: Decimal
+
+    static func calculate(
+        adjustedSensitivity: 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 computedRemainingCarbAbsorptionTime = ForecastGenerator.calculateRemainingCarbAbsorptionTime(
+            sensitivityRatio: sensitivityRatio,
+            maxMealAbsorptionTime: profile.maxMealAbsorptionTime,
+            mealCOB: mealData.mealCOB,
+            lastCarbTime: Date(timeIntervalSince1970: mealData.lastCarbTime),
+            currentTime: currentTime
+        )
+        // Clamp remainingTime for more robustness
+        let remainingCarbAbsorptionTime = min(computedRemainingCarbAbsorptionTime, profile.maxMealAbsorptionTime)
+
+        // Convert remainingCarbAbsorptionTime (hours) to intervals (each 5m):
+        let dynamicAbsorptionIntervals = Int((remainingCarbAbsorptionTime * 60) / 5)
+        // Number of 5-minute intervals over which we expect *all* carbs to absorb
+        let maxAbsorptionIntervals = Int(profile.maxMealAbsorptionTime * Decimal(60) / 5)
+        // Use smaller of both computed intervals, the dynamic and the max-clamped one as the actual # of decay triangle interval
+        let triangleIntervals = min(dynamicAbsorptionIntervals, maxAbsorptionIntervals)
+
+        // Total CI (mg/dL)
+        let totalCarbImpact = max(0, cappedCarbImpact / 5 * 60 * remainingCarbAbsorptionTime / 2)
+        // Total carbs absorbed from CI (g)
+        let totalCarbsAbsorbed: Decimal = totalCarbImpact / carbSensivityFactor
+
+        // Remaining carbs cap/fraction logic
+        let remainingCarbsCap = min(90, profile.remainingCarbsCap)
+        let remainingCarbsFraction = min(1, profile.remainingCarbsFraction)
+        let remainingCarbsIgnore = 1 - remainingCarbsFraction
+
+        var remainingCarbs = max(0, mealData.mealCOB - totalCarbsAbsorbed - mealData.carbs * remainingCarbsIgnore)
+        remainingCarbs = min(remainingCarbsCap, remainingCarbs)
+
+        // /\ triangle for remaining carbs
+        // Peak impact (mg/dL per 5m) of the *remaining* carbs
+        let remainingCarbImpactPeak: Decimal
+        if remainingCarbAbsorptionTime > 0 {
+            remainingCarbImpactPeak = (remainingCarbs * carbSensivityFactor * 5 / 60) / (remainingCarbAbsorptionTime / 2)
+        } else {
+            remainingCarbImpactPeak = 0
+        }
+
+        return CarbImpactParams(
+            carbSensivityFactor: carbSensivityFactor,
+            cappedCarbImpact: cappedCarbImpact,
+            remainingCarbAbsorptionTime: remainingCarbAbsorptionTime,
+            maxAbsorptionIntervals: maxAbsorptionIntervals,
+            triangleIntervals: triangleIntervals,
+            remainingCarbImpactPeak: remainingCarbImpactPeak
+        )
+    }
+}

+ 276 - 45
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift

@@ -22,60 +22,291 @@ struct ForecastGenerator {
     public func generate(
         glucose: Decimal,
         glucoseImpactSeries: [Decimal],
+        iobData _: [IobResult],
         mealData: ComputedCarbs,
         profile: Profile,
         adjustedSensitivity: Decimal,
         sensitivityRatio: Decimal,
+        naiveEventualGlucose _: Decimal,
+        eventualGlucose: Decimal,
+        threshold: Decimal,
         currentTime: Date
     ) -> ForecastResult {
-        let carbImpact = mealData.currentDeviation * (profile.carbRatio ?? profile.carbRatioFor(time: currentTime)) / (profile.sens ?? profile.sensitivityFor(time: currentTime))
+        let carbImpact = mealData
+            .currentDeviation * (profile.carbRatio ?? profile.carbRatioFor(time: currentTime)) /
+            (profile.sens ?? profile.sensitivityFor(time: currentTime))
         let deviation = mealData.currentDeviation
 
+        // JS oref initializes all xxxPredBGs array with current glucose, we do the same, then generate
+        let iobForecast = [glucose] + iob.forecast(
+            startingGlucose: glucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            mealData: mealData,
+            profile: profile,
+            carbImpact: carbImpact,
+            deviation: deviation,
+            adjustedSensitivity: adjustedSensitivity,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
+        )
+
+        let cobForecast = [glucose] + cob.forecast(
+            startingGlucose: glucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            mealData: mealData,
+            profile: profile,
+            carbImpact: carbImpact,
+            deviation: deviation,
+            adjustedSensitivity: adjustedSensitivity,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
+        )
+
+        let uamForecast = [glucose] + uam.forecast(
+            startingGlucose: glucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            mealData: mealData,
+            profile: profile,
+            carbImpact: carbImpact,
+            deviation: deviation,
+            adjustedSensitivity: adjustedSensitivity,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
+        )
+
+        let ztForecast = [glucose] + zt.forecast(
+            startingGlucose: glucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            mealData: mealData,
+            profile: profile,
+            carbImpact: carbImpact,
+            deviation: deviation,
+            adjustedSensitivity: adjustedSensitivity,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
+        )
+
+        let computedForecastSelection = Self.computeForecastSelection(
+            iob: iobForecast,
+            cob: cobForecast,
+            uam: uamForecast,
+            zt: ztForecast,
+            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,
+            remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
+            fractionCarbsLeft: mealData.carbs > 0 ? mealData.mealCOB / mealData.carbs : 0,
+            threshold: threshold,
+            targetGlucose: profile.targetBg ?? 100,
+            currentGlucose: glucose
+        )
+
         return ForecastResult(
-            iob: iob.forecast(
-                startingGlucose: glucose,
-                glucoseImpactSeries: glucoseImpactSeries,
-                mealData: mealData,
-                profile: profile,
-                carbImpact: carbImpact,
-                deviation: deviation,
-                adjustedSensitivity: adjustedSensitivity,
-                sensitivityRatio: sensitivityRatio,
-                currentTime: currentTime
-            ),
-            cob: cob.forecast(
-                startingGlucose: glucose,
-                glucoseImpactSeries: glucoseImpactSeries,
-                mealData: mealData,
-                profile: profile,
-                carbImpact: carbImpact,
-                deviation: deviation,
-                adjustedSensitivity: adjustedSensitivity,
-                sensitivityRatio: sensitivityRatio,
-                currentTime: currentTime
-            ),
-            uam: uam.forecast(
-                startingGlucose: glucose,
-                glucoseImpactSeries: glucoseImpactSeries,
-                mealData: mealData,
-                profile: profile,
-                carbImpact: carbImpact,
-                deviation: deviation,
-                adjustedSensitivity: adjustedSensitivity,
-                sensitivityRatio: sensitivityRatio,
-                currentTime: currentTime
-            ),
-            zt: zt.forecast(
-                startingGlucose: glucose,
-                glucoseImpactSeries: glucoseImpactSeries,
-                mealData: mealData,
-                profile: profile,
-                carbImpact: carbImpact,
-                deviation: deviation,
-                adjustedSensitivity: adjustedSensitivity,
-                sensitivityRatio: sensitivityRatio,
-                currentTime: currentTime
-            )
+            iob: iobForecast,
+            cob: cobForecast,
+            uam: uamForecast,
+            zt: ztForecast,
+            eventualGlucose: eventualGlucose,
+            minForecastedGlucose: blendedForecasts.minForecastedGlucose,
+            minGuardGlucose: blendedForecasts.minGuardGlucose
+        )
+    }
+
+    /// Calculates the dynamic remaining carb absorption time in hours, per oref0 logic.
+    /// - Parameters:
+    ///   - sensitivityRatio: ratio from autosens (usually 1.0 if not present)
+    ///   - mealCOB: unabsorbed carbs (grams)
+    ///   - lastCarbTime: timestamp of last carb entry (Date? or nil)
+    ///   - currentTime: now
+    /// - Returns: Remaining CA time in hours (Decimal)
+    static func calculateRemainingCarbAbsorptionTime(
+        sensitivityRatio: Decimal,
+        maxMealAbsorptionTime: Decimal,
+        mealCOB: Decimal,
+        lastCarbTime: Date?,
+        currentTime: Date
+    ) -> Decimal {
+        var minRemainingCarbAbsorptionTime: Decimal = min(3, maxMealAbsorptionTime) // hours
+        if sensitivityRatio > 0 {
+            minRemainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime / sensitivityRatio
+        }
+        if mealCOB > 0 {
+            let assumedCarbAbsorptionRate: Decimal = 20 // g/h
+            minRemainingCarbAbsorptionTime = max(minRemainingCarbAbsorptionTime, mealCOB / assumedCarbAbsorptionRate)
+        }
+        var remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime
+        if let lastCarbTime = lastCarbTime {
+            let lastCarbAgeMin = Decimal(currentTime.timeIntervalSince(lastCarbTime) / 60)
+            remainingCarbAbsorptionTime += 1.5 * (lastCarbAgeMin / 60)
+        }
+        return remainingCarbAbsorptionTime.rounded(toPlaces: 1)
+    }
+
+    static func computeForecastSelection(
+        iob: [Decimal],
+        cob: [Decimal],
+        uam: [Decimal],
+        zt: [Decimal],
+        currentGlucose: Decimal
+    ) -> ForecastSelectionResult {
+        // In the JS, minPredBG is only considered after insulin peak, so use dropFirst
+        let iobAfter90min = iob.dropFirst(18) // 90m at 5m intervals = 18
+        let cobAfter90min = cob.dropFirst(18)
+        let uamAfter60min = uam.dropFirst(12) // 60m at 5m intervals = 12
+
+        let minIOBForecastGlucose = iobAfter90min.min() ?? Decimal(999)
+        let minCOBForecastGlucose = cobAfter90min.min() ?? Decimal(999)
+        let minUAMForecastGlucose = uamAfter60min.min() ?? Decimal(999)
+
+        let minIOBGuardGlucose = iob.min() ?? Decimal(999)
+        let minCOBGuardGlucose = cob.min() ?? Decimal(999)
+        let minUAMGuardGlucose = uam.min() ?? Decimal(999)
+        let minZTGuardGlucose = zt.min() ?? Decimal(999)
+
+        let maxIOBForecastGlucose = iob.max() ?? currentGlucose
+        let maxCOBForecastGlucose = cob.max() ?? currentGlucose
+        let maxUAMForecastGlucose = uam.max() ?? currentGlucose
+
+        let lastIOBForecastGlucose = iob.last ?? currentGlucose
+        let lastCOBForecastGlucose = cob.last ?? currentGlucose
+        let lastUAMForecastGlucose = uam.last ?? currentGlucose
+        let lastZTForecastGlucose = zt.last ?? currentGlucose
+
+        return ForecastSelectionResult(
+            minIOBForecastGlucose: minIOBForecastGlucose,
+            minCOBForecastGlucose: minCOBForecastGlucose,
+            minUAMForecastGlucose: minUAMForecastGlucose,
+            minIOBGuardGlucose: minIOBGuardGlucose,
+            minCOBGuardGlucose: minCOBGuardGlucose,
+            minUAMGuardGlucose: minUAMGuardGlucose,
+            minZTGuardGlucose: minZTGuardGlucose,
+            maxIOBForecastGlucose: maxIOBForecastGlucose,
+            maxCOBForecastGlucose: maxCOBForecastGlucose,
+            maxUAMForecastGlucose: maxUAMForecastGlucose,
+            lastIOBForecastGlucose: lastIOBForecastGlucose,
+            lastCOBForecastGlucose: lastCOBForecastGlucose,
+            lastUAMForecastGlucose: lastUAMForecastGlucose,
+            lastZTForecastGlucose: lastZTForecastGlucose
+        )
+    }
+
+    /// Mirrors the oref0 JS logic for selecting/blending min/avg/guard BGs.
+    static func blendForecasts(
+        selectionResult: ForecastSelectionResult,
+        carbs: Decimal,
+        mealCOB _: Decimal,
+        enableUAM: Bool,
+        carbImpactDuration: Decimal,
+        remainingCarbImpactPeak: Decimal,
+        fractionCarbsLeft: Decimal,
+        threshold: Decimal,
+        targetGlucose: Decimal,
+        currentGlucose: Decimal
+    ) -> ForecastBlendingResult {
+        // 1. Calculate minZTUAMForecastGlucose ("minZTUAMPredBG" in JS)
+        var minZTUAMForecastGlucose = selectionResult.minUAMForecastGlucose
+        if selectionResult.minZTGuardGlucose < threshold {
+            minZTUAMForecastGlucose = ((selectionResult.minUAMForecastGlucose + selectionResult.minZTGuardGlucose) / 2)
+                .rounded()
+        } else if selectionResult.minZTGuardGlucose < targetGlucose {
+            let blendPct = (selectionResult.minZTGuardGlucose - threshold) / (targetGlucose - threshold)
+            let blendedMinZTGuardGlucose = selectionResult.minUAMForecastGlucose * blendPct + selectionResult
+                .minZTGuardGlucose * (1 - blendPct)
+            minZTUAMForecastGlucose = ((selectionResult.minUAMForecastGlucose + blendedMinZTGuardGlucose) / 2).rounded()
+        } else if selectionResult.minZTGuardGlucose > selectionResult.minUAMForecastGlucose {
+            minZTUAMForecastGlucose = ((selectionResult.minUAMForecastGlucose + selectionResult.minZTGuardGlucose) / 2)
+                .rounded()
+        }
+
+        // 2. avgForecastGlucose blending (like avgPredBG)
+        let avgForecastGlucose: Decimal
+        if selectionResult.minUAMForecastGlucose < 999, selectionResult.minCOBForecastGlucose < 999 {
+            avgForecastGlucose = (
+                (1 - fractionCarbsLeft) * selectionResult
+                    .lastUAMForecastGlucose + fractionCarbsLeft * selectionResult.lastCOBForecastGlucose
+            ).rounded()
+        } else if selectionResult.minCOBForecastGlucose < 999 {
+            avgForecastGlucose = ((selectionResult.lastIOBForecastGlucose + selectionResult.lastCOBForecastGlucose) / 2)
+                .rounded()
+        } else if selectionResult.minUAMForecastGlucose < 999 {
+            avgForecastGlucose = ((selectionResult.lastIOBForecastGlucose + selectionResult.lastUAMForecastGlucose) / 2)
+                .rounded()
+        } else {
+            avgForecastGlucose = selectionResult.lastIOBForecastGlucose.rounded()
+        }
+        let adjustedAvgForecastGlucose = max(avgForecastGlucose, selectionResult.minZTGuardGlucose)
+
+        // 3. minGuardGlucose
+        let minGuardGlucose: Decimal
+        if carbImpactDuration > 0 || remainingCarbImpactPeak > 0 {
+            if enableUAM {
+                minGuardGlucose = (
+                    fractionCarbsLeft * selectionResult
+                        .minCOBGuardGlucose + (1 - fractionCarbsLeft) * selectionResult.minUAMGuardGlucose
+                ).rounded()
+            } else {
+                minGuardGlucose = selectionResult.minCOBGuardGlucose.rounded()
+            }
+        } else if enableUAM {
+            minGuardGlucose = selectionResult.minUAMGuardGlucose.rounded()
+        } else {
+            minGuardGlucose = selectionResult.minIOBGuardGlucose.rounded()
+        }
+
+        // 4. minForecastedGlucose ("minPredBG")
+        var minForecastedGlucose: Decimal = selectionResult.minIOBForecastGlucose.rounded()
+        if carbs > 0 {
+            if !enableUAM, selectionResult.minCOBForecastGlucose < 999 {
+                minForecastedGlucose = max(selectionResult.minIOBForecastGlucose, selectionResult.minCOBForecastGlucose)
+            } else if selectionResult.minCOBForecastGlucose < 999 {
+                let blendedMinForecastGlucose = fractionCarbsLeft * selectionResult
+                    .minCOBForecastGlucose + (1 - fractionCarbsLeft) * minZTUAMForecastGlucose
+                minForecastedGlucose = max(
+                    selectionResult.minIOBForecastGlucose,
+                    selectionResult.minCOBForecastGlucose,
+                    blendedMinForecastGlucose
+                ).rounded()
+            } else if enableUAM {
+                minForecastedGlucose = minZTUAMForecastGlucose
+            } else {
+                minForecastedGlucose = minGuardGlucose
+            }
+        } else if enableUAM {
+            minForecastedGlucose = max(selectionResult.minIOBForecastGlucose, minZTUAMForecastGlucose).rounded()
+        }
+
+        // Clamp minForecastedGlucose to not exceed adjustedAvgForecastGlucose
+        minForecastedGlucose = min(minForecastedGlucose, adjustedAvgForecastGlucose)
+
+        // JS: If maxCOBPredBG > bg, don't trust UAM too much
+        if selectionResult.maxCOBForecastGlucose > currentGlucose {
+            minForecastedGlucose = min(minForecastedGlucose, selectionResult.maxCOBForecastGlucose)
+        }
+
+        return ForecastBlendingResult(
+            minForecastedGlucose: minForecastedGlucose,
+            avgForecastedGlucose: adjustedAvgForecastGlucose,
+            minGuardGlucose: minGuardGlucose
         )
     }
 

+ 19 - 79
Trio/Sources/APS/OpenAPSSwift/Forecasts/SingleForecasting.swift

@@ -25,6 +25,8 @@ protocol SingleForecasting {
 
 /// Forecast sub-generator for insulin-only effect (IOB)
 struct IOBForecastGenerator: SingleForecasting {
+    // TODO: Dynamic ISF not yet supported
+
     public func forecast(
         startingGlucose: Decimal,
         glucoseImpactSeries: [Decimal],
@@ -48,6 +50,8 @@ struct IOBForecastGenerator: SingleForecasting {
 
 /// Forecast sub-generator for carb-only effect (COB + UAM piece)
 struct COBForecastGenerator: SingleForecasting {
+    // TODO: Dynamic ISF not yet supported
+
     public func forecast(
         startingGlucose: Decimal,
         glucoseImpactSeries: [Decimal],
@@ -62,56 +66,18 @@ struct COBForecastGenerator: SingleForecasting {
         // Start with the current BG
         var result = [startingGlucose]
 
-        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 computedRemainingCarbAbsorptionTime = Self.calculateRemainingCarbAbsorptionTime(
+        let carbImpactParams = CarbImpactParams.calculate(
+            adjustedSensitivity: adjustedSensitivity,
+            profile: profile,
+            mealData: mealData,
+            carbImpact: carbImpact,
             sensitivityRatio: sensitivityRatio,
-            maxMealAbsorptionTime: profile.maxMealAbsorptionTime,
-            mealCOB: mealData.mealCOB,
-            lastCarbTime: Date(timeIntervalSince1970: mealData.lastCarbTime),
             currentTime: currentTime
         )
-        // Clamp remainingTime for more robustness
-        let remainingCarbAbsorptionTime = min(computedRemainingCarbAbsorptionTime, profile.maxMealAbsorptionTime)
-
-        // Convert remainingCarbAbsorptionTime (hours) to intervals (each 5m):
-        let dynamicAbsorptionIntervals = Int((remainingCarbAbsorptionTime * 60) / 5)
-        // Number of 5-minute intervals over which we expect *all* carbs to absorb
-        let maxAbsorptionIntervals = Int(profile.maxMealAbsorptionTime * Decimal(60) / 5)
-        // Use smaller of both computed intervals, the dynamic and the max-clamped one as the actual # of decay triangle interval
-        let triangleIntervals = min(dynamicAbsorptionIntervals, maxAbsorptionIntervals)
-
-        // Total CI (mg/dL)
-        let totalCarbImpact = max(0, cappedCarbImpact / 5 * 60 * remainingCarbAbsorptionTime / 2)
-        // Total carbs absorbed from CI (g)
-        let totalCarbsAbsorbed: Decimal = totalCarbImpact / carbSensivityFactor
-
-        // Remaining carbs cap/fraction logic
-        let remainingCarbsCap = min(90, profile.remainingCarbsCap)
-        let remainingCarbsFraction = min(1, profile.remainingCarbsFraction)
-        let remainingCarbsIgnore = 1 - remainingCarbsFraction
-
-        var remainingCarbs = max(0, mealData.mealCOB - totalCarbsAbsorbed - mealData.carbs * remainingCarbsIgnore)
-        remainingCarbs = min(remainingCarbsCap, remainingCarbs)
-
-        // /\ triangle for remaining carbs
-        // Peak impact (mg/dL per 5m) of the *remaining* carbs
-        let remainingCarbImpactPeak: Decimal
-        if remainingCarbAbsorptionTime > 0 {
-            remainingCarbImpactPeak = (remainingCarbs * carbSensivityFactor * 5 / 60) / (remainingCarbAbsorptionTime / 2)
-        } else {
-            remainingCarbImpactPeak = 0
-        }
 
         // 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(maxAbsorptionIntervals * 2, 1)
+        let decayIntervals = max(carbImpactParams.maxAbsorptionIntervals * 2, 1)
 
         // Helper: negative deviation only (never positive)
         let forecastedDeviation = min(0, deviation)
@@ -122,23 +88,23 @@ struct COBForecastGenerator: SingleForecasting {
 
             // Linearly decay the *observed* carb impact from initialCI → 0
             let decayFactor = max(0, 1 - seriesCount / decayIntervals)
-            let forecastedCarbImpact = cappedCarbImpact * Decimal(decayFactor)
+            let forecastedCarbImpact = carbImpactParams.cappedCarbImpact * Decimal(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 triangleIntervals > 0, seriesCount <= triangleIntervals {
+            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 = triangleIntervals / 2
+                let halfTriangle = carbImpactParams.triangleIntervals / 2
                 if seriesCount <= halfTriangle {
                     // Ramp up
-                    triangle = remainingCarbImpactPeak * Decimal(seriesCount) / Decimal(halfTriangle)
+                    triangle = carbImpactParams.remainingCarbImpactPeak * Decimal(seriesCount) / Decimal(halfTriangle)
                 } else {
                     // Ramp down
-                    triangle = remainingCarbImpactPeak * Decimal(triangleIntervals - seriesCount) / Decimal(halfTriangle)
+                    triangle = carbImpactParams.remainingCarbImpactPeak * Decimal(carbImpactParams.triangleIntervals - seriesCount) / Decimal(halfTriangle)
                 }
             } else {
                 triangle = 0
@@ -155,40 +121,12 @@ struct COBForecastGenerator: SingleForecasting {
 
         return ForecastGenerator.trimFlatTails(result, lookback: 12)
     }
-
-    /// Calculates the dynamic remaining carb absorption time in hours, per oref0 logic.
-    /// - Parameters:
-    ///   - sensitivityRatio: ratio from autosens (usually 1.0 if not present)
-    ///   - mealCOB: unabsorbed carbs (grams)
-    ///   - lastCarbTime: timestamp of last carb entry (Date? or nil)
-    ///   - currentTime: now
-    /// - Returns: Remaining CA time in hours (Decimal)
-    private static func calculateRemainingCarbAbsorptionTime(
-        sensitivityRatio: Decimal,
-        maxMealAbsorptionTime: Decimal,
-        mealCOB: Decimal,
-        lastCarbTime: Date?,
-        currentTime: Date
-    ) -> Decimal {
-        var minRemainingCarbAbsorptionTime: Decimal = min(3, maxMealAbsorptionTime) // hours
-        if sensitivityRatio > 0 {
-            minRemainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime / sensitivityRatio
-        }
-        if mealCOB > 0 {
-            let assumedCarbAbsorptionRate: Decimal = 20 // g/h
-            minRemainingCarbAbsorptionTime = max(minRemainingCarbAbsorptionTime, mealCOB / assumedCarbAbsorptionRate)
-        }
-        var remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime
-        if let lastCarbTime = lastCarbTime {
-            let lastCarbAgeMin = Decimal(currentTime.timeIntervalSince(lastCarbTime) / 60)
-            remainingCarbAbsorptionTime += 1.5 * (lastCarbAgeMin / 60)
-        }
-        return remainingCarbAbsorptionTime.rounded(toPlaces: 1)
-    }
 }
 
 /// Forecast sub-generator for “unannounced meal” impact (UAM)
 struct UAMForecastGenerator: SingleForecasting {
+    // TODO: Dynamic ISF not yet supported
+
     public func forecast(
         startingGlucose: Decimal,
         glucoseImpactSeries: [Decimal],
@@ -216,6 +154,8 @@ struct UAMForecastGenerator: SingleForecasting {
 
 /// Forecast sub-generator for “zero-temp” baseline (ZT)
 struct ZTForecastGenerator: SingleForecasting {
+    // TODO: Dynamic ISF not yet supported
+
     public func forecast(
         startingGlucose: Decimal,
         glucoseImpactSeries: [Decimal],

+ 26 - 0
Trio/Sources/APS/OpenAPSSwift/Models/ForecastResult.swift

@@ -5,4 +5,30 @@ struct ForecastResult {
     public let cob: [Decimal]
     public let uam: [Decimal]
     public let zt: [Decimal]
+    public let eventualGlucose: Decimal
+    public let minForecastedGlucose: Decimal
+    public let minGuardGlucose: Decimal
+}
+
+struct ForecastSelectionResult {
+    let minIOBForecastGlucose: Decimal
+    let minCOBForecastGlucose: Decimal
+    let minUAMForecastGlucose: Decimal
+    let minIOBGuardGlucose: Decimal
+    let minCOBGuardGlucose: Decimal
+    let minUAMGuardGlucose: Decimal
+    let minZTGuardGlucose: Decimal
+    let maxIOBForecastGlucose: Decimal
+    let maxCOBForecastGlucose: Decimal
+    let maxUAMForecastGlucose: Decimal
+    let lastIOBForecastGlucose: Decimal
+    let lastCOBForecastGlucose: Decimal
+    let lastUAMForecastGlucose: Decimal
+    let lastZTForecastGlucose: Decimal
+}
+
+struct ForecastBlendingResult {
+    let minForecastedGlucose: Decimal
+    let avgForecastedGlucose: Decimal
+    let minGuardGlucose: Decimal
 }