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

Add missing logic to COB forecast generator; various fixes

Deniz Cengiz 11 месяцев назад
Родитель
Сommit
3ce2def7b5

+ 4 - 1
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -106,7 +106,10 @@ enum DeterminationGenerator {
             glucose: currentGlucose,
             glucoseImpactSeries: glucoseImpactSeries,
             mealData: mealData,
-            profile: profile
+            profile: profile,
+            adjustedSensitivity: sensitivity,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
         )
 
         let currentGlucoseImpact = glucoseImpactSeries[0]

+ 33 - 1
Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+TherapySettingGetter.swift

@@ -27,7 +27,7 @@ extension Profile {
                 return entry.rate
             }
         }
-        return 0
+        return 0.1
     }
 
     /// Returns the ISF (insulin sensitivity factor) for the given time (default: now), or 200 if not found.
@@ -60,4 +60,36 @@ extension Profile {
         }
         return sens ?? 200
     }
+
+    /// Returns the carb ratio for the given time (default: now), or the top-level value, or 10 if not found.
+    func carbRatioFor(time: Date = Date()) -> Decimal {
+        // First: try using the dynamic schedule
+        if let carbRatios = carbRatios, !carbRatios.schedule.isEmpty {
+            let calendar = Calendar.current
+            let startOfDay = calendar.startOfDay(for: time)
+            let nowMinutes = calendar.dateComponents([.minute], from: startOfDay, to: time).minute ?? 0
+
+            let entries = carbRatios.schedule.sorted { $0.offset < $1.offset }
+
+            for (index, entry) in entries.enumerated() {
+                let startMinutes = entry.offset
+                let endMinutes: Int
+                if index < entries.count - 1 {
+                    endMinutes = entries[index + 1].offset
+                } else {
+                    endMinutes = 24 * 60 // 1440, end of day
+                }
+
+                if nowMinutes >= startMinutes, nowMinutes < endMinutes {
+                    return entry.ratio
+                }
+            }
+        }
+        // Second: fallback to flat profile value if present
+        if let carbRatio = self.carbRatio {
+            return carbRatio
+        }
+        // Third: fallback default (safe assumption)
+        return 30
+    }
 }

+ 21 - 6
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift

@@ -23,9 +23,12 @@ struct ForecastGenerator {
         glucose: Decimal,
         glucoseImpactSeries: [Decimal],
         mealData: ComputedCarbs,
-        profile: Profile
+        profile: Profile,
+        adjustedSensitivity: Decimal,
+        sensitivityRatio: Decimal,
+        currentTime: Date
     ) -> ForecastResult {
-        let carbImpact = mealData.currentDeviation * profile.carbRatio! / profile.sens!
+        let carbImpact = mealData.currentDeviation * (profile.carbRatio ?? profile.carbRatioFor(time: currentTime)) / (profile.sens ?? profile.sensitivityFor(time: currentTime))
         let deviation = mealData.currentDeviation
 
         return ForecastResult(
@@ -35,7 +38,10 @@ struct ForecastGenerator {
                 mealData: mealData,
                 profile: profile,
                 carbImpact: carbImpact,
-                deviation: deviation
+                deviation: deviation,
+                adjustedSensitivity: adjustedSensitivity,
+                sensitivityRatio: sensitivityRatio,
+                currentTime: currentTime
             ),
             cob: cob.forecast(
                 startingGlucose: glucose,
@@ -43,7 +49,10 @@ struct ForecastGenerator {
                 mealData: mealData,
                 profile: profile,
                 carbImpact: carbImpact,
-                deviation: deviation
+                deviation: deviation,
+                adjustedSensitivity: adjustedSensitivity,
+                sensitivityRatio: sensitivityRatio,
+                currentTime: currentTime
             ),
             uam: uam.forecast(
                 startingGlucose: glucose,
@@ -51,7 +60,10 @@ struct ForecastGenerator {
                 mealData: mealData,
                 profile: profile,
                 carbImpact: carbImpact,
-                deviation: deviation
+                deviation: deviation,
+                adjustedSensitivity: adjustedSensitivity,
+                sensitivityRatio: sensitivityRatio,
+                currentTime: currentTime
             ),
             zt: zt.forecast(
                 startingGlucose: glucose,
@@ -59,7 +71,10 @@ struct ForecastGenerator {
                 mealData: mealData,
                 profile: profile,
                 carbImpact: carbImpact,
-                deviation: deviation
+                deviation: deviation,
+                adjustedSensitivity: adjustedSensitivity,
+                sensitivityRatio: sensitivityRatio,
+                currentTime: currentTime
             )
         )
     }

+ 113 - 28
Trio/Sources/APS/OpenAPSSwift/Forecasts/SingleForecasting.swift

@@ -16,7 +16,10 @@ protocol SingleForecasting {
         mealData: ComputedCarbs,
         profile: Profile,
         carbImpact: Decimal,
-        deviation: Decimal
+        deviation: Decimal,
+        adjustedSensitivity: Decimal,
+        sensitivityRatio: Decimal,
+        currentTime: Date
     ) -> [Decimal]
 }
 
@@ -28,12 +31,15 @@ struct IOBForecastGenerator: SingleForecasting {
         mealData _: ComputedCarbs,
         profile _: Profile,
         carbImpact _: Decimal,
-        deviation: Decimal
+        deviation: Decimal,
+        adjustedSensitivity _: Decimal,
+        sensitivityRatio _: Decimal,
+        currentTime _: Date
     ) -> [Decimal] {
         var result = [startingGlucose]
         for (count, glucoseImpact) in glucoseImpactSeries.enumerated() {
-            let predDev = deviation * (1 - min(1, Decimal(count) / (60 / 5)))
-            let next = result.last! + glucoseImpact + predDev
+            let forecastedDeviation = deviation * (1 - min(1, Decimal(count) / (60 / 5)))
+            let next = result.last! + glucoseImpact + forecastedDeviation
             result.append(next.clamp(lowerBound: 39, upperBound: 401))
         }
         return ForecastGenerator.trimFlatTails(result, lookback: 90 / 5)
@@ -48,59 +54,99 @@ struct COBForecastGenerator: SingleForecasting {
         mealData: ComputedCarbs,
         profile: Profile,
         carbImpact: Decimal,
-        deviation: Decimal
+        deviation: Decimal,
+        adjustedSensitivity: Decimal,
+        sensitivityRatio: Decimal,
+        currentTime: Date
     ) -> [Decimal] {
         // Start with the current BG
         var result = [startingGlucose]
 
-        // carb-sensitivity factor (mg/dL per gram)
-        guard let sens = profile.sens,
-              let carbRatio = profile.carbRatio
-        else {
-            fatalError("Profile must have sens and carbRatio")
-        }
-        let csf = sens / carbRatio // FIXME: this needs to be the AS-adjusted sens, not profile.sens
+        let carbSensivityFactor = adjustedSensitivity / (profile.carbRatio ?? profile.carbRatioFor(time: currentTime))
 
         // Initial carb impact in mg/dL per 5m
-        let initialCarbImpact = carbImpact * csf
+        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(
+            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 absorptionIntervals = Int(profile.maxMealAbsorptionTime * Decimal(60) / 5)
+        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 = mealData.mealCOB * csf
+        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 2× the window, CI has decayed to zero).
-        let decayIntervals = max(absorptionIntervals * 2, 1)
+        // We use twice the absorption window (so that by 2x the window, CI has decayed to zero).
+        let decayIntervals = max(maxAbsorptionIntervals * 2, 1)
 
         // Helper: negative deviation only (never positive)
-        let predDev = min(0, deviation)
+        let forecastedDeviation = min(0, deviation)
 
-        // Build prediction out to glucoseImpactSeries.count (usually 48)
+        // Build forecast out to glucoseImpactSeries.count (usually 48)
         for seriesCount in 1 ..< glucoseImpactSeries.count {
             let insulinEffect = glucoseImpactSeries[seriesCount]
 
             // Linearly decay the *observed* carb impact from initialCI → 0
             let decayFactor = max(0, 1 - seriesCount / decayIntervals)
-            let forecastedCarbImpact = initialCarbImpact * Decimal(decayFactor)
+            let forecastedCarbImpact = 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 seriesCount <= absorptionIntervals {
-                triangle = remainingCarbImpactPeak * (Decimal(seriesCount) / Decimal(absorptionIntervals))
-            } else if seriesCount <= decayIntervals {
-                triangle = remainingCarbImpactPeak * (Decimal(decayIntervals - seriesCount) / Decimal(absorptionIntervals))
+            if triangleIntervals > 0, seriesCount <= 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
+                if seriesCount <= halfTriangle {
+                    // Ramp up
+                    triangle = remainingCarbImpactPeak * Decimal(seriesCount) / Decimal(halfTriangle)
+                } else {
+                    // Ramp down
+                    triangle = remainingCarbImpactPeak * Decimal(triangleIntervals - seriesCount) / Decimal(halfTriangle)
+                }
             } else {
                 triangle = 0
             }
 
             let next = result.last!
                 + insulinEffect
-                + predDev
+                + forecastedDeviation
                 + forecastedCarbImpact
                 + triangle
 
@@ -109,6 +155,36 @@ 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)
@@ -119,7 +195,10 @@ struct UAMForecastGenerator: SingleForecasting {
         mealData: ComputedCarbs,
         profile _: Profile,
         carbImpact: Decimal,
-        deviation: Decimal
+        deviation: Decimal,
+        adjustedSensitivity _: Decimal,
+        sensitivityRatio _: Decimal,
+        currentTime _: Date
     ) -> [Decimal] {
         var result = [startingGlucose]
 
@@ -143,7 +222,10 @@ struct ZTForecastGenerator: SingleForecasting {
         mealData: ComputedCarbs,
         profile: Profile,
         carbImpact: Decimal,
-        deviation: Decimal
+        deviation: Decimal,
+        adjustedSensitivity: Decimal,
+        sensitivityRatio: Decimal,
+        currentTime: Date
     ) -> [Decimal] {
         // essentially insulin effect only, but with zero-temp ISF if needed
         IOBForecastGenerator().forecast(
@@ -152,7 +234,10 @@ struct ZTForecastGenerator: SingleForecasting {
             mealData: mealData,
             profile: profile,
             carbImpact: carbImpact,
-            deviation: deviation
+            deviation: deviation,
+            adjustedSensitivity: adjustedSensitivity,
+            sensitivityRatio: sensitivityRatio,
+            currentTime: currentTime
         )
     }
 }