Kaynağa Gözat

Merge pull request #573 from nightscout/oref-swift-carbsrequired

Implement `carbsReq` in oref-swift
Sam King 9 ay önce
ebeveyn
işleme
86b3054d5a

+ 9 - 1
Trio.xcodeproj/project.pbxproj

@@ -303,6 +303,8 @@
 		3BAAE60C2DE7766C0049589B /* DynamicISFEnableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */; };
 		3BAD36B22D7CDC1A00CC298D /* MainLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */; };
 		3BAD36CC2D7D420E00CC298D /* CoreDataInitializationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */; };
+		3BAE876E2E47F12900FCA8D2 /* DosingEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAE876D2E47F12900FCA8D2 /* DosingEngine.swift */; };
+		3BAE87702E480BE100FCA8D2 /* ForecastResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAE876F2E480BE100FCA8D2 /* ForecastResults.swift */; };
 		3BBB76AA2E01C70B0040977D /* MealCob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BBB76A92E01C7070040977D /* MealCob.swift */; };
 		3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BBC22622DF5B93900169236 /* AutosensTests.swift */; };
 		3BC0AA3B2DA74C87000DF7B7 /* iob-total.js in Resources */ = {isa = PBXBuildFile; fileRef = 3BC0AA3A2DA74C87000DF7B7 /* iob-total.js */; };
@@ -1225,6 +1227,8 @@
 		3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicISFEnableTests.swift; sourceTree = "<group>"; };
 		3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainLoadingView.swift; sourceTree = "<group>"; };
 		3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataInitializationCoordinator.swift; sourceTree = "<group>"; };
+		3BAE876D2E47F12900FCA8D2 /* DosingEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingEngine.swift; sourceTree = "<group>"; };
+		3BAE876F2E480BE100FCA8D2 /* ForecastResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastResults.swift; sourceTree = "<group>"; };
 		3BBB76A92E01C7070040977D /* MealCob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealCob.swift; sourceTree = "<group>"; };
 		3BBC22622DF5B93900169236 /* AutosensTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensTests.swift; sourceTree = "<group>"; };
 		3BC0AA3A2DA74C87000DF7B7 /* iob-total.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "iob-total.js"; sourceTree = "<group>"; };
@@ -3739,10 +3743,11 @@
 		DD30B9C52E0624C600DA677C /* DetermineBasal */ = {
 			isa = PBXGroup;
 			children = (
-				DD30BA1B2E08BA8100DA677C /* DetermineBasal+Dosing.swift */,
 				DD30BA072E076CAA00DA677C /* DeterminationError.swift */,
+				DD30BA1B2E08BA8100DA677C /* DetermineBasal+Dosing.swift */,
 				DD30BA052E07667000DA677C /* DetermineBasal+Helpers.swift */,
 				DD30B9C62E06257300DA677C /* DetermineBasalGenerator.swift */,
+				3BAE876D2E47F12900FCA8D2 /* DosingEngine.swift */,
 			);
 			path = DetermineBasal;
 			sourceTree = "<group>";
@@ -3750,6 +3755,7 @@
 		DD30B9C82E06295700DA677C /* Forecasts */ = {
 			isa = PBXGroup;
 			children = (
+				3BAE876F2E480BE100FCA8D2 /* ForecastResults.swift */,
 				DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */,
 				DD30B9CD2E062AA300DA677C /* ForecastGenerator+Forecasts.swift */,
 				DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */,
@@ -4677,6 +4683,7 @@
 				3811DE3125C9D49500A708ED /* HomeProvider.swift in Sources */,
 				FE41E4D629463EE20047FD55 /* NightscoutPreferences.swift in Sources */,
 				E013D872273AC6FE0014109C /* GlucoseSimulatorSource.swift in Sources */,
+				3BAE87702E480BE100FCA8D2 /* ForecastResults.swift in Sources */,
 				BD249D862D42FBEC00412DEB /* GlucoseMetricsView.swift in Sources */,
 				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
 				DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */,
@@ -5025,6 +5032,7 @@
 				110AEDEC2C51A0AE00615CC9 /* ShortcutsConfigDataFlow.swift in Sources */,
 				CE7CA3562A064973004BE681 /* StateIntentRequest.swift in Sources */,
 				3BF424C72DF4805A0017CFD9 /* AutosensError.swift in Sources */,
+				3BAE876E2E47F12900FCA8D2 /* DosingEngine.swift in Sources */,
 				DD4C57A82D73ADEA001BFF2C /* RestartLiveActivityIntent.swift in Sources */,
 				19E1F7EA29D082ED005C8D20 /* IconConfigProvider.swift in Sources */,
 				DD09D4822C5986F6003FEA5D /* CalendarEventSettingsRootView.swift in Sources */,

+ 11 - 6
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasal+Helpers.swift

@@ -41,7 +41,7 @@ extension DeterminationGenerator {
         let sorted = glucoseReadings.sorted { $0.date > $1.date }
 
         guard let mostRecentGlucose = sorted.first else { return nil }
-        var mostRecentGlucoseReading: Int = mostRecentGlucose.glucose
+        var mostRecentGlucoseReading = Decimal(mostRecentGlucose.glucose)
         var mostRecentGlucoseDate: Date = mostRecentGlucose.date
 
         var lastDeltas: [Decimal] = []
@@ -59,11 +59,11 @@ extension DeterminationGenerator {
 
             let minutesAgo = (mostRecentGlucoseDate.timeIntervalSince(entry.date) / 60).rounded()
             // compute mg/dL per 5 m as a Decimal:
-            let change = Decimal(mostRecentGlucoseReading - entry.glucose)
+            let change = mostRecentGlucoseReading - Decimal(entry.glucose)
 
             // very-recent (<2.5 m) smooths "now"
             if minutesAgo > -2, minutesAgo <= 2.5 {
-                mostRecentGlucoseReading = (mostRecentGlucoseReading + entry.glucose) / 2
+                mostRecentGlucoseReading = (mostRecentGlucoseReading + Decimal(entry.glucose)) / 2
                 mostRecentGlucoseDate = Date(
                     timeIntervalSince1970: (
                         mostRecentGlucoseDate.timeIntervalSince1970 + entry.date
@@ -93,9 +93,9 @@ extension DeterminationGenerator {
 
         return GlucoseStatus(
             delta: lastDelta.rounded(toPlaces: 2),
-            glucose: Decimal(mostRecentGlucoseReading),
+            glucose: mostRecentGlucoseReading,
             noise: Int(sorted[0].noise ?? 0),
-            shortAvgDelta: shortAvg.rounded(toPlaces: 2),
+            shortAvgDelta: shortAvg.jsRounded(scale: 2),
             longAvgDelta: longAvg.rounded(toPlaces: 2),
             date: mostRecentGlucoseDate,
             lastCalIndex: nil,
@@ -364,7 +364,6 @@ extension DeterminationGenerator {
         // Calculate threshold: minGlucose thresholds: 80->60, 90->65, etc.
         var threshold = minGlucose - 0.5 * (minGlucose - 40)
         threshold = min(max(profile.thresholdSetting, threshold, 60), 120)
-        threshold = threshold.rounded(toPlaces: 0)
 
         return (AdjustedGlucoseTargets(minGlucose: minGlucose, maxGlucose: maxGlucose, targetGlucose: targetGlucose), threshold)
     }
@@ -399,6 +398,7 @@ extension Profile {
     }
 
     /// Calculates the profile ISF at this point in time and applies any overrides to it
+    /// This is `sensitivity` in JS
     func profileSensitivity(at: Date, trioCustomOrefVaribales: TrioCustomOrefVariables) -> Decimal {
         let sensitivity = sensitivityFor(time: at)
         return trioCustomOrefVaribales.override(sensitivity: sensitivity)
@@ -406,6 +406,11 @@ extension Profile {
 }
 
 extension TrioCustomOrefVariables {
+    func overrideFactor() -> Decimal {
+        guard useOverride else { return 1 }
+        return overridePercentage / 100
+    }
+
     func override(sensitivity: Decimal) -> Decimal {
         if useOverride {
             let overrideFactor = overridePercentage / 100

+ 29 - 12
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -93,7 +93,8 @@ enum DeterminationGenerator {
             )
         }
 
-        let sensitivity = computeAdjustedSensitivity(
+        // this is the `sens` variable in JS, it's the adjusted sensitivity
+        let adjustedSensitivity = computeAdjustedSensitivity(
             sensitivity: profile.sens ?? profile.sensitivityFor(time: currentTime),
             sensitivityRatio: sensitivityRatio,
             trioCustomOrefVariables: trioCustomOrefVariables
@@ -154,10 +155,10 @@ enum DeterminationGenerator {
             noise: 1
         )
 
-        let glucoseImpactSeries = buildGlucoseImpactSeries(iobDataSeries: iobData, sensitivity: sensitivity)
+        let glucoseImpactSeries = buildGlucoseImpactSeries(iobDataSeries: iobData, sensitivity: adjustedSensitivity)
         let glucoseImpactSeriesWithZeroTemp = buildGlucoseImpactSeries(
             iobDataSeries: iobData,
-            sensitivity: sensitivity,
+            sensitivity: adjustedSensitivity,
             withZeroTemp: true
         )
 
@@ -186,7 +187,7 @@ enum DeterminationGenerator {
 
         let naiveEventualGlucose: Decimal
         if currentIob > 0 {
-            naiveEventualGlucose = (currentGlucose - (currentIob * sensitivity)).rounded(toPlaces: 0)
+            naiveEventualGlucose = (currentGlucose - (currentIob * adjustedSensitivity)).rounded(toPlaces: 0)
         } else {
             naiveEventualGlucose =
                 (
@@ -195,7 +196,7 @@ enum DeterminationGenerator {
                             currentIob *
                                 min(
                                     profile.profileSensitivity(at: currentTime, trioCustomOrefVaribales: trioCustomOrefVariables),
-                                    sensitivity
+                                    adjustedSensitivity
                                 )
                         )
                 )
@@ -206,7 +207,7 @@ enum DeterminationGenerator {
 
         // Safety: if we ever get an invalid Decimal (very rare with Decimal), handle
         guard eventualGlucose.isFinite else {
-            throw DeterminationError.eventualGlucoseCalculationError(sensitivity: sensitivity, deviation: deviation)
+            throw DeterminationError.eventualGlucoseCalculationError(sensitivity: adjustedSensitivity, deviation: deviation)
         }
 
         let forecastResult = ForecastGenerator.generate(
@@ -222,7 +223,7 @@ enum DeterminationGenerator {
             trioCustomOrefVariables: trioCustomOrefVariables,
             dynamicIsfResult: dynamicIsfResult,
             targetGlucose: adjustedGlucoseTargets.targetGlucose,
-            adjustedSensitivity: sensitivity,
+            adjustedSensitivity: adjustedSensitivity,
             sensitivityRatio: sensitivityRatio,
             naiveEventualGlucose: naiveEventualGlucose,
             eventualGlucose: eventualGlucose,
@@ -237,12 +238,28 @@ enum DeterminationGenerator {
             glucoseImpact: currentGlucoseImpact
         )
 
-        // TODO: STOPPING at LINE 1152
+        let dosingInputs = DosingEngine.prepareDosingInputs(
+            profile: profile,
+            mealData: mealData,
+            forecast: forecastResult,
+            naiveEventualGlucose: naiveEventualGlucose,
+            threshold: threshold,
+            glucoseImpact: currentGlucoseImpact,
+            deviation: deviation,
+            currentBasal: profile.currentBasal ?? profile.basalFor(time: currentTime),
+            overrideFactor: trioCustomOrefVariables.overrideFactor(),
+            adjustedSensitivity: adjustedSensitivity,
+            isfReason: "", // Placeholder
+            tddReason: "", // Placeholder
+            targetLog: "" // Placeholder
+        )
+
+        // TODO: STOPPING at LINE 1264
 
         // FIXME: properly populate all fields!
         let temporaryResult = Determination(
             id: UUID(),
-            reason: "FOR TESTING: output after forecasting",
+            reason: dosingInputs.reason,
             units: nil,
             insulinReq: nil,
             eventualBG: Int(forecastResult.eventualGlucose),
@@ -258,7 +275,7 @@ enum DeterminationGenerator {
                 uam: forecastResult.uam?.map { Int($0.jsRounded()) }
             ),
             deliverAt: currentTime,
-            carbsReq: nil,
+            carbsReq: dosingInputs.carbsRequired?.carbs,
             temp: nil,
             bg: currentGlucose,
             reservoir: nil,
@@ -272,8 +289,8 @@ enum DeterminationGenerator {
             expectedDelta: expectedDelta,
             minGuardBG: forecastResult.minGuardGlucose,
             minPredBG: forecastResult.minForecastedGlucose,
-            threshold: threshold,
-            carbRatio: nil,
+            threshold: threshold.jsRounded(),
+            carbRatio: forecastResult.adjustedCarbRatio.jsRounded(scale: 1),
             received: false,
         )
 

+ 121 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DosingEngine.swift

@@ -0,0 +1,121 @@
+import Foundation
+
+enum DosingEngine {
+    struct DosingInputs {
+        let reason: String
+        let carbsRequired: (carbs: Decimal, minutes: Decimal)?
+    }
+
+    static func prepareDosingInputs(
+        profile: Profile,
+        mealData: ComputedCarbs,
+        forecast: ForecastResult,
+        naiveEventualGlucose: Decimal,
+        threshold: Decimal,
+        glucoseImpact: Decimal,
+        deviation: Decimal,
+        currentBasal: Decimal,
+        overrideFactor: Decimal,
+        adjustedSensitivity: Decimal,
+        isfReason: String,
+        tddReason: String,
+        targetLog: String // This is a pre-formatted string from the JS
+    ) -> DosingInputs {
+        let lastIOBpredBG = forecast.iob.last ?? 0
+        let lastCOBpredBG = forecast.cob?.last
+        let lastUAMpredBG = forecast.uam?.last
+
+        var reason =
+            "\(isfReason), COB: \(mealData.mealCOB), Dev: \(deviation), BGI: \(glucoseImpact), CR: \(forecast.adjustedCarbRatio), Target: \(targetLog), minPredBG \(forecast.minForecastedGlucose), minGuardBG \(forecast.minGuardGlucose), IOBpredBG \(lastIOBpredBG)"
+
+        if let lastCOB = lastCOBpredBG {
+            reason += ", COBpredBG \(lastCOB)"
+        }
+        if let lastUAM = lastUAMpredBG {
+            reason += ", UAMpredBG \(lastUAM)"
+        }
+        reason += tddReason
+        reason += "; " // Start of conclusion
+
+        let carbsRequiredResult = calculateCarbsRequired(
+            profile: profile,
+            mealData: mealData,
+            naiveEventualGlucose: naiveEventualGlucose,
+            minGuardGlucose: forecast.minGuardGlucose,
+            threshold: threshold,
+            iobForecast: forecast.iob,
+            cobForecast: forecast.internalCob,
+            carbImpact: forecast.carbImpact,
+            remainingCarbImpactPeak: forecast.remainingCarbImpactPeak,
+            currentBasal: currentBasal,
+            overrideFactor: overrideFactor,
+            adjustedSensitivity: adjustedSensitivity,
+            adjustedCarbRatio: forecast.adjustedCarbRatio
+        )
+
+        if let result = carbsRequiredResult {
+            reason += "\(result.carbs) add'l carbs req w/in \(result.minutes)m; "
+        }
+
+        return DosingInputs(reason: reason, carbsRequired: carbsRequiredResult)
+    }
+
+    /// Calculates the carbohydrates required to avoid a potential hypoglycemic event.
+    ///
+    /// - Returns: A tuple containing the required carbs and minutes until BG is below threshold, or `nil` if no carbs are required.
+    static func calculateCarbsRequired(
+        profile: Profile,
+        mealData: ComputedCarbs,
+        naiveEventualGlucose: Decimal,
+        minGuardGlucose: Decimal,
+        threshold: Decimal,
+        iobForecast: [Decimal],
+        cobForecast: [Decimal],
+        carbImpact: Decimal,
+        remainingCarbImpactPeak: Decimal,
+        currentBasal: Decimal,
+        overrideFactor: Decimal,
+        adjustedSensitivity: Decimal,
+        adjustedCarbRatio: Decimal
+    ) -> (carbs: Decimal, minutes: Decimal)? {
+        var carbsRequiredGlucose = naiveEventualGlucose
+        if naiveEventualGlucose < 40 {
+            carbsRequiredGlucose = min(minGuardGlucose, naiveEventualGlucose)
+        }
+
+        let glucoseUndershoot = threshold - carbsRequiredGlucose
+
+        var minutesAboveThreshold = Decimal(240)
+
+        let useCOBForecast = mealData.mealCOB > 0 && (carbImpact > 0 || remainingCarbImpactPeak > 0)
+        let forecast = useCOBForecast ? cobForecast : iobForecast
+
+        // At this point in the JS the forecasts have already been rounded
+        for (index, glucose) in forecast.map({ $0.jsRounded() }).enumerated() {
+            if glucose < threshold {
+                minutesAboveThreshold = Decimal(5) * Decimal(index)
+                break
+            }
+        }
+
+        let zeroTempDuration = minutesAboveThreshold
+        let zeroTempEffect = currentBasal * adjustedSensitivity * overrideFactor * zeroTempDuration / 60
+
+        let mealCarbs = mealData.carbs
+        let cobForCarbsRequired = max(0, mealData.mealCOB - (Decimal(0.25) * mealCarbs))
+
+        guard adjustedCarbRatio > 0 else { return nil }
+        let carbSensitivityFactor = adjustedSensitivity / adjustedCarbRatio
+        guard carbSensitivityFactor > 0 else { return nil }
+
+        var carbsRequired = (glucoseUndershoot - zeroTempEffect) / carbSensitivityFactor - cobForCarbsRequired
+        carbsRequired = carbsRequired.rounded(toPlaces: 0)
+
+        let carbsRequiredThreshold = profile.carbsReqThreshold
+        if carbsRequired >= carbsRequiredThreshold, minutesAboveThreshold <= 45 {
+            return (carbs: carbsRequired, minutes: minutesAboveThreshold)
+        }
+
+        return nil
+    }
+}

+ 53 - 10
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator+Forecasts.swift

@@ -1,8 +1,6 @@
 import Foundation
 
 extension ForecastGenerator {
-    // TODO: Dynamic ISF not yet supported
-
     static func forecastIOB(
         startingGlucose: Decimal,
         glucoseImpactSeries: [Decimal],
@@ -12,8 +10,10 @@ extension ForecastGenerator {
         insulinFactor: Decimal?,
         tdd: Decimal,
         adjustmentFactorLogrithmic: Decimal
-    ) -> [Decimal] {
+    ) -> IndividualForecast {
         var result = [startingGlucose]
+        var rawResult = [startingGlucose]
+        var minGuardGlucose = Decimal(999)
         for (glucoseImpact, iob) in zip(glucoseImpactSeries, iobData) {
             let forecastedDeviation = carbImpact * (1 - min(1, Decimal(result.count) / (60 / 5)))
             let lastForecast = result.last!
@@ -31,9 +31,17 @@ extension ForecastGenerator {
                 next = lastForecast + glucoseImpact.jsRounded(scale: 2) + forecastedDeviation
             }
             if result.count < 48 { result.append(next) }
+            if next < minGuardGlucose { minGuardGlucose = next.jsRounded() }
+            rawResult.append(next)
         }
         let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 401) }
-        return ForecastGenerator.trimFlatTails(clampedResult, lookback: 13)
+
+        return IndividualForecast(
+            forecasts: ForecastGenerator.trimFlatTails(clampedResult, lookback: 13),
+            minGuardGlucose: minGuardGlucose,
+            rawForecasts: rawResult,
+            duration: nil
+        )
     }
 
     static func forecastCOB(
@@ -41,10 +49,12 @@ extension ForecastGenerator {
         glucoseImpactSeries: [Decimal],
         carbImpact: Decimal,
         carbImpactParams: CarbImpactParams
-    ) -> [Decimal] {
+    ) -> IndividualForecast {
         // Start with the current BG
         var result = [startingGlucose]
+        var rawResult = [startingGlucose]
 
+        var minGuardGlucose = Decimal(999)
         // Build forecast out to glucoseImpactSeries.count (usually 48)
         for glucoseImpact in glucoseImpactSeries {
             let forecastedDeviation = carbImpact * (1 - min(1, Decimal(result.count) / (60 / 5)))
@@ -74,10 +84,18 @@ extension ForecastGenerator {
                 + triangle
 
             if result.count < 48 { result.append(next) }
+            if next < minGuardGlucose { minGuardGlucose = next.jsRounded() }
+            rawResult.append(next)
         }
 
         let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 1500) }
-        return ForecastGenerator.trimFlatTails(clampedResult, lookback: 13)
+
+        return IndividualForecast(
+            forecasts: ForecastGenerator.trimFlatTails(clampedResult, lookback: 13),
+            minGuardGlucose: minGuardGlucose,
+            rawForecasts: rawResult,
+            duration: nil
+        )
     }
 
     static func forecastUAM(
@@ -91,8 +109,10 @@ extension ForecastGenerator {
         insulinFactor: Decimal?,
         tdd: Decimal,
         adjustmentFactorLogrithmic: Decimal
-    ) -> [Decimal] {
+    ) -> IndividualForecast {
         var result = [startingGlucose]
+        var rawResult = [startingGlucose]
+        var uamDuration: Decimal = 0
 
         let slopeFromDeviations = min(
             mealData.slopeFromMaxDeviation.jsRounded(scale: 2),
@@ -101,6 +121,7 @@ extension ForecastGenerator {
         let ticksInThreeHours: Decimal = 36 // 3 * 60 / 5
 
         let unannouncedCarbImpact = uamCarbImpact
+        var minGuardGlucose = Decimal(999)
 
         for (glucoseImpact, iob) in zip(glucoseImpactSeries, iobData) {
             let forecastedDeviation = carbImpact * (1 - min(1, Decimal(result.count) / (60 / 5)))
@@ -121,6 +142,10 @@ extension ForecastGenerator {
                 maxForecastedUnannouncedCarbImpact
             )
 
+            if forecastedUnannouncedCarbImpact > 0 {
+                uamDuration = (Decimal(result.count) + 1) * 5 / 60
+            }
+
             let lastForecast = result.last!
             let next: Decimal
             if let insulinFactor = insulinFactor, dynamicIsfState == .logrithmic {
@@ -138,10 +163,18 @@ extension ForecastGenerator {
             }
 
             if result.count < 48 { result.append(next) }
+            if next < minGuardGlucose { minGuardGlucose = next.jsRounded() }
+            rawResult.append(next)
         }
 
         let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 401) }
-        return ForecastGenerator.trimFlatTails(clampedResult, lookback: 13)
+
+        return IndividualForecast(
+            forecasts: ForecastGenerator.trimFlatTails(clampedResult, lookback: 13),
+            minGuardGlucose: minGuardGlucose,
+            rawForecasts: rawResult,
+            duration: uamDuration.jsRounded(scale: 1)
+        )
     }
 
     static func forecastZT(
@@ -153,8 +186,11 @@ extension ForecastGenerator {
         insulinFactor: Decimal?,
         tdd: Decimal,
         adjustmentFactorLogrithmic: Decimal
-    ) -> [Decimal] {
+    ) -> IndividualForecast {
         var result = [startingGlucose]
+        var rawResult = [startingGlucose]
+
+        var minGuardGlucose = Decimal(999)
         // Potential bug: ZT doesn't use forecastedDeviation like IoB does
         for (glucoseImpact, iob) in zip(glucoseImpactSeriesWithZeroTemp, iobData) {
             let lastForecast = result.last!
@@ -173,9 +209,16 @@ extension ForecastGenerator {
             }
 
             if result.count < 48 { result.append(next) }
+            if next < minGuardGlucose { minGuardGlucose = next.jsRounded() }
+            rawResult.append(next)
         }
         let clampedResult = result.map { $0.clamp(lowerBound: 39, upperBound: 401) }
-        return ForecastGenerator.trimZTTails(series: clampedResult, targetBG: targetBG)
+        return IndividualForecast(
+            forecasts: ForecastGenerator.trimZTTails(series: clampedResult, targetBG: targetBG),
+            minGuardGlucose: minGuardGlucose,
+            rawForecasts: rawResult,
+            duration: nil
+        )
     }
 
     static func adjustedGlucoseImpactForLogrithmicDynamicIsf(

+ 162 - 110
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift

@@ -54,7 +54,7 @@ enum ForecastGenerator {
         let uamCarbImpact = (minDelta - currentGlucoseImpact).jsRounded(scale: 1)
 
         // JS oref initializes all xxxPredBGs array with current glucose, we do the same, then generate
-        let iobForecast = forecastIOB(
+        let iobResult = forecastIOB(
             startingGlucose: glucose,
             glucoseImpactSeries: glucoseImpactSeries,
             iobData: iobData,
@@ -65,14 +65,14 @@ enum ForecastGenerator {
             adjustmentFactorLogrithmic: profile.adjustmentFactor
         )
 
-        let cobForecast = forecastCOB(
+        let cobResult = forecastCOB(
             startingGlucose: glucose,
             glucoseImpactSeries: glucoseImpactSeries,
             carbImpact: carbImpact,
             carbImpactParams: carbImpactParams
         )
 
-        let uamForecast = forecastUAM(
+        let uamResult = forecastUAM(
             startingGlucose: glucose,
             glucoseImpactSeries: glucoseImpactSeries,
             mealData: mealData,
@@ -85,7 +85,7 @@ enum ForecastGenerator {
             adjustmentFactorLogrithmic: profile.adjustmentFactor
         )
 
-        let ztForecast = forecastZT(
+        let ztResult = forecastZT(
             startingGlucose: glucose,
             glucoseImpactSeriesWithZeroTemp: glucoseImpactSeriesWithZeroTemp,
             targetBG: targetGlucose,
@@ -96,22 +96,28 @@ enum ForecastGenerator {
             adjustmentFactorLogrithmic: profile.adjustmentFactor
         )
 
-        let computedForecastSelection = Self.computeForecastSelection(
-            iob: iobForecast,
-            cob: cobForecast,
-            uam: uamForecast,
-            zt: ztForecast,
-            currentGlucose: glucose
+        let initialForecasts = calculateMinMaxForecastedGlucose(
+            currentGlucose: glucose,
+            iobForecast: iobResult,
+            cobForecast: cobResult,
+            uamForecast: uamResult,
+            ztForecast: ztResult,
+            carbImpactDuration: carbImpactParams.carbImpactDuration,
+            remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
+            uamEnabled: profile.enableUAM
         )
 
         let blendedForecasts = Self.blendForecasts(
-            selectionResult: computedForecastSelection,
+            iobResult: initialForecasts.iob,
+            cobResult: initialForecasts.cob,
+            uamResult: initialForecasts.uam,
+            ztResult: initialForecasts.zt,
             carbs: mealData.carbs,
             mealCOB: mealData.mealCOB,
             enableUAM: profile.enableUAM,
             carbImpactDuration: carbImpactParams.carbImpactDuration,
             remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
-            fractionCarbsLeft: mealData.carbs > 0 ? mealData.mealCOB / mealData.carbs : 0,
+            fractionCarbsLeft: mealData.carbs > 0 ? mealData.mealCOB / mealData.carbs : Decimal(0),
             threshold: threshold,
             targetGlucose: profile.targetBg ?? 100,
             currentGlucose: glucose
@@ -120,28 +126,119 @@ enum ForecastGenerator {
         var eventualGlucose = eventualGlucose
         var finalCobForecast: [Decimal]?
         if mealData.mealCOB > 0, carbImpact > 0 || carbImpactParams.remainingCarbImpactPeak > 0 {
-            finalCobForecast = cobForecast
-            if let lastCobGlucose = cobForecast.last {
+            finalCobForecast = cobResult.forecasts
+            if let lastCobGlucose = cobResult.forecasts.last {
                 eventualGlucose = max(eventualGlucose, lastCobGlucose)
             }
         }
 
         var finalUamForecast: [Decimal]?
         if profile.enableUAM, carbImpact > 0 || carbImpactParams.remainingCarbImpactPeak > 0 {
-            finalUamForecast = uamForecast
-            if let lastUamGlucose = uamForecast.last {
+            finalUamForecast = uamResult.forecasts
+            if let lastUamGlucose = uamResult.forecasts.last {
                 eventualGlucose = max(eventualGlucose, lastUamGlucose)
             }
         }
 
         return ForecastResult(
-            iob: iobForecast,
+            iob: iobResult.forecasts,
             cob: finalCobForecast,
             uam: finalUamForecast,
-            zt: ztForecast,
+            zt: ztResult.forecasts,
+            internalCob: cobResult.forecasts,
+            internalUam: uamResult.forecasts,
             eventualGlucose: eventualGlucose,
             minForecastedGlucose: blendedForecasts.minForecastedGlucose,
-            minGuardGlucose: blendedForecasts.minGuardGlucose
+            minGuardGlucose: blendedForecasts.minGuardGlucose,
+            carbImpact: carbImpact,
+            remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
+            adjustedCarbRatio: adjustedCarbRatio
+        )
+    }
+
+    /// This function does the min/max glucose forecasts at the end of the main forecast loop
+    /// in JS. It operates on raw forecasts and there is a cross dependency between IOB
+    /// predictions and the UAM predictions, so we need to pull out this logic here
+    static func calculateMinMaxForecastedGlucose(
+        currentGlucose: Decimal,
+        iobForecast: IndividualForecast,
+        cobForecast: IndividualForecast,
+        uamForecast: IndividualForecast,
+        ztForecast: IndividualForecast,
+        carbImpactDuration: Decimal,
+        remainingCarbImpactPeak: Decimal,
+        uamEnabled: Bool
+    ) -> AllForecasts {
+        // FIXME: we need to make sure that these will all be the same length
+        // but since they're running their loops on the same data they should be
+        let minCount = min(
+            iobForecast.rawForecasts.count,
+            cobForecast.rawForecasts.count,
+            uamForecast.rawForecasts.count
+        )
+
+        var maxIobForecastGlucose = currentGlucose
+        var maxCobForecastGlucose = currentGlucose
+        var maxUamForecastGlucose = currentGlucose
+        var minIobForecastGlucose = Decimal(999)
+        var minCobForecastGlucose = Decimal(999)
+        var minUamForecastGlucose = Decimal(999)
+
+        let insulinPeak5m = 18
+
+        // start at 1 because the first entry is currentGlucose
+        for index in 1 ..< minCount {
+            let length = index + 1
+            let iob = iobForecast.rawForecasts[index]
+            let cob = cobForecast.rawForecasts[index]
+            let uam = uamForecast.rawForecasts[index]
+
+            // the max calculations don't get rounded in JS
+            if length > insulinPeak5m, iob < minIobForecastGlucose {
+                minIobForecastGlucose = iob.jsRounded()
+            }
+            if iob > maxIobForecastGlucose {
+                maxIobForecastGlucose = iob
+            }
+            if carbImpactDuration != 0 || remainingCarbImpactPeak > 0, length > insulinPeak5m, cob < minCobForecastGlucose {
+                minCobForecastGlucose = cob.jsRounded()
+            }
+            if carbImpactDuration != 0 || remainingCarbImpactPeak > 0, cob > maxCobForecastGlucose {
+                maxCobForecastGlucose = cob
+            }
+            if uamEnabled, length > 12, uam < minUamForecastGlucose {
+                minUamForecastGlucose = uam.jsRounded()
+            }
+            // BUG: I can't tell if the comparison against maxIobForecastGlucose is
+            // intentional or not, but this is what is in JS
+            if uamEnabled, uam > maxIobForecastGlucose {
+                maxUamForecastGlucose = uam
+            }
+        }
+        return AllForecasts(
+            iob: IOBForecast(
+                forecasts: iobForecast.forecasts,
+                minGuardGlucose: iobForecast.minGuardGlucose,
+                minForecastGlucose: minIobForecastGlucose,
+                maxForecastGlucose: maxIobForecastGlucose
+            ),
+            zt: ZTForecast(
+                forecasts: ztForecast.forecasts,
+                minGuardGlucose: ztForecast.minGuardGlucose
+            ),
+            cob: COBForecast(
+                forecasts: cobForecast.forecasts,
+                minGuardGlucose: cobForecast.minGuardGlucose,
+                minForecastGlucose: minCobForecastGlucose,
+                maxForecastGlucose: maxCobForecastGlucose
+            ),
+            uam: UAMForecast(
+                forecasts: uamForecast.forecasts,
+                minGuardGlucose: uamForecast.minGuardGlucose,
+                minForecastGlucose: minUamForecastGlucose,
+                maxForecastGlucose: maxUamForecastGlucose,
+                duration: uamForecast.duration!
+            ) // I don't love the force unwrap here but it should always be set
         )
     }
 
@@ -178,57 +275,12 @@ enum ForecastGenerator {
         return remainingCarbAbsorptionTime
     }
 
-    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,
+        iobResult: IOBForecast,
+        cobResult: COBForecast,
+        uamResult: UAMForecast,
+        ztResult: ZTForecast,
         carbs: Decimal,
         mealCOB _: Decimal,
         enableUAM: Bool,
@@ -240,66 +292,66 @@ enum ForecastGenerator {
         currentGlucose: Decimal
     ) -> ForecastBlendingResult {
         // 1. Calculate minZTUAMForecastGlucose ("minZTUAMPredBG" in JS)
-        var minZTUAMForecastGlucose = selectionResult.minUAMForecastGlucose
-        if selectionResult.minZTGuardGlucose < threshold {
-            minZTUAMForecastGlucose = ((selectionResult.minUAMForecastGlucose + selectionResult.minZTGuardGlucose) / 2)
+        var minZTUAMForecastGlucose = uamResult.minForecastGlucose
+        if ztResult.minGuardGlucose < threshold {
+            minZTUAMForecastGlucose = ((uamResult.minForecastGlucose + ztResult.minGuardGlucose) / 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)
+        } else if ztResult.minGuardGlucose < targetGlucose {
+            let blendPct = (ztResult.minGuardGlucose - threshold) / (targetGlucose - threshold)
+            let blendedMinZTGuardGlucose = uamResult.minForecastGlucose * blendPct + ztResult.minGuardGlucose * (1 - blendPct)
+            minZTUAMForecastGlucose = ((uamResult.minForecastGlucose + blendedMinZTGuardGlucose) / 2).rounded()
+        } else if ztResult.minGuardGlucose > uamResult.minForecastGlucose {
+            minZTUAMForecastGlucose = ((uamResult.minForecastGlucose + ztResult.minGuardGlucose) / 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
+        let avgerageForecastGlucose: Decimal
+        if uamResult.minForecastGlucose < 999, cobResult.minForecastGlucose < 999 {
+            avgerageForecastGlucose = (
+                (1 - fractionCarbsLeft) * (uamResult.forecasts.last ?? currentGlucose) + fractionCarbsLeft *
+                    (cobResult.forecasts.last ?? currentGlucose)
             ).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 if cobResult.minForecastGlucose < 999 {
+            avgerageForecastGlucose =
+                (((iobResult.forecasts.last ?? currentGlucose) + (cobResult.forecasts.last ?? currentGlucose)) / 2)
+                    .rounded()
+        } else if uamResult.minForecastGlucose < 999 {
+            avgerageForecastGlucose =
+                (((iobResult.forecasts.last ?? currentGlucose) + (uamResult.forecasts.last ?? currentGlucose)) / 2)
+                    .rounded()
         } else {
-            avgForecastGlucose = selectionResult.lastIOBForecastGlucose.rounded()
+            avgerageForecastGlucose = (iobResult.forecasts.last ?? currentGlucose).rounded()
         }
-        let adjustedAvgForecastGlucose = max(avgForecastGlucose, selectionResult.minZTGuardGlucose)
+        let adjustedAverageForecastGlucose = max(avgerageForecastGlucose, ztResult.minGuardGlucose)
 
         // 3. minGuardGlucose
         let minGuardGlucose: Decimal
         if carbImpactDuration > 0 || remainingCarbImpactPeak > 0 {
             if enableUAM {
                 minGuardGlucose = (
-                    fractionCarbsLeft * selectionResult
-                        .minCOBGuardGlucose + (1 - fractionCarbsLeft) * selectionResult.minUAMGuardGlucose
-                ).rounded()
+                    fractionCarbsLeft * cobResult.minGuardGlucose + (1 - fractionCarbsLeft) * uamResult.minGuardGlucose
+                ).jsRounded()
             } else {
-                minGuardGlucose = selectionResult.minCOBGuardGlucose.rounded()
+                minGuardGlucose = cobResult.minGuardGlucose.rounded()
             }
         } else if enableUAM {
-            minGuardGlucose = selectionResult.minUAMGuardGlucose.rounded()
+            minGuardGlucose = uamResult.minGuardGlucose.rounded()
         } else {
-            minGuardGlucose = selectionResult.minIOBGuardGlucose.rounded()
+            minGuardGlucose = iobResult.minGuardGlucose.rounded()
         }
 
         // 4. minForecastedGlucose ("minPredBG")
-        var minForecastedGlucose: Decimal = selectionResult.minIOBForecastGlucose.rounded()
+        var minForecastedGlucose: Decimal = iobResult.minForecastGlucose.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
+            if !enableUAM, cobResult.minForecastGlucose < 999 {
+                minForecastedGlucose = max(iobResult.minForecastGlucose, cobResult.minForecastGlucose)
+            } else if cobResult.minForecastGlucose < 999 {
+                let blendedMinForecastGlucose = fractionCarbsLeft * cobResult
+                    .minForecastGlucose + (1 - fractionCarbsLeft) * minZTUAMForecastGlucose
                 minForecastedGlucose = max(
-                    selectionResult.minIOBForecastGlucose,
-                    selectionResult.minCOBForecastGlucose,
+                    iobResult.minForecastGlucose,
+                    cobResult.minForecastGlucose,
                     blendedMinForecastGlucose
                 ).rounded()
             } else if enableUAM {
@@ -308,20 +360,20 @@ enum ForecastGenerator {
                 minForecastedGlucose = minGuardGlucose
             }
         } else if enableUAM {
-            minForecastedGlucose = max(selectionResult.minIOBForecastGlucose, minZTUAMForecastGlucose).rounded()
+            minForecastedGlucose = max(iobResult.minForecastGlucose, minZTUAMForecastGlucose).rounded()
         }
 
         // Clamp minForecastedGlucose to not exceed adjustedAvgForecastGlucose
-        minForecastedGlucose = min(minForecastedGlucose, adjustedAvgForecastGlucose)
+        minForecastedGlucose = min(minForecastedGlucose, adjustedAverageForecastGlucose)
 
         // JS: If maxCOBPredBG > bg, don't trust UAM too much
-        if selectionResult.maxCOBForecastGlucose > currentGlucose {
-            minForecastedGlucose = min(minForecastedGlucose, selectionResult.maxCOBForecastGlucose)
+        if cobResult.maxForecastGlucose > currentGlucose {
+            minForecastedGlucose = min(minForecastedGlucose, cobResult.maxForecastGlucose)
         }
 
         return ForecastBlendingResult(
             minForecastedGlucose: minForecastedGlucose,
-            avgForecastedGlucose: adjustedAvgForecastGlucose,
+            avgForecastedGlucose: adjustedAverageForecastGlucose,
             minGuardGlucose: minGuardGlucose
         )
     }

+ 42 - 0
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastResults.swift

@@ -0,0 +1,42 @@
+import Foundation
+
+struct IOBForecast {
+    let forecasts: [Decimal] // The final, trimmed array for output
+    let minGuardGlucose: Decimal // The absolute min of the untrimmed array
+    let minForecastGlucose: Decimal // The min after the initial 90-min peak
+    let maxForecastGlucose: Decimal // The absolute max of the untrimmed array
+}
+
+struct COBForecast {
+    let forecasts: [Decimal] // The final, trimmed array for output
+    let minGuardGlucose: Decimal // The absolute min of the untrimmed array
+    let minForecastGlucose: Decimal // The min after the initial 90-min peak
+    let maxForecastGlucose: Decimal // The absolute max of the untrimmed array
+}
+
+struct UAMForecast {
+    let forecasts: [Decimal] // The final, trimmed array for output
+    let minGuardGlucose: Decimal // The absolute min of the untrimmed array
+    let minForecastGlucose: Decimal // The min after the initial 60-min peak
+    let maxForecastGlucose: Decimal // The absolute max of the untrimmed array
+    let duration: Decimal // The calculated UAM duration in hours
+}
+
+struct ZTForecast {
+    let forecasts: [Decimal] // The final, trimmed array for output
+    let minGuardGlucose: Decimal // The absolute min of the untrimmed array
+}
+
+struct IndividualForecast {
+    let forecasts: [Decimal]
+    let minGuardGlucose: Decimal
+    let rawForecasts: [Decimal]
+    let duration: Decimal? // only set by UAM
+}
+
+struct AllForecasts {
+    let iob: IOBForecast
+    let zt: ZTForecast
+    let cob: COBForecast
+    let uam: UAMForecast
+}

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

@@ -52,7 +52,6 @@ enum OrefFunction: String, Codable {
                 "rate",
                 "duration",
                 "deliverAt",
-                "carbsReq",
                 "temp",
                 "reservoir",
                 "ISF",
@@ -61,7 +60,6 @@ enum OrefFunction: String, Codable {
                 "insulinForManualBolus",
                 "manualBolusErrorString",
                 "minDelta",
-                "CR",
                 "received",
                 "reason",
                 // in JS but not in Swift

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

@@ -5,9 +5,14 @@ struct ForecastResult {
     public let cob: [Decimal]?
     public let uam: [Decimal]?
     public let zt: [Decimal]
+    public let internalCob: [Decimal] // non optional, used downstream
+    public let internalUam: [Decimal] // non optional, used downstream
     public let eventualGlucose: Decimal
     public let minForecastedGlucose: Decimal
     public let minGuardGlucose: Decimal
+    public let carbImpact: Decimal
+    public let remainingCarbImpactPeak: Decimal
+    public let adjustedCarbRatio: Decimal
 }
 
 struct ForecastSelectionResult {