浏览代码

Very rough outlining for determine basal WIP

Deniz Cengiz 11 月之前
父节点
当前提交
13f3ed14dd

+ 34 - 2
Trio.xcodeproj/project.pbxproj

@@ -201,6 +201,7 @@
 		38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FB2737E53800574A46 /* MainStateModel.swift */; };
 		38FEF3FE2738083E00574A46 /* CGMSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF3FD2738083E00574A46 /* CGMSettingsProvider.swift */; };
 		38FEF413273B317A00574A46 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF412273B317A00574A46 /* HKUnit.swift */; };
+		3B0B4E6C2DE1439F005C6627 /* LockedResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0B4E6B2DE1439A005C6627 /* LockedResolver.swift */; };
 		3B139EF32DF06CE100D40797 /* AutosensGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B139EF22DF06CDC00D40797 /* AutosensGenerator.swift */; };
 		3B1C5C292D68E1E3004E9273 /* IobCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C242D68E1E3004E9273 /* IobCalculation.swift */; };
 		3B1C5C2A2D68E1E3004E9273 /* IobGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C262D68E1E3004E9273 /* IobGenerator.swift */; };
@@ -215,7 +216,6 @@
 		3B1C5C452D68E269004E9273 /* IobTotalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C382D68E269004E9273 /* IobTotalTests.swift */; };
 		3B1C5C472D68E269004E9273 /* IobCalculateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C352D68E269004E9273 /* IobCalculateTests.swift */; };
 		3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1C5C362D68E269004E9273 /* IobHistoryTests.swift */; };
-		3B0B4E6C2DE1439F005C6627 /* LockedResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0B4E6B2DE1439A005C6627 /* LockedResolver.swift */; };
 		3B2F77862D7E52ED005ED9FA /* TDD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77852D7E52ED005ED9FA /* TDD.swift */; };
 		3B2F77882D7E5387005ED9FA /* CurrentTDDSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */; };
 		3B3B57C92DA07B3400849D16 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3B57C82DA07B3400849D16 /* GoogleService-Info.plist */; };
@@ -630,6 +630,10 @@
 		DD2CC85C2D25DA1000445446 /* GlucoseTargetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2CC85B2D25D9CE00445446 /* GlucoseTargetsView.swift */; };
 		DD3078682D42F5CE00DE0490 /* WatchGlucoseObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3078672D42F5CE00DE0490 /* WatchGlucoseObject.swift */; };
 		DD30786A2D42F94000DE0490 /* GarminDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3078692D42F94000DE0490 /* GarminDevice.swift */; };
+		DD30B9C72E06257900DA677C /* DetermineBasalGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9C62E06257300DA677C /* DetermineBasalGenerator.swift */; };
+		DD30B9CA2E062A3400DA677C /* ForecastGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */; };
+		DD30B9CC2E062A7000DA677C /* ForecastResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9CB2E062A7000DA677C /* ForecastResult.swift */; };
+		DD30B9CE2E062AA300DA677C /* SingleForecasting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9CD2E062AA300DA677C /* SingleForecasting.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 */; };
@@ -1118,6 +1122,7 @@
 		38FEF3FB2737E53800574A46 /* MainStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStateModel.swift; sourceTree = "<group>"; };
 		38FEF3FD2738083E00574A46 /* CGMSettingsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMSettingsProvider.swift; sourceTree = "<group>"; };
 		38FEF412273B317A00574A46 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = "<group>"; };
+		3B0B4E6B2DE1439A005C6627 /* LockedResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockedResolver.swift; sourceTree = "<group>"; };
 		3B139EF22DF06CDC00D40797 /* AutosensGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensGenerator.swift; sourceTree = "<group>"; };
 		3B1C5C242D68E1E3004E9273 /* IobCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobCalculation.swift; sourceTree = "<group>"; };
 		3B1C5C252D68E1E3004E9273 /* IobError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobError.swift; sourceTree = "<group>"; };
@@ -1132,7 +1137,6 @@
 		3B1C5C382D68E269004E9273 /* IobTotalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobTotalTests.swift; sourceTree = "<group>"; };
 		3B1C5C3D2D68E269004E9273 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
 		3B1C5C3E2D68E269004E9273 /* IobJsonTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobJsonTypes.swift; sourceTree = "<group>"; };
-		3B0B4E6B2DE1439A005C6627 /* LockedResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockedResolver.swift; sourceTree = "<group>"; };
 		3B2F77852D7E52ED005ED9FA /* TDD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDD.swift; sourceTree = "<group>"; };
 		3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentTDDSetup.swift; sourceTree = "<group>"; };
 		3B3B57C82DA07B3400849D16 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
@@ -1531,6 +1535,10 @@
 		DD2CC85B2D25D9CE00445446 /* GlucoseTargetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseTargetsView.swift; sourceTree = "<group>"; };
 		DD3078672D42F5CE00DE0490 /* WatchGlucoseObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchGlucoseObject.swift; sourceTree = "<group>"; };
 		DD3078692D42F94000DE0490 /* GarminDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminDevice.swift; sourceTree = "<group>"; };
+		DD30B9C62E06257300DA677C /* DetermineBasalGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalGenerator.swift; sourceTree = "<group>"; };
+		DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastGenerator.swift; sourceTree = "<group>"; };
+		DD30B9CB2E062A7000DA677C /* ForecastResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastResult.swift; sourceTree = "<group>"; };
+		DD30B9CD2E062AA300DA677C /* SingleForecasting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleForecasting.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>"; };
@@ -2793,6 +2801,8 @@
 		3B5CD1EB2D4912A600CE213C /* OpenAPSSwift */ = {
 			isa = PBXGroup;
 			children = (
+				DD30B9C82E06295700DA677C /* Forecasts */,
+				DD30B9C52E0624C600DA677C /* DetermineBasal */,
 				3B139EF12DF06CD100D40797 /* Autosens */,
 				3B5CD2A42D4AEA5D00CE213C /* Extensions */,
 				3B1C5C282D68E1E3004E9273 /* Iob */,
@@ -2845,6 +2855,7 @@
 		3B5CD2B22D4AEA6600CE213C /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				DD30B9CB2E062A7000DA677C /* ForecastResult.swift */,
 				3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */,
 				3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */,
 				3B1C5C2D2D68E220004E9273 /* ComputedPumpHistoryEvent.swift */,
@@ -3663,6 +3674,23 @@
 			path = View;
 			sourceTree = "<group>";
 		};
+		DD30B9C52E0624C600DA677C /* DetermineBasal */ = {
+			isa = PBXGroup;
+			children = (
+				DD30B9C62E06257300DA677C /* DetermineBasalGenerator.swift */,
+			);
+			path = DetermineBasal;
+			sourceTree = "<group>";
+		};
+		DD30B9C82E06295700DA677C /* Forecasts */ = {
+			isa = PBXGroup;
+			children = (
+				DD30B9CD2E062AA300DA677C /* SingleForecasting.swift */,
+				DD30B9C92E062A3300DA677C /* ForecastGenerator.swift */,
+			);
+			path = Forecasts;
+			sourceTree = "<group>";
+		};
 		DD3A3CEC2D29CFBA00AE478E /* Helper */ = {
 			isa = PBXGroup;
 			children = (
@@ -4735,6 +4763,7 @@
 				DD4FFF332D458EE600B6CFF9 /* GarminWatchState.swift in Sources */,
 				DD6B7CB22C7B6F0800B75029 /* Rounding.swift in Sources */,
 				38DAB28A260D349500F74C1A /* FetchGlucoseManager.swift in Sources */,
+				DD30B9CE2E062AA300DA677C /* SingleForecasting.swift in Sources */,
 				BDB8998A2C565D0C006F3298 /* CarbsGlucose+helper.swift in Sources */,
 				BD7DB88E2D2C4A17003D3155 /* BolusCalculationManager.swift in Sources */,
 				38F37828261260DC009DB701 /* Color+Extensions.swift in Sources */,
@@ -4787,6 +4816,7 @@
 				58645B9D2CA2D275008AFCE7 /* DeterminationSetup.swift in Sources */,
 				DDFF202F2DB1D14500AB8A96 /* NotificationPermissionStepView.swift in Sources */,
 				491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */,
+				DD30B9CC2E062A7000DA677C /* ForecastResult.swift in Sources */,
 				491D6FBE2D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift in Sources */,
 				491D6FBF2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift in Sources */,
 				491D6FC02D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift in Sources */,
@@ -4861,6 +4891,7 @@
 				3B139EF32DF06CE100D40797 /* AutosensGenerator.swift in Sources */,
 				DDC38E102D9B377800ADCB46 /* OnboardingView+Util.swift in Sources */,
 				BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */,
+				DD30B9C72E06257900DA677C /* DetermineBasalGenerator.swift in Sources */,
 				3B1C5C332D68E233004E9273 /* PumpHistory+copy.swift in Sources */,
 				3B1C5C342D68E233004E9273 /* TimeExtensions.swift in Sources */,
 				1BBB001DAD60F3B8CEA4B1C7 /* ISFEditorStateModel.swift in Sources */,
@@ -5015,6 +5046,7 @@
 				0F7A65FBD2CD8D6477ED4539 /* GlucoseNotificationSettingsProvider.swift in Sources */,
 				3171D2818C7C72CD1584BB5E /* GlucoseNotificationSettingsStateModel.swift in Sources */,
 				DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */,
+				DD30B9CA2E062A3400DA677C /* ForecastGenerator.swift in Sources */,
 				DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */,
 				DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */,
 				BDC530FF2D0F6BE300088832 /* ContactImageManager.swift in Sources */,

+ 50 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -0,0 +1,50 @@
+import Foundation
+
+struct DeterminationRequest {
+    let glucose:        [BloodGlucose]
+    let pumpHistory:    [PumpHistoryEvent]
+    let carbTreatments: [MealInput]
+    let currentTemp:    TempBasal
+    let preferences:    Preferences
+    let custom:         TrioCustomOrefVariables
+    let date:           Date
+}
+
+protocol SMBProvider {
+    func isSMBEnabled(
+        glucose: BloodGlucose,
+        profile: Profile,
+        autosens: Autosens,
+        date: Date
+    ) -> Bool
+    
+    // TODO: handle oref JS's enable_smb() logic
+}
+
+struct DeterminationGenerator {
+    let profileGenerator: ProfileGenerator
+    let iobGenerator: IobGenerator
+    let autosensGenerator: AutosensGenerator
+    let mealProcessor: MealTotal
+    let smbProvider: SMBProvider
+
+    func generate(
+        request _: DeterminationRequest
+    ) throws -> Determination? {
+        
+        // FIXME: implement... (return type will not be Optional; just to shut up the compiler)
+        
+        /// Current determine basal (if we ignore forecasting logic; already modularized) does:
+        /// 1. Validate CGM → cancel if needed
+        /// 2. Override basal → log
+        /// 3. Load targets → error if missing
+        /// 4. Adjust sensitivity → maybe adjust basal/target
+        /// 5. Check IOB consistency → cancel if needed
+        /// 6. Compute deviation/eventualBG → log
+        /// 7. Ignore Forecast & but guard-BG
+        /// 8. Compute carbsReq → we could move this to MEAL
+        /// 9. Decide temp basal → we could do a tempBasalGenerator ?
+        
+        nil
+    }
+}

+ 73 - 0
Trio/Sources/APS/OpenAPSSwift/Forecasts/ForecastGenerator.swift

@@ -0,0 +1,73 @@
+/// The top-level orchestrator
+struct ForecastGenerator {
+    let iob: SingleForecasting
+    let cob: SingleForecasting
+    let uam: SingleForecasting
+    let zt: SingleForecasting
+
+    init(
+        iob: SingleForecasting = IOBForecastGenerator(),
+        cob: SingleForecasting = COBForecastGenerator(),
+        uam: SingleForecasting = UAMForecastGenerator(),
+        zt: SingleForecasting = ZTForecastGenerator()
+    ) {
+        self.iob = iob
+        self.cob = cob
+        self.uam = uam
+        self.zt = zt
+    }
+
+    public func generate(
+        glucose: Double,
+        glucoseImpactSeries: [Double],
+        mealData: ComputedCarbs,
+        profile: Profile
+    ) -> ForecastResult {
+        let carbImpact = Double(mealData.currentDeviation) * Double(profile.carbRatio!) / Double(profile.sens!)
+        let deviation = mealData.currentDeviation
+
+        return ForecastResult(
+            iob: iob.forecast(
+                startingGlucose: glucose,
+                glucoseImpactSeries: glucoseImpactSeries,
+                mealData: mealData,
+                profile: profile,
+                carbImpact: carbImpact,
+                deviation: Double(deviation)
+            ),
+            cob: cob.forecast(
+                startingGlucose: glucose,
+                glucoseImpactSeries: glucoseImpactSeries,
+                mealData: mealData,
+                profile: profile,
+                carbImpact: carbImpact,
+                deviation: Double(deviation)
+            ),
+            uam: uam.forecast(
+                startingGlucose: glucose,
+                glucoseImpactSeries: glucoseImpactSeries,
+                mealData: mealData,
+                profile: profile,
+                carbImpact: carbImpact,
+                deviation: Double(deviation)
+            ),
+            zt: zt.forecast(
+                startingGlucose: glucose,
+                glucoseImpactSeries: glucoseImpactSeries,
+                mealData: mealData,
+                profile: profile,
+                carbImpact: carbImpact,
+                deviation: Double(deviation)
+            )
+        )
+    }
+
+    /// Trims trailing flat-line points beyond a “lookback” count
+    public static func trimFlatTails(_ series: [Double], lookback: Int) -> [Double] {
+        var s = series
+        while s.count > lookback, s.suffix(2)[0] == s.suffix(2)[1] {
+            s.removeLast()
+        }
+        return s
+    }
+}

+ 134 - 0
Trio/Sources/APS/OpenAPSSwift/Forecasts/SingleForecasting.swift

@@ -0,0 +1,134 @@
+/// Common interface for a single forecast pipeline
+protocol SingleForecasting {
+    /// - Parameters:
+    ///   - startingGlucose: the current glucose
+    ///   - glucoseImpactSeries:  the series of BGI (insulin effect) ticks
+    ///   - mealData:   absorption & COB info
+    ///   - profile:    user profile (for carbRatio, DIA, etc)
+    ///   - carbImpact:         current carb impact (mg/dL per 5m)
+    ///   - deviation:  current deviation (mg/dL per 5m)
+    /// - Returns: a capped/clamped array of future BGs, one per 5-minute interval
+    func forecast(
+        startingGlucose: Double,
+        glucoseImpactSeries: [Double],
+        mealData: ComputedCarbs,
+        profile: Profile,
+        carbImpact: Double,
+        deviation: Double
+    ) -> [Double]
+}
+
+/// Forecast sub-generator for insulin-only effect (IOB)
+struct IOBForecastGenerator: SingleForecasting {
+    public func forecast(
+        startingGlucose: Double,
+        glucoseImpactSeries: [Double],
+        mealData _: ComputedCarbs,
+        profile _: Profile,
+        carbImpact _: Double,
+        deviation: Double
+    ) -> [Double] {
+        var result = [startingGlucose]
+        for (count, glucoseImpact) in glucoseImpactSeries.enumerated() {
+            let predDev = deviation * (1 - min(1, Double(count) / (60 / 5)))
+            let next = result.last! + glucoseImpact + predDev
+            result.append(next.clamp(lowerBound: 39, upperBound: 401))
+        }
+        return ForecastGenerator.trimFlatTails(result, lookback: 90 / 5)
+    }
+}
+
+/// Forecast sub-generator for carb-only effect (COB + UAM piece)
+struct COBForecastGenerator: SingleForecasting {
+    public func forecast(
+        startingGlucose: Double,
+        glucoseImpactSeries: [Double],
+        mealData _: ComputedCarbs,
+        profile: Profile,
+        carbImpact: Double,
+        deviation: Double
+    ) -> [Double] {
+        var result = [startingGlucose]
+
+        guard let sensitivity = profile.sens else {
+            fatalError("Profile must have a `sens` value")
+        }
+
+        guard let carbRatio = profile.carbRatio else {
+            fatalError("Profile must have a `carbRatio` value")
+        }
+
+        let carbSensivityFactor = Double(sensitivity) / Double(carbRatio)
+
+        // FIXME: compute these
+        let carbImpactDuration = 100
+        let remainingCarbImpactPeak = 100
+
+        for i in 1 ..< 48 {
+            let forecastedGlucoseImpact = glucoseImpactSeries[i]
+            // linear drop-off of carb impact over carbImpactDuration*2 intervals
+
+            let numerator = Double(carbImpact * (1 - Double(i)))
+            let denominator = Double(max(carbImpactDuration * 2, 1))
+            let rawDecay = numerator / denominator
+            let carbDecay = Double(max(0, rawDecay))
+
+            // add the "triangle" bump up to remainingCarbImpactPeak
+            let remainingCarbImpact = i < Int(carbImpactDuration * 2)
+                ? remainingCarbImpactPeak * (Int(Double(i)) / (carbImpactDuration * 2))
+                : 0
+
+            let next = result
+                .last! + Double(carbImpactDuration) + Double(min(0, deviation)) + carbDecay + Double(remainingCarbImpact)
+            result.append(next.clamp(lowerBound: 39, upperBound: 1500))
+        }
+
+        return ForecastGenerator.trimFlatTails(result, lookback: 12) // stop at plateau
+    }
+}
+
+/// Forecast sub-generator for “unannounced meal” impact (UAM)
+struct UAMForecastGenerator: SingleForecasting {
+    public func forecast(
+        startingGlucose: Double,
+        glucoseImpactSeries: [Double],
+        mealData: ComputedCarbs,
+        profile _: Profile,
+        carbImpact: Double,
+        deviation: Double
+    ) -> [Double] {
+        var result = [startingGlucose]
+
+        let slope = min(deviation, -(Double(mealData.slopeFromMinDeviation) / 3))
+        for i in 1 ..< 48 {
+            let forecastedGlucoseImpact = glucoseImpactSeries[i]
+            let forecastedUnannouncedCarbImpact = max(0, carbImpact + slope * Double(i))
+            let next = result.last! + forecastedGlucoseImpact + min(0, deviation) + forecastedUnannouncedCarbImpact
+            result.append(next.clamp(lowerBound: 39, upperBound: 401))
+        }
+
+        return ForecastGenerator.trimFlatTails(result, lookback: 12)
+    }
+}
+
+/// Forecast sub-generator for “zero-temp” baseline (ZT)
+struct ZTForecastGenerator: SingleForecasting {
+    public func forecast(
+        startingGlucose: Double,
+        glucoseImpactSeries: [Double],
+        mealData: ComputedCarbs,
+        profile: Profile,
+        carbImpact: Double,
+        deviation: Double
+    ) -> [Double] {
+        // essentially insulin effect only, but with zero-temp ISF if needed
+        IOBForecastGenerator().forecast(
+            startingGlucose: startingGlucose,
+            glucoseImpactSeries: glucoseImpactSeries.map { /* TODO: use iobWithZeroTemp.activity */ $0 },
+            mealData: mealData,
+            profile: profile,
+            carbImpact: carbImpact,
+            deviation: deviation
+        )
+    }
+}

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

@@ -0,0 +1,6 @@
+struct ForecastResult {
+    public let iob: [Double]
+    public let cob: [Double]
+    public let uam: [Double]
+    public let zt: [Double]
+}