Przeglądaj źródła

Merge pull request #339 from MikePlante1/maxIOB-maxCOB

Bolus Calculator Improvements
Deniz Cengiz 1 rok temu
rodzic
commit
782a6108ff

+ 14 - 0
Model/Helper/Determination+helper.swift

@@ -19,6 +19,20 @@ extension OrefDetermination {
     var reasonConclusion: String {
         reason?.components(separatedBy: "; ").last ?? ""
     }
+
+    var minPredBGFromReason: Decimal? {
+        // Find the part that contains "minPredBG"
+        if let minPredBGPart = reasonParts.first(where: { $0.contains("minPredBG") }) {
+            // Extract the number after "minPredBG"
+            let components = minPredBGPart.components(separatedBy: "minPredBG ")
+            if let valueComponent = components.dropFirst().first {
+                // Get everything after "minPredBG " and convert to Decimal
+                let valueString = valueComponent.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789.-").inverted)
+                return Decimal(string: valueString)
+            }
+        }
+        return nil
+    }
 }
 
 extension NSPredicate {

Plik diff jest za duży
+ 178 - 1
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 2 - 2
Trio/Sources/Models/DecimalPickerSettings.swift

@@ -48,7 +48,7 @@ struct DecimalPickerSettings {
     var maxCarbs = PickerSetting(value: 250, step: 5, min: 0, max: 300, type: PickerSetting.PickerSettingType.gram)
     var maxFat = PickerSetting(value: 250, step: 5, min: 0, max: 300, type: PickerSetting.PickerSettingType.gram)
     var maxProtein = PickerSetting(value: 250, step: 5, min: 0, max: 300, type: PickerSetting.PickerSettingType.gram)
-    var overrideFactor = PickerSetting(value: 0.8, step: 0.05, min: 0.5, max: 1.5, type: PickerSetting.PickerSettingType.factor)
+    var overrideFactor = PickerSetting(value: 0.8, step: 0.05, min: 0.05, max: 1.5, type: PickerSetting.PickerSettingType.factor)
     var fattyMealFactor = PickerSetting(value: 0.7, step: 0.05, min: 0.05, max: 1, type: PickerSetting.PickerSettingType.factor)
     var sweetMealFactor = PickerSetting(value: 1, step: 0.05, min: 0.05, max: 2, type: PickerSetting.PickerSettingType.factor)
     var maxIOB = PickerSetting(value: 0, step: 1, min: 0, max: 20, type: PickerSetting.PickerSettingType.insulinUnit)
@@ -136,7 +136,7 @@ struct DecimalPickerSettings {
     var timeCap = PickerSetting(value: 8, step: 1, min: 5, max: 12, type: PickerSetting.PickerSettingType.hour)
     var hours = PickerSetting(value: 6, step: 0.5, min: 2, max: 24, type: PickerSetting.PickerSettingType.hour)
     var dia = PickerSetting(value: 10, step: 0.5, min: 5, max: 10, type: PickerSetting.PickerSettingType.hour)
-    var maxBolus = PickerSetting(value: 10, step: 0.5, min: 1, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)
+    var maxBolus = PickerSetting(value: 10, step: 0.5, min: 0.5, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)
     var maxBasal = PickerSetting(value: 10, step: 0.5, min: 0.5, max: 30, type: PickerSetting.PickerSettingType.insulinUnit)
 }
 

+ 5 - 0
Trio/Sources/Models/TrioSettings.swift

@@ -66,6 +66,7 @@ struct TrioSettings: JSON, Equatable {
     var sweetMeals: Bool = false
     var sweetMealFactor: Decimal = 1
     var displayPresets: Bool = true
+    var confirmBolus: Bool = false
     var useLiveActivity: Bool = false
     var lockScreenView: LockScreenView = .simple
     var bolusShortcut: BolusShortcutLimit = .notAllowed
@@ -284,6 +285,10 @@ extension TrioSettings: Decodable {
             settings.displayPresets = displayPresets
         }
 
+        if let confirmBolus = try? container.decode(Bool.self, forKey: .confirmBolus) {
+            settings.confirmBolus = confirmBolus
+        }
+
         if let useLiveActivity = try? container.decode(Bool.self, forKey: .useLiveActivity) {
             settings.useLiveActivity = useLiveActivity
         }

+ 5 - 18
Trio/Sources/Modules/BolusCalculatorConfig/BolusCalculatorStateModel.swift

@@ -9,31 +9,18 @@ extension BolusCalculatorConfig {
         @Published var sweetMeals: Bool = false
         @Published var sweetMealFactor: Decimal = 0
         @Published var displayPresets: Bool = true
+        @Published var confirmBolusWhenVeryLowGlucose: Bool = false
 
         override func subscribe() {
             units = settingsManager.settings.units
 
-            subscribeSetting(\.overrideFactor, on: $overrideFactor, initial: {
-                let value = max(min($0, 1.2), 0.1)
-                overrideFactor = value
-            }, map: {
-                $0
-            })
+            subscribeSetting(\.overrideFactor, on: $overrideFactor) { overrideFactor = $0 }
             subscribeSetting(\.fattyMeals, on: $fattyMeals) { fattyMeals = $0 }
             subscribeSetting(\.displayPresets, on: $displayPresets) { displayPresets = $0 }
-            subscribeSetting(\.fattyMealFactor, on: $fattyMealFactor, initial: {
-                let value = max(min($0, 1.2), 0.1)
-                fattyMealFactor = value
-            }, map: {
-                $0
-            })
+            subscribeSetting(\.fattyMealFactor, on: $fattyMealFactor) { fattyMealFactor = $0 }
             subscribeSetting(\.sweetMeals, on: $sweetMeals) { sweetMeals = $0 }
-            subscribeSetting(\.sweetMealFactor, on: $sweetMealFactor, initial: {
-                let value = max(min($0, 5), 1)
-                sweetMealFactor = value
-            }, map: {
-                $0
-            })
+            subscribeSetting(\.sweetMealFactor, on: $sweetMealFactor) { sweetMealFactor = $0 }
+            subscribeSetting(\.confirmBolus, on: $confirmBolusWhenVeryLowGlucose) { confirmBolusWhenVeryLowGlucose = $0 }
         }
     }
 }

+ 33 - 2
Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift

@@ -113,7 +113,7 @@ extension BolusCalculatorConfig {
                             "When \"Fatty Meal\" is selected in the bolus calculator, the recommended bolus will be multiplied by the \"Fatty Meal Bolus Percentage\" as well as the \"Recommended Bolus Percentage\"."
                         )
                         Text(
-                            "If you have a \"Recommended Bolus Percentage\" of 80%, and a \"Fatty Meal Bolus Percentage\" of 70%, your recommended bolus will be multiplied by: (80 × 70) ÷ 100 = 56%."
+                            "If you have a \"Recommended Bolus Percentage\" of 80%, and a \"Fatty Meal Bolus Percentage\" of 70%, your recommended bolus will be multiplied by: (80 × 70) / 100 = 56%."
                         )
                         Text("This could be useful for slow absorbing meals like pizza.")
                     }
@@ -147,11 +147,42 @@ extension BolusCalculatorConfig {
                             "When \"Super Bolus\" is selected in the bolus calculator, your current basal rate multiplied by \"Super Bolus Percentage\" will be added to your bolus recommendation."
                         )
                         Text(
-                            "If your current basal rate is 0.8 U/hr and \"Super Bolus Percentage\" is set to 200%: 0.8 × (200 ÷ 100) = 1.6 units will be added to your bolus recommendation."
+                            "If your current basal rate is 0.8 U/hr and \"Super Bolus Percentage\" is set to 200%: 0.8 × (200 / 100) = 1.6 units will be added to your bolus recommendation."
                         )
                         Text("This could be useful for fast absorbing meals like sugary cereal.")
                     }
                 )
+
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.confirmBolusWhenVeryLowGlucose,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0.map { AnyView($0) }
+                            hintLabel = String(localized: "Very Low Glucose Bolus Warning")
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: String(localized: "Very Low Glucose Warning"),
+                    miniHint: String(
+                        localized: "Warning when bolusing with a very low or forecasted very low glucose."
+                    ),
+                    verboseHint: VStack(alignment: .leading, spacing: 10) {
+                        Text("Default: OFF").bold()
+                        Text(
+                            "Triggers a confirmation dialog if you attempt to bolus when glucose is < \(state.units == .mgdL ? 54.description : 54.formattedAsMmolL) \(state.units.rawValue)."
+                        )
+                        Text(
+                            "Also triggered when the lowest forecasted glucose (minPredBG) is < \(state.units == .mgdL ? 54.description : 54.formattedAsMmolL) \(state.units.rawValue)."
+                        )
+                        Text(
+                            "Note: The forecast used for this warning does not include carbs or insulin that have not yet been logged."
+                        )
+                    }
+                )
             }
             .listSectionSpacing(sectionSpacing)
             .sheet(isPresented: $shouldDisplayHint) {

+ 1 - 1
Trio/Sources/Modules/DynamicSettings/View/DynamicSettingsRootView.swift

@@ -256,7 +256,7 @@ extension DynamicSettings {
                             Text(
                                 "Enabling Adjust Basal replaces the standard Autosens Ratio calculation with its own Autosens Ratio calculated as such:"
                             )
-                            Text("Autosens Ratio =\n(Weighted Average of TDD) ÷ (10-day Average of TDD)")
+                            Text("Autosens Ratio =\n(Weighted Average of TDD) / (10-day Average of TDD)")
                             Text("New Basal Profile =\n(Current Basal Profile) × (Autosens Ratio)")
                         },
                         headerText: String(localized: "Dynamic-dependent Features")

+ 2 - 2
Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift

@@ -262,7 +262,7 @@ extension SMBSettings {
                             Text(
                                 "𝒳 = Max SMB Basal Minutes"
                             )
-                            Text("(𝒳 ÷ 60) × current basal rate")
+                            Text("(𝒳 / 60) × current basal rate")
                         }
 
                         VStack(alignment: .leading, spacing: 10) {
@@ -308,7 +308,7 @@ extension SMBSettings {
                             Text(
                                 "𝒳 = Max UAM SMB Basal Minutes"
                             )
-                            Text("(𝒳 ÷ 60) × current basal rate")
+                            Text("(𝒳 / 60) × current basal rate")
                         }
                         VStack(alignment: .leading, spacing: 10) {
                             Text(

+ 6 - 0
Trio/Sources/Modules/Treatments/TreatmentsProvider.swift

@@ -33,5 +33,11 @@ extension Treatments {
                     sensitivities: []
                 )
         }
+
+        func getPreferences() async -> Preferences {
+            await storage.retrieveAsync(OpenAPS.Settings.preferences, as: Preferences.self)
+                ?? Preferences(from: OpenAPS.defaults(for: OpenAPS.Settings.preferences))
+                ?? Preferences(maxIOB: 0, maxCOB: 120)
+        }
     }
 }

+ 36 - 3
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -31,6 +31,8 @@ extension Treatments {
         var threshold: Decimal = 0
         var maxBolus: Decimal = 0
         var maxExternal: Decimal { maxBolus * 3 }
+        var maxIOB: Decimal = 0
+        var maxCOB: Decimal = 0
         var errorString: Decimal = 0
         var evBG: Decimal = 0
         var insulin: Decimal = 0
@@ -40,6 +42,7 @@ extension Treatments {
         var minDelta: Decimal = 0
         var expectedDelta: Decimal = 0
         var minPredBG: Decimal = 0
+        var lastLoopDate: Date?
         var isAwaitingDeterminationResult: Bool = false
         var carbRatio: Decimal = 0
 
@@ -58,6 +61,7 @@ extension Treatments {
         var wholeCobInsulin: Decimal = 0
         var iobInsulinReduction: Decimal = 0
         var wholeCalc: Decimal = 0
+        var factoredInsulin: Decimal = 0
         var insulinCalculated: Decimal = 0
         var fraction: Decimal = 0
         var basal: Decimal = 0
@@ -65,6 +69,7 @@ extension Treatments {
         var fattyMealFactor: Decimal = 0
         var useFattyMealCorrectionFactor: Bool = false
         var displayPresets: Bool = true
+        var confirmBolus: Bool = false
 
         var currentBasal: Decimal = 0
         var currentCarbRatio: Decimal = 0
@@ -244,6 +249,13 @@ extension Treatments {
                         self.maxBolus = getMaxBolus
                     }
                 }
+                group.addTask {
+                    let getPreferences = await self.provider.getPreferences()
+                    await MainActor.run {
+                        self.maxIOB = getPreferences.maxIOB
+                        self.maxCOB = getPreferences.maxCOB
+                    }
+                }
             }
         }
 
@@ -274,6 +286,7 @@ extension Treatments {
             sweetMeals = settings.settings.sweetMeals
             sweetMealFactor = settings.settings.sweetMealFactor
             displayPresets = settings.settings.displayPresets
+            confirmBolus = settings.settings.confirmBolus
             forecastDisplayType = settings.settings.forecastDisplayType
             lowGlucose = settingsManager.settings.low
             highGlucose = settingsManager.settings.high
@@ -373,6 +386,7 @@ extension Treatments {
                 iobInsulinReduction = result.iobInsulinReduction
                 superBolusInsulin = result.superBolusInsulin
                 wholeCalc = result.wholeCalc
+                factoredInsulin = result.factoredInsulin
                 fifteenMinInsulin = result.fifteenMinutesInsulin
             }
 
@@ -673,11 +687,28 @@ extension Treatments.StateModel {
     }
 
     @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
+        // Store all objects for the forecast graph
         glucoseFromPersistence = objects
 
-        let lastGlucose = glucoseFromPersistence.first?.glucose ?? 0
-        let thirdLastGlucose = glucoseFromPersistence.dropFirst(2).first?.glucose ?? 0
-        let delta = Decimal(lastGlucose) - Decimal(thirdLastGlucose)
+        // Always use the most recent reading for current glucose
+        let lastGlucose = objects.first?.glucose ?? 0
+
+        // Filter for readings less than 20 minutes old
+        let twentyMinutesAgo = Date().addingTimeInterval(-20 * 60)
+        let recentObjects = objects.filter {
+            guard let date = $0.date else { return false }
+            return date > twentyMinutesAgo
+        }
+
+        // Calculate delta using newest and oldest readings within 20-minute window
+        let delta: Decimal
+        if let newestInWindow = recentObjects.first?.glucose, let oldestInWindow = recentObjects.last?.glucose {
+            // Newest is at index 0, oldest is at the last index
+            delta = Decimal(newestInWindow) - Decimal(oldestInWindow)
+        } else {
+            // Not enough data points in the window
+            delta = 0
+        }
 
         currentBG = Decimal(lastGlucose)
         deltaBG = delta
@@ -765,6 +796,8 @@ extension Treatments.StateModel {
             // setup vars for bolus calculation
             insulinRequired = (mostRecentDetermination.insulinReq ?? 0) as Decimal
             evBG = (mostRecentDetermination.eventualBG ?? 0) as Decimal
+            minPredBG = (mostRecentDetermination.minPredBGFromReason ?? 0) as Decimal
+            lastLoopDate = apsManager.lastLoopDate as Date?
             insulin = (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal
             target = (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal
             isf = (mostRecentDetermination.insulinSensitivity ?? currentISF as NSDecimalNumber) as Decimal

Plik diff jest za duży
+ 870 - 379
Trio/Sources/Modules/Treatments/View/PopupView.swift


+ 67 - 19
Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift

@@ -391,8 +391,6 @@ extension Treatments {
             }
             .sheet(isPresented: $state.showInfo) {
                 PopupView(state: state)
-                    .presentationDetents([.fraction(0.9), .large])
-                    .presentationDragIndicator(.visible)
             }
             .sheet(isPresented: $showPresetSheet, onDismiss: {
                 showPresetSheet = false
@@ -421,6 +419,28 @@ extension Treatments {
             }
         }
 
+        @State private var showConfirmDialogForBolusing = false
+
+        private var bolusWarning: (shouldConfirm: Bool, warningMessage: String, color: Color) {
+            let isGlucoseVeryLow = state.currentBG < 54
+            let isForecastVeryLow = state.minPredBG < 54
+
+            // Only warn when enacting a bolus via pump
+            guard !state.externalInsulin, state.amount > 0 else {
+                return (false, "", .primary)
+            }
+
+            let warningMessage = isGlucoseVeryLow ? String(localized: "Glucose is very low.") :
+                isForecastVeryLow ? String(localized: "Glucose forecast is very low.") :
+                ""
+
+            let warningColor: Color = isGlucoseVeryLow ? .red : colorScheme == .dark ? .orange : .accentColor
+
+            let shouldConfirm = state.confirmBolus && (isGlucoseVeryLow || isForecastVeryLow)
+
+            return (shouldConfirm, warningMessage, warningColor)
+        }
+
         var treatmentButton: some View {
             var treatmentButtonBackground = Color(.systemBlue)
             if limitExceeded {
@@ -429,26 +449,54 @@ extension Treatments {
                 treatmentButtonBackground = Color(.systemGray)
             }
 
-            return Button {
-                state.invokeTreatmentsTask()
-            } label: {
-                HStack {
-                    if state.isBolusInProgress && state
-                        .amount > 0 && !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
-                    {
-                        ProgressView()
+            return Section {
+                Button {
+                    if bolusWarning.shouldConfirm {
+                        showConfirmDialogForBolusing = true
+                    } else {
+                        state.invokeTreatmentsTask()
+                    }
+                } label: {
+                    HStack {
+                        if state.isBolusInProgress && state.amount > 0 &&
+                            !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
+                        {
+                            ProgressView()
+                        }
+                        taskButtonLabel
                     }
-                    taskButtonLabel
+                    .font(.headline)
+                    .foregroundStyle(Color.white)
+                    .frame(maxWidth: .infinity, alignment: .center)
+                    .frame(height: 35)
+                }
+                .disabled(disableTaskButton)
+                .listRowBackground(treatmentButtonBackground)
+                .shadow(radius: 3)
+                .clipShape(RoundedRectangle(cornerRadius: 8))
+                .confirmationDialog(
+                    bolusWarning.warningMessage + " Bolus \(state.amount.description) U?",
+                    isPresented: $showConfirmDialogForBolusing,
+                    titleVisibility: .visible
+                ) {
+                    Button("Cancel", role: .cancel) {}
+                    Button(
+                        bolusWarning.warningMessage.isEmpty ? "Enact Bolus" : "Ignore Warning and Enact Bolus",
+                        role: bolusWarning.warningMessage.isEmpty ? nil : .destructive
+                    ) {
+                        state.invokeTreatmentsTask()
+                    }
+                }
+            } header: {
+                if !bolusWarning.warningMessage.isEmpty {
+                    Text(bolusWarning.warningMessage)
+                        .textCase(nil)
+                        .font(.subheadline)
+                        .foregroundColor(bolusWarning.color)
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .padding(.top, -22)
                 }
-                .font(.headline)
-                .foregroundStyle(Color.white)
-                .frame(maxWidth: .infinity, alignment: .center)
-                .frame(height: 35)
             }
-            .disabled(disableTaskButton)
-            .listRowBackground(treatmentButtonBackground)
-            .shadow(radius: 3)
-            .clipShape(RoundedRectangle(cornerRadius: 8))
         }
 
         private var taskButtonLabel: some View {

+ 87 - 22
Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift

@@ -30,6 +30,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
     private struct BolusCalculatorVariables {
         var insulinRequired: Decimal
         var evBG: Decimal
+        var minPredBG: Decimal
+        var lastLoopDate: Date?
         var insulin: Decimal
         var target: Decimal
         var isf: Decimal
@@ -170,6 +172,14 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             )
     }
 
+    /// Retrieves Preferences from storage
+    /// - Returns: Preferences object containing maxIOB and maxCOB
+    private func getPreferences() async -> Preferences {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.preferences, as: Preferences.self)
+            ?? Preferences(from: OpenAPS.defaults(for: OpenAPS.Settings.preferences))
+            ?? Preferences(maxIOB: 0, maxCOB: 120)
+    }
+
     /// Fetches recent glucose readings from CoreData
     /// - Returns: Array of NSManagedObjectIDs for glucose readings
     private func fetchGlucose() async throws -> [NSManagedObjectID] {
@@ -194,9 +204,27 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
     /// - Parameter objects: Array of GlucoseStored objects
     /// - Returns: GlucoseVariables containing current blood glucose and delta
     private func updateGlucoseVariables(with objects: [GlucoseStored]) -> GlucoseVariables {
+        // Always use the most recent reading for current glucose regardless of time
         let lastGlucose = objects.first?.glucose ?? 0
-        let thirdLastGlucose = objects.dropFirst(2).first?.glucose ?? 0
-        let delta = Decimal(lastGlucose) - Decimal(thirdLastGlucose)
+
+        // Filter for readings less than 20 minutes old
+        let twentyMinutesAgo = Date().addingTimeInterval(-20 * 60)
+        let recentObjects = objects.filter {
+            guard let date = $0.date else { return false }
+            return date > twentyMinutesAgo
+        }
+
+        // Calculate delta using newest and oldest readings within 20-minute window
+        let delta: Decimal
+        if recentObjects.count >= 2 {
+            // Newest is at index 0, oldest is at the last index
+            let newestInWindow = recentObjects.first?.glucose ?? 0
+            let oldestInWindow = recentObjects.last?.glucose ?? 0
+            delta = Decimal(newestInWindow) - Decimal(oldestInWindow)
+        } else {
+            // Not enough data points in the window
+            delta = 0
+        }
 
         return GlucoseVariables(currentBG: Decimal(lastGlucose), deltaBG: delta)
     }
@@ -220,6 +248,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             return BolusCalculatorVariables(
                 insulinRequired: 0,
                 evBG: 0,
+                minPredBG: 0,
+                lastLoopDate: nil,
                 insulin: 0,
                 target: currentBGTarget,
                 isf: currentISF,
@@ -234,6 +264,8 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
         return BolusCalculatorVariables(
             insulinRequired: (mostRecentDetermination.insulinReq ?? 0) as Decimal,
             evBG: (mostRecentDetermination.eventualBG ?? 0) as Decimal,
+            minPredBG: (mostRecentDetermination.minPredBGFromReason ?? 0) as Decimal,
+            lastLoopDate: apsManager.lastLoopDate as Date?,
             insulin: (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal,
             target: (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal,
             isf: (mostRecentDetermination.insulinSensitivity ?? NSDecimalNumber(decimal: currentISF)) as Decimal,
@@ -263,6 +295,12 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             let currentBGTarget = await getCurrentSettingValue(for: .bgTarget)
             let currentISF = await getCurrentSettingValue(for: .isf)
 
+            // Get max IOB and max COB
+
+            let preferences = await getPreferences()
+            let maxIOB = preferences.maxIOB
+            let maxCOB = preferences.maxCOB
+
             // Fetch glucose data
             let glucoseIds = try await fetchGlucose()
             let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared.getNSManagedObject(
@@ -306,7 +344,10 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
                 sweetMealFactor: settings.sweetMealFactor,
                 basal: bolusVars.basal,
                 fraction: settings.fraction,
-                maxBolus: maxBolus
+                maxBolus: maxBolus,
+                maxIOB: maxIOB,
+                maxCOB: maxCOB,
+                minPredBG: bolusVars.minPredBG
             )
         } catch {
             debug(
@@ -324,14 +365,15 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
     func calculateInsulin(input: CalculationInput) async -> CalculationResult {
         // insulin needed for the current blood glucose
         let targetDifference = input.currentBG - input.target
-        let targetDifferenceInsulin = apsManager.roundBolus(amount: targetDifference / input.isf)
+
+        let targetDifferenceInsulin = targetDifference / input.isf
 
         // more or less insulin because of bg trend in the last 15 minutes
-        let fifteenMinutesInsulin = apsManager.roundBolus(amount: input.deltaBG / input.isf)
+        let fifteenMinutesInsulin = input.deltaBG / input.isf
 
         // determine whole COB for which we want to dose insulin for and then determine insulin for wholeCOB
-        let wholeCob = Decimal(input.cob) + input.carbs
-        let wholeCobInsulin = apsManager.roundBolus(amount: wholeCob / input.carbRatio)
+        let wholeCob = min(Decimal(input.cob) + input.carbs, input.maxCOB)
+        let wholeCobInsulin = wholeCob / input.carbRatio
 
         // determine how much the calculator reduces/ increases the bolus because of IOB
         let iobInsulinReduction = (-1) * input.iob
@@ -352,29 +394,47 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
         }
 
         // apply custom factor at the end of the calculations
-        let result = wholeCalc * input.fraction
-
         // apply custom factor if fatty meal toggle in bolus calc config settings is on and the box for fatty meals is checked (in RootView)
-        var insulinCalculated: Decimal
+        var factoredInsulin = wholeCalc
+
+        // Apply Recommended Bolus Percentage (input.fraction) and if selected apply Fatty Meal Bolus Percentage (input.fattyMealFactor)
+        // If factoredInsulin is negative, though, don't apply either
+        if factoredInsulin > 0 {
+            factoredInsulin *= input.fraction
+
+            if input.useFattyMealCorrectionFactor {
+                factoredInsulin *= input.fattyMealFactor
+            }
+        }
+
+        // Calculate and add super bolus insulin if enabled
         var superBolusInsulin: Decimal = 0
-        if input.useFattyMealCorrectionFactor {
-            insulinCalculated = result * input.fattyMealFactor
-        } else if input.useSuperBolus {
+        if input.useSuperBolus {
             superBolusInsulin = input.sweetMealFactor * input.basal
-            insulinCalculated = result + superBolusInsulin
-        } else {
-            insulinCalculated = result
+            factoredInsulin += superBolusInsulin
         }
 
-        // display no negative insulinCalculated
-        insulinCalculated = max(insulinCalculated, 0)
-        insulinCalculated = min(insulinCalculated, input.maxBolus)
+        // the final result for recommended insulin amount
+        var insulinCalculated: Decimal
+        let isLoopStale = Date().timeIntervalSince(apsManager.lastLoopDate) > 15 * 60
 
-        // round calculated recommendation to allowed bolus increment
-        insulinCalculated = apsManager.roundBolus(amount: insulinCalculated)
+        // don't recommend insulin when current glucose or minPredBG is < 54 or last sucessful loop was over 15 minutes ago
+        if input.currentBG < 54 || input.minPredBG < 54 || isLoopStale {
+            insulinCalculated = 0
+        } else {
+            // no negative insulinCalculated
+            insulinCalculated = max(factoredInsulin, 0)
+            // don't exceed maxBolus
+            insulinCalculated = min(insulinCalculated, input.maxBolus)
+            // don't exceed maxIOB
+            insulinCalculated = min(insulinCalculated, input.maxIOB - input.iob)
+            // round calculated recommendation to allowed bolus increment
+            insulinCalculated = apsManager.roundBolus(amount: insulinCalculated)
+        }
 
         return CalculationResult(
             insulinCalculated: insulinCalculated,
+            factoredInsulin: factoredInsulin,
             wholeCalc: wholeCalc,
             correctionInsulin: targetDifferenceInsulin,
             iobInsulinReduction: iobInsulinReduction,
@@ -414,6 +474,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             // Return safe default values
             return CalculationResult(
                 insulinCalculated: 0,
+                factoredInsulin: 0,
                 wholeCalc: 0,
                 correctionInsulin: 0,
                 iobInsulinReduction: 0,
@@ -445,11 +506,15 @@ struct CalculationInput: Sendable {
     let basal: Decimal // Current basal rate
     let fraction: Decimal // General correction factor
     let maxBolus: Decimal // Maximum allowed bolus
+    let maxIOB: Decimal // Maximum allowed IOB to be used for rec. bolus calculation
+    let maxCOB: Decimal // Maximum allowed COB to be used for rec. bolus calculation
+    let minPredBG: Decimal // Minimum Predicted Glucose determined by Oref
 }
 
 /// Results of the bolus calculation
 struct CalculationResult: Sendable {
-    let insulinCalculated: Decimal // Final calculated insulin amount
+    let insulinCalculated: Decimal // Final calculated insulin amount which respects limits
+    let factoredInsulin: Decimal // Total calculation after adjustments
     let wholeCalc: Decimal // Total calculation before adjustments
     let correctionInsulin: Decimal // Insulin for BG correction
     let iobInsulinReduction: Decimal // IOB reduction amount

+ 29 - 5
TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift

@@ -37,6 +37,9 @@ import Testing
         let basal: Decimal = 1.5
         let fraction: Decimal = 0.8
         let maxBolus: Decimal = 10
+        let maxIOB: Decimal = 15.0
+        let maxCOB: Decimal = 120.0
+        let minPredBG: Decimal = 80.0
 
         // STEP 2: Create calculation input
         let input = CalculationInput(
@@ -54,7 +57,10 @@ import Testing
             sweetMealFactor: sweetMealFactor,
             basal: basal,
             fraction: fraction,
-            maxBolus: maxBolus
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: minPredBG
         )
 
         // STEP 3: Calculate insulin
@@ -144,6 +150,9 @@ import Testing
         let basal: Decimal = 1.5
         let fraction: Decimal = 0.8
         let maxBolus: Decimal = 10
+        let maxIOB: Decimal = 15.0
+        let maxCOB: Decimal = 120.0
+        let minPredBG: Decimal = 80.0
 
         // STEP 2: Create calculation input
         let input = CalculationInput(
@@ -161,7 +170,10 @@ import Testing
             sweetMealFactor: sweetMealFactor,
             basal: basal,
             fraction: fraction,
-            maxBolus: maxBolus
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: minPredBG
         )
 
         // STEP 3: Calculate insulin with fatty meal enabled
@@ -183,7 +195,10 @@ import Testing
             sweetMealFactor: sweetMealFactor,
             basal: basal,
             fraction: fraction,
-            maxBolus: maxBolus
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: minPredBG
         )
         let standardResult = await calculator.calculateInsulin(input: standardInput)
 
@@ -224,6 +239,9 @@ import Testing
         let basal: Decimal = 1.5 // Will be added to insulin calculation when super bolus is enabled
         let fraction: Decimal = 0.8
         let maxBolus: Decimal = 10
+        let maxIOB: Decimal = 15.0
+        let maxCOB: Decimal = 120.0
+        let minPredBG: Decimal = 80.0
 
         // STEP 2: Create calculation input with super bolus enabled
         let input = CalculationInput(
@@ -241,7 +259,10 @@ import Testing
             sweetMealFactor: sweetMealFactor,
             basal: basal,
             fraction: fraction,
-            maxBolus: maxBolus
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: minPredBG
         )
 
         // STEP 3: Calculate insulin with super bolus enabled
@@ -263,7 +284,10 @@ import Testing
             sweetMealFactor: sweetMealFactor,
             basal: basal,
             fraction: fraction,
-            maxBolus: maxBolus
+            maxBolus: maxBolus,
+            maxIOB: maxIOB,
+            maxCOB: maxCOB,
+            minPredBG: minPredBG
         )
         let standardResult = await calculator.calculateInsulin(input: standardInput)