Przeglądaj źródła

Intermittent commit; determine-basal port (~Line 734) WIP

Deniz Cengiz 11 miesięcy temu
rodzic
commit
eb63781fb9

+ 24 - 0
Trio.xcodeproj/project.pbxproj

@@ -637,6 +637,12 @@
 		DD30B9FE2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */; };
 		DD30BA002E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */; };
 		DD30BA022E074F0F00DA677C /* GlucoseStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */; };
+		DD30BA062E07667000DA677C /* DetermineBasal+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA052E07667000DA677C /* DetermineBasal+Helpers.swift */; };
+		DD30BA082E076CAA00DA677C /* DeterminationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA072E076CAA00DA677C /* DeterminationError.swift */; };
+		DD30BA0E2E07700000DA677C /* Profile+Autosens.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA0D2E076FFC00DA677C /* Profile+Autosens.swift */; };
+		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 */; };
 		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 */; };
@@ -1545,6 +1551,12 @@
 		DD30B9FD2E0742E200DA677C /* DetermineBasalSMBEnablementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalSMBEnablementTests.swift; sourceTree = "<group>"; };
 		DD30B9FF2E0745C400DA677C /* DetermineBasalDeltaCalculationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineBasalDeltaCalculationTests.swift; sourceTree = "<group>"; };
 		DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatus.swift; sourceTree = "<group>"; };
+		DD30BA052E07667000DA677C /* DetermineBasal+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DetermineBasal+Helpers.swift"; sourceTree = "<group>"; };
+		DD30BA072E076CAA00DA677C /* DeterminationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationError.swift; sourceTree = "<group>"; };
+		DD30BA0D2E076FFC00DA677C /* Profile+Autosens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+Autosens.swift"; sourceTree = "<group>"; };
+		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>"; };
 		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>"; };
@@ -2847,6 +2859,9 @@
 		3B5CD2A42D4AEA5D00CE213C /* Extensions */ = {
 			isa = PBXGroup;
 			children = (
+				DD30BA172E078F8100DA677C /* ComputedInsulinSensitivities+Getter.swift */,
+				DD30BA112E07763E00DA677C /* Profile+TherapySettingGetter.swift */,
+				DD30BA0D2E076FFC00DA677C /* Profile+Autosens.swift */,
 				3B4550522D862BF200551B0D /* PumpHistoryEvent+Duplicates.swift */,
 				3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */,
 				3B5CD2A32D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift */,
@@ -2861,6 +2876,7 @@
 		3B5CD2B22D4AEA6600CE213C /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				DD30BA152E0780A500DA677C /* AdjustedGlucoseTargets.swift */,
 				DD30BA012E074F0F00DA677C /* GlucoseStatus.swift */,
 				DD30B9CB2E062A7000DA677C /* ForecastResult.swift */,
 				3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */,
@@ -3686,6 +3702,8 @@
 		DD30B9C52E0624C600DA677C /* DetermineBasal */ = {
 			isa = PBXGroup;
 			children = (
+				DD30BA072E076CAA00DA677C /* DeterminationError.swift */,
+				DD30BA052E07667000DA677C /* DetermineBasal+Helpers.swift */,
 				DD30B9C62E06257300DA677C /* DetermineBasalGenerator.swift */,
 			);
 			path = DetermineBasal;
@@ -4654,9 +4672,11 @@
 				DD1745462C55C61500211FAC /* AutosensSettingsProvider.swift in Sources */,
 				DD6A4E802DBEC3EE008C4B26 /* StartupForceCloseWarningStepView.swift in Sources */,
 				DDA6E2852D2361F800C2988C /* LoopStatusView.swift in Sources */,
+				DD30BA122E07764300DA677C /* Profile+TherapySettingGetter.swift in Sources */,
 				DDF691012DA2CA11008BF16C /* AppDiagnosticsDataFlow.swift in Sources */,
 				DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */,
 				3B5CD2982D4AEA3C00CE213C /* Carbs.swift in Sources */,
+				DD30BA182E078F8900DA677C /* ComputedInsulinSensitivities+Getter.swift in Sources */,
 				3B5CD2992D4AEA3C00CE213C /* Isf.swift in Sources */,
 				3B5CD29A2D4AEA3C00CE213C /* ProfileError.swift in Sources */,
 				3B5CD29B2D4AEA3C00CE213C /* Basal.swift in Sources */,
@@ -4880,6 +4900,7 @@
 				BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */,
 				CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */,
 				385CEA8225F23DFD002D6D5B /* NightscoutStatus.swift in Sources */,
+				DD30BA162E0780A500DA677C /* AdjustedGlucoseTargets.swift in Sources */,
 				DD17453C2C55BFAD00211FAC /* AlgorithmAdvancedSettingsProvider.swift in Sources */,
 				F90692AA274B7AAE0037068D /* HealthKitManager.swift in Sources */,
 				38887CCE25F5725200944304 /* IOBEntry.swift in Sources */,
@@ -4996,6 +5017,7 @@
 				F90692D3274B9A130037068D /* AppleHealthKitRootView.swift in Sources */,
 				3B5CD2A52D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift in Sources */,
 				BDF34F852C10C62E00D51995 /* GlucoseData.swift in Sources */,
+				DD30BA082E076CAA00DA677C /* DeterminationError.swift in Sources */,
 				19E1F7EC29D082FE005C8D20 /* IconConfigStateModel.swift in Sources */,
 				711C0CB42CAABE788916BC9D /* ManualTempBasalDataFlow.swift in Sources */,
 				DD1745222C55524800211FAC /* SMBSettingsProvider.swift in Sources */,
@@ -5068,6 +5090,7 @@
 				BD4D738D2D15A4080052227B /* TDDStored+CoreDataClass.swift in Sources */,
 				BD4D738E2D15A4080052227B /* TDDStored+CoreDataProperties.swift in Sources */,
 				DDE179582C910127003CDDB7 /* ForecastValue+CoreDataClass.swift in Sources */,
+				DD30BA062E07667000DA677C /* DetermineBasal+Helpers.swift in Sources */,
 				DDE179592C910127003CDDB7 /* ForecastValue+CoreDataProperties.swift in Sources */,
 				DDE1795A2C910127003CDDB7 /* CarbEntryStored+CoreDataClass.swift in Sources */,
 				DDE1795B2C910127003CDDB7 /* CarbEntryStored+CoreDataProperties.swift in Sources */,
@@ -5076,6 +5099,7 @@
 				BDDAF9EF2D00554500B34E7A /* SelectionPopoverView.swift in Sources */,
 				DDE1795F2C910127003CDDB7 /* PumpEventStored+CoreDataProperties.swift in Sources */,
 				DDE179602C910127003CDDB7 /* StatsData+CoreDataClass.swift in Sources */,
+				DD30BA0E2E07700000DA677C /* Profile+Autosens.swift in Sources */,
 				DD3F1F852D9DD84000DCE7B3 /* DeliveryLimitsStepView.swift in Sources */,
 				DDE179612C910127003CDDB7 /* StatsData+CoreDataProperties.swift in Sources */,
 				DDE179622C910127003CDDB7 /* Forecast+CoreDataClass.swift in Sources */,

+ 41 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DeterminationError.swift

@@ -0,0 +1,41 @@
+import Foundation
+
+enum DeterminationError: LocalizedError, Equatable {
+    case missingGlucoseStatus
+    case missingProfile
+    case staleGlucoseData(ageMinutes: Double)
+    case glucoseOutOfRange(glucose: Decimal)
+    case cgmNoiseTooHigh(noise: Int)
+    case noDelta
+    case missingIob
+    case missingInputs
+    case eventualGlucoseCalculationError(sensitivity: Decimal, deviation: Decimal)
+    case determinationError
+
+    var errorDescription: String? {
+        switch self {
+        case .missingGlucoseStatus:
+            return String(localized: "No glucose status; cannot determine basal.")
+        case .missingProfile:
+            return String(localized: "No profile; cannot determine basal.")
+        case let .staleGlucoseData(ageMinutes):
+            return String(localized: "Glucose data is too old (\(ageMinutes) min ago).")
+        case let .glucoseOutOfRange(glucose):
+            return String(localized: "Glucose out of range: \(glucose.description).")
+        case let .cgmNoiseTooHigh(noise):
+            return String(localized: "CGM noise level too high: \(noise).")
+        case .noDelta:
+            return String(localized: "No glucose delta (flat readings); cannot determine trend.")
+        case .missingIob:
+            return String(localized: "No IOB data available; cannot determine basal.")
+        case .missingInputs:
+            return String(localized: "Missing required inputs; cannot determine basal.")
+        case let .eventualGlucoseCalculationError(sensitivity, deviation):
+            return String(
+                localized: "Could not calculate eventual glucose. Sensitivity: \(sensitivity.description), Deviation: \(deviation.description)"
+            )
+        case .determinationError:
+            return String(localized: "Unknown determination error.")
+        }
+    }
+}

+ 130 - 0
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasal+Helpers.swift

@@ -0,0 +1,130 @@
+import Foundation
+
+extension DeterminationGenerator {
+    static func calculateExpectedDelta(
+        targetGlucose: Decimal,
+        eventualGlucose: Decimal,
+        glucoseImpact: Decimal
+    ) -> Decimal {
+        // JS expects glucose to rise/fall at rate of glucose impact
+        // adjusted by the rate at which glucose would need to rise/fall
+        // to move eventual glucose to target over a 2 hr window
+        // TODO: expects that glucose can only be available in 5min chunks. do we need to change this handling?
+
+        let fiveMinuteBlocks = (2 * 60) / 5
+        let delta = targetGlucose - eventualGlucose
+        return (glucoseImpact + Decimal(Int(delta) / fiveMinuteBlocks)).rounded(toPlaces: 1)
+    }
+
+    /// Determines whether SMBs are enabled based on profile settings,
+    /// computed meal data, CGM conditions, and any active overrides.
+    ///
+    /// Mirrors the JavaScript oref's `enable_smb()` logic.
+    ///
+    /// - Parameters:
+    ///   - glucose: The latest blood glucose reading.
+    ///   - profile: The user profile containing SMB preferences and temp-target flags.
+    ///   - autosens: The autosens data (not used in this logic).
+    ///   - mealData: Computed carbs-on-board and related meal information.
+    ///   - override: An optional override controlling SMB scheduling and hard-off flags.
+    ///   - shouldProtectDueToHIGH: `true` if CGM indicates a HIGH reading requiring SMB disable.
+    ///   - currentTime: The current system time for scheduled-off evaluation.
+    /// - Returns: `true` if SMBs should be enabled, `false` otherwise.
+    static func isSMBEnabled(
+        glucose: BloodGlucose,
+        profile: Profile,
+        autosens _: Autosens,
+        mealData: ComputedCarbs?,
+        override: Override?,
+        shouldProtectDueToHIGH: Bool,
+        currentTime: Date
+    ) -> Bool {
+        if let override = override {
+            if override.smbIsScheduledOff {
+                let startHour = override.start
+                let endHour = override.end
+                let hour = Calendar.current.component(.hour, from: currentTime)
+
+                // disable SMB during the scheduled-off window [start, end)
+                if startHour < endHour {
+                    if hour >= Int(startHour), hour < Int(endHour) {
+                        return false
+                    }
+                }
+                // disable SMB if window wraps midnight
+                else if startHour > endHour {
+                    if hour >= Int(startHour) || hour < Int(endHour) {
+                        return false
+                    }
+                }
+                // special cases: off all day or single-hour off
+                else {
+                    if startHour == 0, endHour == 0 {
+                        return false
+                    }
+                    if hour == Int(startHour) {
+                        return false
+                    }
+                }
+            } else if override.smbIsOff {
+                // hard-off override disables SMB entirely
+                return false
+            }
+        }
+
+        if let hasActiveTempTarget = profile.temptargetSet, hasActiveTempTarget {
+            // disable SMB when a high temp target is active and not allowed
+            if !profile.allowSMBWithHighTemptarget,
+               let targetGlucose = profile.targetBg,
+               targetGlucose > 100
+            {
+                return false
+            }
+
+            // enable SMB when a low temp target is active
+            if profile.enableSMBWithTemptarget,
+               let targetGlucose = profile.targetBg,
+               targetGlucose < 100
+            {
+                return true
+            }
+        }
+
+        // disable SMB for invalid CGM readings (HIGH)
+        if shouldProtectDueToHIGH {
+            return false
+        }
+
+        // enable SMB unconditionally if always-on preference is set
+        if profile.enableSMBAlways {
+            return true
+        }
+
+        // enable SMB when carbs-on-board (COB) exists
+        if profile.enableSMBWithCOB,
+           let cob = mealData?.mealCOB,
+           cob > 0
+        {
+            return true
+        }
+
+        // enable SMB for the full post-carb window
+        if profile.enableSMBAfterCarbs,
+           let carbs = mealData?.carbs,
+           carbs > 0
+        {
+            return true
+        }
+
+        // enable SMB when BG exceeds the high-BG threshold
+        if profile.enableSMBHighBg,
+           let glucoseVal = glucose.glucose ?? glucose.sgv,
+           glucoseVal >= Int(profile.enableSMBHighBgTarget)
+        {
+            return true
+        }
+
+        // no enable condition met → disable SMB
+        return false
+    }
+}

+ 443 - 125
Trio/Sources/APS/OpenAPSSwift/DetermineBasal/DetermineBasalGenerator.swift

@@ -12,21 +12,153 @@ enum DeterminationGenerator {
     // handling via overrideManager ?
 
     static func generate(
-        profile _: Profile,
-        currentTemp _: TempBasal,
-        iobData _: IobResult?,
-        mealData _: ComputedCarbs?,
-        autosensData _: Autosens,
+        profile: Profile,
+        currentTemp: TempBasal,
+        iobData: [IobResult],
+        mealData: ComputedCarbs,
+        autosensData: Autosens,
         reservoirData _: Reservoir,
-        currentTime _: Date
+        glucoseStatus: GlucoseStatus?,
+        currentTime: Date
     ) throws -> Determination? {
-        // FIXME: implement... (return type will not be Optional; just to shut up the compiler)
+        try checkDeterminationInputs(
+            glucoseStatus: glucoseStatus,
+            currentTemp: currentTemp,
+            iobData: iobData,
+            profile: profile,
+            currentTime: currentTime,
+        )
+
+        guard let glucoseStatus = glucoseStatus else { throw DeterminationError.missingInputs }
+
+        let currentGlucose: Decimal = glucoseStatus.glucose
+
+        if let errorDetermination = handleTempBasalCases(
+            glucoseStatus: glucoseStatus,
+            profile: profile,
+            currentTemp: currentTemp,
+            currentTime: currentTime
+        ) {
+            return errorDetermination
+        }
+
+        let sensitivityRatio = calculateSensitivityRatio(
+            profile: profile,
+            autosens: autosensData,
+            targetGlucose: profile.targetBg ?? 120,
+            temptargetSet: profile.temptargetSet ?? false
+        )
+
+        let basal = computeAdjustedBasal(
+            currentBasalRate: profile.currentBasal ?? profile.basalFor(time: currentTime),
+            sensitivityRatio: sensitivityRatio
+        )
+        let sensitivity = computeAdjustedSensitivity(
+            sensitivity: profile.sens ?? profile.sensitivityFor(time: currentTime),
+            sensitivityRatio: sensitivityRatio
+        )
+
+        // Safety check: current temp vs. last temp in iob
+        if !checkCurrentTempBasalRateSafety(
+            currentTemp: currentTemp,
+            lastTempTarget: iobData[0].lastTemp,
+            currentTime: currentTime
+        ) {
+            let reason =
+                "Safety check: currentTemp does not match lastTemp in IOB or lastTemp ended long ago; canceling temp basal."
+            return Determination(
+                id: UUID(),
+                reason: reason,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: 0,
+                duration: 0,
+                iob: iobData[0].iob,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: .absolute,
+                bg: glucoseStatus.glucose,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: profile.targetBg,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: nil,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: profile.carbRatio,
+                received: false
+            )
+        }
+
+        let glucoseImpactSeries = buildGlucoseImpactSeries(iobDataSeries: iobData, sensitivity: sensitivity)
+
+        let forecastGenerator = ForecastGenerator()
+        let forecastResult = forecastGenerator.generate(
+            glucose: currentGlucose,
+            glucoseImpactSeries: glucoseImpactSeries,
+            mealData: mealData,
+            profile: profile
+        )
+
+        let currentGlucoseImpact = glucoseImpactSeries[0]
+
+        let minDelta = min(glucoseStatus.delta, glucoseStatus.shortAvgDelta)
+        let minAvgDelta = min(glucoseStatus.shortAvgDelta, glucoseStatus.longAvgDelta)
+        let longAvgDelta = glucoseStatus.longAvgDelta
+
+        let intervals: Decimal = 6 // 30 / 5
+
+        var deviation = (intervals * (minDelta - currentGlucoseImpact)).rounded(toPlaces: 0)
+        if deviation < 0 {
+            deviation = (intervals * (minAvgDelta - currentGlucoseImpact)).rounded(toPlaces: 0)
+            if deviation < 0 {
+                deviation = (intervals * (longAvgDelta - currentGlucoseImpact)).rounded(toPlaces: 0)
+            }
+        }
+
+        // Calculate what oref calls "naive eventual glucose"
+        let currentIob = iobData[0].iob
+
+        let naiveEventualGlucose: Decimal
+        if currentIob > 0 {
+            naiveEventualGlucose = (currentGlucose - (currentIob * sensitivity)).rounded(toPlaces: 0)
+        } else {
+            naiveEventualGlucose =
+                (currentGlucose - (currentIob * min(profile.sens ?? profile.sensitivityFor(time: currentTime), sensitivity)))
+                    .rounded(toPlaces: 0)
+        }
+
+        let eventualGlucose = naiveEventualGlucose + deviation
+
+        // Safety: if we ever get an invalid Decimal (very rare with Decimal), handle
+        guard eventualGlucose.isFinite else {
+            throw DeterminationError.eventualGlucoseCalculationError(sensitivity: sensitivity, deviation: deviation)
+        }
+
+        let expectedDelta = calculateExpectedDelta(
+            targetGlucose: profile.targetBg ?? 100,
+            eventualGlucose: eventualGlucose,
+            glucoseImpact: currentGlucoseImpact
+        )
 
-        // trio-oref signature:
-//        function determine_basal(glucose_status, currenttemp, iob_data, profile, autosens_data, meal_data, tempBasalFunctions, microBolusAllowed, reservoir_data, currentTime, pumphistory, preferences, basalprofile, trio_custom_variables, middleWare) {
+        let minPredBG = forecastResult.iob.min()
+        let minGuardBG = minPredBG
 
-        // openaps/oref0 signature:
-//        function determine_basal(glucose_status, currenttemp, iob_data, profile, autosens_data, meal_data, tempBasalFunctions, microBolusAllowed, reservoir_data, currentTime) {
+        // TODO: STOPPING at LINE 734
+        // L734ff handles forecasting, already handled (I hope)
+        // continue at ~785
+
+        return nil
+        // FIXME: implement... (return type will not be Optional; just to shut up the compiler)
 
         /// We also need a call to glucose-get-last here (JS passes object `glucoseStatus`) → could be a simple function in GlucoseStorage
         /// We also need the tempBasal helpers (JS passes object `tempBasalFunctions` with functions)
@@ -48,148 +180,334 @@ enum DeterminationGenerator {
         // TODO: Do we want store algorithm input *and* output?
 
         /// 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
+        /// 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 ?
 
         // TODO: how to handle output?
         // TODO: how to handle logging?
 
-        nil
+        return nil
     }
-}
 
-extension DeterminationGenerator {
-    public static func calculateExpectedDelta(
-        targetGlucose: Decimal,
-        eventualGlucose: Decimal,
-        glucoseImpact: Decimal
-    ) -> Decimal {
-        // JS expects glucose to rise/fall at rate of glucose impact
-        // adjusted by the rate at which glucose would need to rise/fall
-        // to move eventual glucose to target over a 2 hr window
-        // TODO: expects that glucose can only be available in 5min chunks. do we need to change this handling?
-
-        let fiveMinuteBlocks = (2 * 60) / 5
-        let delta = targetGlucose - eventualGlucose
-        return (glucoseImpact + Decimal(Int(delta) / fiveMinuteBlocks)).rounded(toPlaces: 1)
+    static func checkDeterminationInputs(
+        glucoseStatus: GlucoseStatus?,
+        currentTemp _: TempBasal?,
+        iobData: [IobResult]?,
+        profile: Profile?,
+        currentTime: Date = Date()
+    ) throws {
+        guard let glucoseStatus = glucoseStatus else {
+            throw DeterminationError.missingGlucoseStatus
+        }
+        guard let profile = profile else {
+            throw DeterminationError.missingProfile
+        }
+        let glucoseAge = currentTime.timeIntervalSince(glucoseStatus.date)
+        if glucoseAge > 15 * 60 {
+            throw DeterminationError.staleGlucoseData(ageMinutes: glucoseAge / 60)
+        }
+        if glucoseStatus.glucose < 39 || glucoseStatus.glucose > 600 {
+            throw DeterminationError.glucoseOutOfRange(glucose: glucoseStatus.glucose)
+        }
+        if glucoseStatus.noise > 1 {
+            throw DeterminationError.cgmNoiseTooHigh(noise: glucoseStatus.noise)
+        }
+        if glucoseStatus.delta == 0 {
+            throw DeterminationError.noDelta
+        }
+        guard let _ = iobData else {
+            throw DeterminationError.missingIob
+        }
     }
 
-    /// Determines whether SMBs are enabled based on profile settings,
-    /// computed meal data, CGM conditions, and any active overrides.
-    ///
-    /// Mirrors the JavaScript oref's `enable_smb()` logic.
-    ///
-    /// - Parameters:
-    ///   - glucose: The latest blood glucose reading.
-    ///   - profile: The user profile containing SMB preferences and temp-target flags.
-    ///   - autosens: The autosens data (not used in this logic).
-    ///   - mealData: Computed carbs-on-board and related meal information.
-    ///   - override: An optional override controlling SMB scheduling and hard-off flags.
-    ///   - shouldProtectDueToHIGH: `true` if CGM indicates a HIGH reading requiring SMB disable.
-    ///   - currentTime: The current system time for scheduled-off evaluation.
-    /// - Returns: `true` if SMBs should be enabled, `false` otherwise.
-    public static func isSMBEnabled(
-        glucose: BloodGlucose,
+    static func handleTempBasalCases(
+        glucoseStatus: GlucoseStatus,
         profile: Profile,
-        autosens _: Autosens,
-        mealData: ComputedCarbs?,
-        override: Override?,
-        shouldProtectDueToHIGH: Bool,
+        currentTemp: TempBasal?,
         currentTime: Date
-    ) -> Bool {
-        if let override = override {
-            if override.smbIsScheduledOff {
-                let startHour = override.start
-                let endHour = override.end
-                let hour = Calendar.current.component(.hour, from: currentTime)
-
-                // disable SMB during the scheduled-off window [start, end)
-                if startHour < endHour {
-                    if hour >= Int(startHour), hour < Int(endHour) {
-                        return false
-                    }
-                }
-                // disable SMB if window wraps midnight
-                else if startHour > endHour {
-                    if hour >= Int(startHour) || hour < Int(endHour) {
-                        return false
-                    }
-                }
-                // special cases: off all day or single-hour off
-                else {
-                    if startHour == 0, endHour == 0 {
-                        return false
-                    }
-                    if hour == Int(startHour) {
-                        return false
-                    }
-                }
-            } else if override.smbIsOff {
-                // hard-off override disables SMB entirely
-                return false
+    ) -> Determination? {
+        let glucose = glucoseStatus.glucose
+        let noise = glucoseStatus.noise
+        let bgTime = glucoseStatus.date
+        let minAgo = Decimal(currentTime.timeIntervalSince(bgTime) / 60) // minutes
+        let shortAvgDelta = glucoseStatus.shortAvgDelta
+        let longAvgDelta = glucoseStatus.longAvgDelta
+        let delta = glucoseStatus.delta
+        let device = glucoseStatus.device
+
+        // Always use profile-supplied basal
+        let basal = profile.currentBasal ?? profile.basalFor(time: currentTime)
+
+        // Compose tick for log
+        let tick: String = (delta > -0.5) ? "+\(delta.rounded(toPlaces: 0))" : "\(delta.rounded(toPlaces: 0))"
+        let minDelta = min(delta, shortAvgDelta)
+        let minAvgDelta = min(shortAvgDelta, longAvgDelta)
+        let maxDelta = max(delta, shortAvgDelta, longAvgDelta)
+
+        var reason = ""
+
+        // === ERROR CONDITIONS ===
+        // xDrip code 38 = sensor error; BG <= 10 = ???/calibrating; noise >= 3 = high noise
+        if glucose <= 10 || glucose == 38 || noise >= 3 {
+            reason = "CGM is calibrating, in ??? state, or noise is high"
+        }
+        // minAgo (BG age) > 12 or < -5 = old/future BG
+        if minAgo > 12 || minAgo < -5 {
+            reason =
+                "If current system time \(currentTime) is correct, then BG data is too old. The last BG data was read \(minAgo) min ago at \(bgTime)"
+        }
+        // CGM data unchanged (flat)
+        if shortAvgDelta == 0 && longAvgDelta == 0 {
+            if glucoseStatus.lastCalIndex != nil, glucoseStatus.lastCalIndex! < 3 {
+                reason = "CGM was just calibrated"
+            } else {
+                reason =
+                    "CGM data is unchanged (\(glucose)+\(delta)) for 5m w/ \(shortAvgDelta) mg/dL ~15m change & \(longAvgDelta) mg/dL ~45m change"
             }
         }
 
-        if let hasActiveTempTarget = profile.temptargetSet, hasActiveTempTarget {
-            // disable SMB when a high temp target is active and not allowed
-            if !profile.allowSMBWithHighTemptarget,
-               let targetGlucose = profile.targetBg,
-               targetGlucose > 100
-            {
-                return false
-            }
+        let errorDetected =
+            glucose <= 10 ||
+            glucose == 38 ||
+            noise >= 3 ||
+            minAgo > 12 ||
+            minAgo < -5 ||
+            (shortAvgDelta == 0 && longAvgDelta == 0)
 
-            // enable SMB when a low temp target is active
-            if profile.enableSMBWithTemptarget,
-               let targetGlucose = profile.targetBg,
-               targetGlucose < 100
-            {
-                return true
+        // === IF ERROR, CANCEL/SHORTEN TEMPS ===
+        guard errorDetected, let currentTemp = currentTemp else { return nil }
+
+        if currentTemp.rate >= basal {
+            // Cancel high temp: set 0U/hr for 0m (neutralizes)
+            let reasonWithAction = reason + ". Canceling high temp basal of \(currentTemp.rate)U/hr."
+            return Determination(
+                id: UUID(),
+                reason: reasonWithAction,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: 0,
+                duration: 0,
+                iob: nil,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: .absolute,
+                bg: glucose,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: profile.targetBg,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: minDelta,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: profile.carbRatio,
+                received: false
+            )
+        } else if currentTemp.rate == 0, currentTemp.duration > 30 {
+            // Shorten long zero temp to 30m
+            let reasonWithAction = reason + ". Shortening \(currentTemp.duration)m long zero temp to 30m."
+            return Determination(
+                id: UUID(),
+                reason: reasonWithAction,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: 0,
+                duration: 30,
+                iob: nil,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: .absolute,
+                bg: glucose,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: profile.targetBg,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: minDelta,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: profile.carbRatio,
+                received: false
+            )
+        } else {
+            // Do nothing (temp already safe)
+            let reasonWithAction = reason + ". Temp \(currentTemp.rate) <= current basal \(basal)U/hr; doing nothing."
+            return Determination(
+                id: UUID(),
+                reason: reasonWithAction,
+                units: nil,
+                insulinReq: nil,
+                eventualBG: nil,
+                sensitivityRatio: nil,
+                rate: currentTemp.rate,
+                duration: Decimal(currentTemp.duration),
+                iob: nil,
+                cob: nil,
+                predictions: nil,
+                deliverAt: currentTime,
+                carbsReq: nil,
+                temp: currentTemp.temp,
+                bg: glucose,
+                reservoir: nil,
+                isf: profile.sens,
+                timestamp: currentTime,
+                tdd: nil,
+                current_target: profile.targetBg,
+                insulinForManualBolus: nil,
+                manualBolusErrorString: nil,
+                minDelta: minDelta,
+                expectedDelta: nil,
+                minGuardBG: nil,
+                minPredBG: nil,
+                threshold: nil,
+                carbRatio: profile.carbRatio,
+                received: false
+            )
+        }
+    }
+
+    static func calculateSensitivityRatio(
+        profile: Profile,
+        autosens: Autosens?,
+        targetGlucose: Decimal,
+        temptargetSet: Bool
+    ) -> Decimal {
+        let normalTarget: Decimal = 100
+        let halfBasalTarget = profile.halfBasalExerciseTarget
+        let highTemptargetRaisesSensitivity = profile.highTemptargetRaisesSensitivity
+        let lowTemptargetLowersSensitivity = profile.lowTemptargetLowersSensitivity
+
+        var ratio: Decimal = 1
+
+        // High temp target raises sensitivity or low temp lowers it
+        if (profile.highTemptargetRaisesSensitivity && temptargetSet && targetGlucose > normalTarget) ||
+            (profile.lowTemptargetLowersSensitivity && temptargetSet && targetGlucose < normalTarget)
+        {
+            let c = halfBasalTarget - normalTarget
+            if c * (c + targetGlucose - normalTarget) <= 0 {
+                ratio = profile.autosensMax
+            } else {
+                ratio = c / (c + targetGlucose - normalTarget)
             }
+            ratio = min(ratio, profile.autosensMax)
+            // You can round here if needed: ratio = ratio.rounded(2)
+            return ratio
         }
+        // Use autosens if present
+        if let autosens = autosens {
+            return autosens.ratio
+        }
+        // Otherwise default to 1.0 (no adjustment)
+        return 1.0
+    }
 
-        // disable SMB for invalid CGM readings (HIGH)
-        if shouldProtectDueToHIGH {
+    static func computeAdjustedBasal(currentBasalRate: Decimal, sensitivityRatio: Decimal) -> Decimal {
+        // FIXME: Ideally, we round this here to allowed pump basal increments
+        currentBasalRate * sensitivityRatio
+    }
+
+    static func computeAdjustedSensitivity(sensitivity: Decimal, sensitivityRatio: Decimal) -> Decimal {
+        guard sensitivityRatio != 1.0 else { return sensitivity }
+        return (sensitivity / sensitivityRatio).rounded(toPlaces: 1)
+    }
+
+    static func checkCurrentTempBasalRateSafety(
+        currentTemp: TempBasal,
+        lastTempTarget: IobResult.LastTemp?,
+        currentTime: Date
+    ) -> Bool {
+        guard let lastTemp = lastTempTarget, let lastTempDate = lastTemp.timestamp,
+              let lastTempDuration = lastTemp.duration else { return true }
+        // TODO: throw error for malformed IobResult? Can this be malformed?
+
+        let lastTempAge = Int(currentTime.timeIntervalSince(lastTempDate) / 60) // in minutes
+        let tempModulus = Int(lastTempAge + currentTemp.duration) % 30
+
+        if currentTemp.rate != lastTemp.rate, lastTempAge > 10, currentTemp.duration > 0 {
+            // Rates don’t match and temp is old: cancel temp
             return false
         }
+        let lastTempEnded = lastTempAge - Int(lastTempDuration) // TODO: check if this comes in minutes
 
-        // enable SMB unconditionally if always-on preference is set
-        if profile.enableSMBAlways {
-            return true
+        if lastTempEnded > 5, lastTempAge > 10 {
+            // Last temp ended long ago but temp is running: cancel temp
+            return false
         }
 
-        // enable SMB when carbs-on-board (COB) exists
-        if profile.enableSMBWithCOB,
-           let cob = mealData?.mealCOB,
-           cob > 0
-        {
-            return true
-        }
+        return true
+    }
 
-        // enable SMB for the full post-carb window
-        if profile.enableSMBAfterCarbs,
-           let carbs = mealData?.carbs,
-           carbs > 0
-        {
-            return true
+    /// Adjust glucose targets (min, max, target) based on autosens and/or noise.
+    /// - Returns: adjusted targets and new threshold
+    static func adjustGlucoseTargets(
+        profile: Profile,
+        autosens: Autosens?,
+        temptargetSet: Bool,
+        targetGlucose: Decimal,
+        minGlucose: Decimal,
+        maxGlucose: Decimal,
+        noise: Int
+    ) -> (targets: AdjustedGlucoseTargets, threshold: Decimal) {
+        var minGlucose = minGlucose
+        var maxGlucose = maxGlucose
+        var targetGlucose = targetGlucose
+
+        // Only adjust glucose targets for autosens if no temp target set
+        if !temptargetSet, let autosens = autosens {
+            if (profile.sensitivityRaisesTarget && autosens.ratio < 1) ||
+                (profile.resistanceLowersTarget && autosens.ratio > 1)
+            {
+                minGlucose = ((minGlucose - 60) / autosens.ratio + 60).rounded(toPlaces: 0)
+                maxGlucose = ((maxGlucose - 60) / autosens.ratio + 60).rounded(toPlaces: 0)
+                targetGlucose = max(80, ((targetGlucose - 60) / autosens.ratio + 60).rounded(toPlaces: 0))
+            }
         }
 
-        // enable SMB when BG exceeds the high-BG threshold
-        if profile.enableSMBHighBg,
-           let glucoseVal = glucose.glucose ?? glucose.sgv,
-           glucoseVal >= Int(profile.enableSMBHighBgTarget)
-        {
-            return true
+        // Raise target for noisy/CGM data
+        if noise >= 2 {
+            let noisyCGMTargetMultiplier = max(1.1, profile.noisyCGMTargetMultiplier)
+            minGlucose = min(200, minGlucose * noisyCGMTargetMultiplier).rounded(toPlaces: 0)
+            targetGlucose = min(200, targetGlucose * noisyCGMTargetMultiplier).rounded(toPlaces: 0)
+            maxGlucose = min(200, maxGlucose * noisyCGMTargetMultiplier).rounded(toPlaces: 0)
         }
 
-        // no enable condition met → disable SMB
-        return false
+        // 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)
+    }
+
+    static func buildGlucoseImpactSeries(iobDataSeries: [IobResult], sensitivity: Decimal) -> [Decimal] {
+        iobDataSeries.map { iob in
+            // FIXME: this is assuming 5min steps...
+            // Activity is U/hr
+            // oref0 uses: -activity * ISF * 5
+            -iob.activity * sensitivity * 5
+        }
     }
 }

+ 11 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedBGTargets+Getter.swift

@@ -0,0 +1,11 @@
+// import Foundation
+//
+// extension ComputedBGTargets {
+//    func targetEntry(for time: Date = Date()) -> ComputedBGTargetEntry? {
+//        // Assumes targets are sorted by start/offset ascending, wrap at midnight
+//        let nowMinutes = Calendar.current.component(.hour, from: time) * 60 +
+//            Calendar.current.component(.minute, from: time)
+//        // Find last entry with offset <= nowMinutes
+//        return targets.last(where: { $0.offset <= nowMinutes }) ?? targets.first
+//    }
+// }

+ 24 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/ComputedInsulinSensitivities+Getter.swift

@@ -0,0 +1,24 @@
+import Foundation
+
+extension ComputedInsulinSensitivities {
+    /// Returns the insulin sensitivity (ISF) for a specific Date (using the closest entry).
+    func sensitivity(for date: Date) -> Decimal? {
+        guard !sensitivities.isEmpty else { return nil }
+        // Assumes all offsets are in minutes from midnight
+        let calendar = Calendar.current
+        let components = calendar.dateComponents([.hour, .minute], from: date)
+        let minutesSinceMidnight = (components.hour ?? 0) * 60 + (components.minute ?? 0)
+
+        // Find the entry whose offset is the largest but not greater than the time
+        let sorted = sensitivities.sorted(by: { $0.offset < $1.offset })
+        var current = sorted.first
+        for entry in sorted {
+            if entry.offset <= minutesSinceMidnight {
+                current = entry
+            } else {
+                break
+            }
+        }
+        return current?.sensitivity
+    }
+}

+ 11 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+Autosens.swift

@@ -0,0 +1,11 @@
+import Foundation
+
+// Extend Profile for easy ISF replacement
+extension Profile {
+    func withAutosensISF(_ autosens: Autosens) -> Profile {
+        guard let newisf = autosens.newisf else { return self }
+        var copy = self
+        copy.sens = newisf
+        return copy
+    }
+}

+ 63 - 0
Trio/Sources/APS/OpenAPSSwift/Extensions/Profile+TherapySettingGetter.swift

@@ -0,0 +1,63 @@
+import Foundation
+
+extension Profile {
+    /// Returns the basal rate for the given time (default: now), or 0 if not found.
+    func basalFor(time: Date = Date()) -> Decimal {
+        guard let entries = basalprofile, !entries.isEmpty else {
+            return currentBasal ?? 0
+        }
+
+        let calendar = Calendar.current
+
+        // Get today's midnight
+        let startOfDay = calendar.startOfDay(for: time)
+        let nowMinutes = calendar.dateComponents([.minute], from: startOfDay, to: time).minute ?? 0
+
+        for (index, entry) in entries.enumerated() {
+            let startMinutes = entry.minutes
+            let endMinutes: Int
+
+            if index < entries.count - 1 {
+                endMinutes = entries[index + 1].minutes
+            } else {
+                endMinutes = 24 * 60 // 1440, end of day
+            }
+
+            if nowMinutes >= startMinutes, nowMinutes < endMinutes {
+                return entry.rate
+            }
+        }
+        return 0
+    }
+
+    /// Returns the ISF (insulin sensitivity factor) for the given time (default: now), or 200 if not found.
+    func sensitivityFor(time: Date = Date()) -> Decimal {
+        guard let isfProfile = isfProfile,
+              !isfProfile.sensitivities.isEmpty
+        else {
+            // Fallback to single value, if present
+            return sens ?? 200
+        }
+
+        let calendar = Calendar.current
+        let startOfDay = calendar.startOfDay(for: time)
+        let nowMinutes = calendar.dateComponents([.minute], from: startOfDay, to: time).minute ?? 0
+
+        let entries = isfProfile.sensitivities.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.sensitivity
+            }
+        }
+        return sens ?? 200
+    }
+}

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

@@ -1,3 +1,5 @@
+import Foundation
+
 /// The top-level orchestrator
 struct ForecastGenerator {
     let iob: SingleForecasting
@@ -18,12 +20,12 @@ struct ForecastGenerator {
     }
 
     public func generate(
-        glucose: Double,
-        glucoseImpactSeries: [Double],
+        glucose: Decimal,
+        glucoseImpactSeries: [Decimal],
         mealData: ComputedCarbs,
         profile: Profile
     ) -> ForecastResult {
-        let carbImpact = Double(mealData.currentDeviation) * Double(profile.carbRatio!) / Double(profile.sens!)
+        let carbImpact = mealData.currentDeviation * profile.carbRatio! / profile.sens!
         let deviation = mealData.currentDeviation
 
         return ForecastResult(
@@ -33,7 +35,7 @@ struct ForecastGenerator {
                 mealData: mealData,
                 profile: profile,
                 carbImpact: carbImpact,
-                deviation: Double(deviation)
+                deviation: deviation
             ),
             cob: cob.forecast(
                 startingGlucose: glucose,
@@ -41,7 +43,7 @@ struct ForecastGenerator {
                 mealData: mealData,
                 profile: profile,
                 carbImpact: carbImpact,
-                deviation: Double(deviation)
+                deviation: deviation
             ),
             uam: uam.forecast(
                 startingGlucose: glucose,
@@ -49,7 +51,7 @@ struct ForecastGenerator {
                 mealData: mealData,
                 profile: profile,
                 carbImpact: carbImpact,
-                deviation: Double(deviation)
+                deviation: deviation
             ),
             zt: zt.forecast(
                 startingGlucose: glucose,
@@ -57,13 +59,13 @@ struct ForecastGenerator {
                 mealData: mealData,
                 profile: profile,
                 carbImpact: carbImpact,
-                deviation: Double(deviation)
+                deviation: deviation
             )
         )
     }
 
     /// Trims trailing flat-line points beyond a “lookback” count
-    public static func trimFlatTails(_ series: [Double], lookback: Int) -> [Double] {
+    public static func trimFlatTails(_ series: [Decimal], lookback: Int) -> [Decimal] {
         var s = series
         while s.count > lookback, s.suffix(2)[0] == s.suffix(2)[1] {
             s.removeLast()

+ 43 - 44
Trio/Sources/APS/OpenAPSSwift/Forecasts/SingleForecasting.swift

@@ -11,28 +11,28 @@ protocol SingleForecasting {
     ///   - 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],
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
         mealData: ComputedCarbs,
         profile: Profile,
-        carbImpact: Double,
-        deviation: Double
-    ) -> [Double]
+        carbImpact: Decimal,
+        deviation: Decimal
+    ) -> [Decimal]
 }
 
 /// Forecast sub-generator for insulin-only effect (IOB)
 struct IOBForecastGenerator: SingleForecasting {
     public func forecast(
-        startingGlucose: Double,
-        glucoseImpactSeries: [Double],
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
         mealData _: ComputedCarbs,
         profile _: Profile,
-        carbImpact _: Double,
-        deviation: Double
-    ) -> [Double] {
+        carbImpact _: Decimal,
+        deviation: Decimal
+    ) -> [Decimal] {
         var result = [startingGlucose]
         for (count, glucoseImpact) in glucoseImpactSeries.enumerated() {
-            let predDev = deviation * (1 - min(1, Double(count) / (60 / 5)))
+            let predDev = deviation * (1 - min(1, Decimal(count) / (60 / 5)))
             let next = result.last! + glucoseImpact + predDev
             result.append(next.clamp(lowerBound: 39, upperBound: 401))
         }
@@ -43,13 +43,13 @@ struct IOBForecastGenerator: SingleForecasting {
 /// Forecast sub-generator for carb-only effect (COB + UAM piece)
 struct COBForecastGenerator: SingleForecasting {
     public func forecast(
-        startingGlucose: Double,
-        glucoseImpactSeries: [Double],
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
         mealData: ComputedCarbs,
         profile: Profile,
-        carbImpact: Double,
-        deviation: Double
-    ) -> [Double] {
+        carbImpact: Decimal,
+        deviation: Decimal
+    ) -> [Decimal] {
         // Start with the current BG
         var result = [startingGlucose]
 
@@ -59,16 +59,16 @@ struct COBForecastGenerator: SingleForecasting {
         else {
             fatalError("Profile must have sens and carbRatio")
         }
-        let csf = Double(sens) / Double(carbRatio)
+        let csf = sens / carbRatio // FIXME: this needs to be the AS-adjusted sens, not profile.sens
 
         // Initial carb impact in mg/dL per 5m
-        let initialCI = carbImpact * csf
+        let initialCarbImpact = carbImpact * csf
 
         // Number of 5-minute intervals over which we expect *all* carbs to absorb
         let absorptionIntervals = Int(profile.maxMealAbsorptionTime * Decimal(60) / 5)
 
         // Peak impact (mg/dL per 5m) of the *remaining* carbs
-        let remainingCarbImpactPeak = Double(mealData.mealCOB) * csf
+        let remainingCarbImpactPeak = mealData.mealCOB * csf
 
         // 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).
@@ -78,22 +78,22 @@ struct COBForecastGenerator: SingleForecasting {
         let predDev = min(0, deviation)
 
         // Build prediction out to glucoseImpactSeries.count (usually 48)
-        for i in 1..<glucoseImpactSeries.count {
-            let insulinEffect = glucoseImpactSeries[i]
+        for seriesCount in 1 ..< glucoseImpactSeries.count {
+            let insulinEffect = glucoseImpactSeries[seriesCount]
 
             // Linearly decay the *observed* carb impact from initialCI → 0
-            let decayFactor = max(0, 1 - Double(i) / Double(decayIntervals))
-            let predCI = initialCI * decayFactor
+            let decayFactor = max(0, 1 - seriesCount / decayIntervals)
+            let forecastedCarbImpact = initialCarbImpact * 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: Double
-            if i <= absorptionIntervals {
-                triangle = remainingCarbImpactPeak * (Double(i) / Double(absorptionIntervals))
-            } else if i <= decayIntervals {
-                triangle = remainingCarbImpactPeak * (Double(decayIntervals - i) / Double(absorptionIntervals))
+            let triangle: Decimal
+            if seriesCount <= absorptionIntervals {
+                triangle = remainingCarbImpactPeak * (Decimal(seriesCount) / Decimal(absorptionIntervals))
+            } else if seriesCount <= decayIntervals {
+                triangle = remainingCarbImpactPeak * (Decimal(decayIntervals - seriesCount) / Decimal(absorptionIntervals))
             } else {
                 triangle = 0
             }
@@ -101,7 +101,7 @@ struct COBForecastGenerator: SingleForecasting {
             let next = result.last!
                 + insulinEffect
                 + predDev
-                + predCI
+                + forecastedCarbImpact
                 + triangle
 
             result.append(next.clamp(lowerBound: 39, upperBound: 1500))
@@ -111,23 +111,22 @@ struct COBForecastGenerator: SingleForecasting {
     }
 }
 
-
 /// Forecast sub-generator for “unannounced meal” impact (UAM)
 struct UAMForecastGenerator: SingleForecasting {
     public func forecast(
-        startingGlucose: Double,
-        glucoseImpactSeries: [Double],
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
         mealData: ComputedCarbs,
         profile _: Profile,
-        carbImpact: Double,
-        deviation: Double
-    ) -> [Double] {
+        carbImpact: Decimal,
+        deviation: Decimal
+    ) -> [Decimal] {
         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 slope = min(deviation, -(mealData.slopeFromMinDeviation / 3))
+        for seriesCount in 1 ..< 48 {
+            let forecastedGlucoseImpact = glucoseImpactSeries[seriesCount]
+            let forecastedUnannouncedCarbImpact = max(0, carbImpact + slope * Decimal(seriesCount))
             let next = result.last! + forecastedGlucoseImpact + min(0, deviation) + forecastedUnannouncedCarbImpact
             result.append(next.clamp(lowerBound: 39, upperBound: 401))
         }
@@ -139,13 +138,13 @@ struct UAMForecastGenerator: SingleForecasting {
 /// Forecast sub-generator for “zero-temp” baseline (ZT)
 struct ZTForecastGenerator: SingleForecasting {
     public func forecast(
-        startingGlucose: Double,
-        glucoseImpactSeries: [Double],
+        startingGlucose: Decimal,
+        glucoseImpactSeries: [Decimal],
         mealData: ComputedCarbs,
         profile: Profile,
-        carbImpact: Double,
-        deviation: Double
-    ) -> [Double] {
+        carbImpact: Decimal,
+        deviation: Decimal
+    ) -> [Decimal] {
         // essentially insulin effect only, but with zero-temp ISF if needed
         IOBForecastGenerator().forecast(
             startingGlucose: startingGlucose,

+ 7 - 0
Trio/Sources/APS/OpenAPSSwift/Models/AdjustedGlucoseTargets.swift

@@ -0,0 +1,7 @@
+import Foundation
+
+struct AdjustedGlucoseTargets {
+    var minGlucose: Decimal
+    var maxGlucose: Decimal
+    var targetGlucose: Decimal
+}

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

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

+ 37 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -56803,6 +56803,9 @@
         }
       }
     },
+    "CGM noise level too high: %lld." : {
+
+    },
     "Change CR" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -64696,6 +64699,16 @@
         }
       }
     },
+    "Could not calculate eventual glucose. Sensitivity: %@, Deviation: %@" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Could not calculate eventual glucose. Sensitivity: %1$@, Deviation: %2$@"
+          }
+        }
+      }
+    },
     "Count" : {
       "localizations" : {
         "bg" : {
@@ -105056,6 +105069,9 @@
         }
       }
     },
+    "Glucose data is too old (%lf min ago)." : {
+
+    },
     "Glucose Data used for statistics" : {
       "comment" : "Debug option view Glucose Data used for statistics",
       "extractionState" : "manual",
@@ -106025,6 +106041,9 @@
         }
       }
     },
+    "Glucose out of range: %@." : {
+
+    },
     "Glucose Reading" : {
       "localizations" : {
         "bg" : {
@@ -143087,6 +143106,9 @@
         }
       }
     },
+    "Missing required inputs; cannot determine basal." : {
+
+    },
     "mmol/L" : {
       "comment" : "The short unit display string for millimoles of glucose per liter",
       "localizations" : {
@@ -146764,6 +146786,9 @@
         }
       }
     },
+    "No glucose delta (flat readings); cannot determine trend." : {
+
+    },
     "No Glucose Notifications will be triggered." : {
       "localizations" : {
         "bg" : {
@@ -146976,6 +147001,12 @@
         }
       }
     },
+    "No glucose status; cannot determine basal." : {
+
+    },
+    "No IOB data available; cannot determine basal." : {
+
+    },
     "No Libre Transmitter Selected" : {
       "comment" : "No Libre Transmitter Selected",
       "extractionState" : "manual",
@@ -147627,6 +147658,9 @@
         }
       }
     },
+    "No profile; cannot determine basal." : {
+
+    },
     "No recent oref algorithm determination." : {
       "localizations" : {
         "bg" : {
@@ -225648,6 +225682,9 @@
         }
       }
     },
+    "Unknown determination error." : {
+
+    },
     "Unknown Error" : {
       "localizations" : {
         "bg" : {