From 08834405bf762be7bcb423f688bc7d33843e7e47 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 28 Dec 2023 13:00:01 +0200 Subject: [PATCH 01/43] Recommend for carbs only, excluding corrections --- Loop/Managers/LoopDataManager.swift | 43 ++++++++++++++----- .../Managers/LoopDataManagerDosingTests.swift | 16 +++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2319f4eceb..3f54bd5f0f 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1469,7 +1469,29 @@ extension LoopDataManager { let pendingInsulin = try getPendingInsulin() let shouldIncludePendingInsulin = pendingInsulin > 0 let prediction = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - return try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) + let recommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) + + guard recommendation != nil else { + return nil + } + + guard potentialCarbEntry != nil else { + return recommendation + } + + // when adding new carb entries, don't try to cover corrections. In particular when using + // auto-bolus, this prevents including the entire correction as part of the bolus for carbs + let predictionWithoutCarbs = try predictGlucose(using: .all, potentialBolus: nil, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) + + let recommendationWithoutCarbs = try recommendBolusValidatingDataRecency(forPrediction: predictionWithoutCarbs, consideringPotentialCarbEntry: nil) + + guard recommendationWithoutCarbs != nil && recommendationWithoutCarbs!.amount > 0 else { + return recommendation + } + + let updatedAmount = volumeRounder()(recommendation!.amount - recommendationWithoutCarbs!.amount) + + return ManualBolusRecommendation(amount: updatedAmount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice) } /// - Throws: @@ -1514,6 +1536,13 @@ extension LoopDataManager { return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry) } + private func volumeRounder() -> ((Double) -> Double) { + let result = { (_ units: Double) in + return self.delegate?.roundBolusVolume(units: units) ?? units + } + return result + } + /// - Throws: LoopError.configurationError private func recommendManualBolus(forPrediction predictedGlucose: [Sample], consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { @@ -1534,10 +1563,6 @@ extension LoopDataManager { // successful in any case. return nil } - - let volumeRounder = { (_ units: Double) in - return self.delegate?.roundBolusVolume(units: units) ?? units - } let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) @@ -1549,7 +1574,7 @@ extension LoopDataManager { model: model, pendingInsulin: 0, // Pending insulin is already reflected in the prediction maxBolus: maxBolus, - volumeRounder: volumeRounder + volumeRounder: volumeRounder() ) } @@ -1798,10 +1823,6 @@ extension LoopDataManager { switch settings.automaticDosingStrategy { case .automaticBolus: - let volumeRounder = { (_ units: Double) in - return self.delegate?.roundBolusVolume(units: units) ?? units - } - // Create dosing strategy based on user setting let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled ? GlucoseBasedApplicationFactorStrategy() @@ -1830,7 +1851,7 @@ extension LoopDataManager { maxAutomaticBolus: maxAutomaticBolus, partialApplicationFactor: effectiveBolusApplicationFactor * self.timeBasedDoseApplicationFactor, lastTempBasal: lastTempBasal, - volumeRounder: volumeRounder, + volumeRounder: volumeRounder(), rateRounder: rateRounder, isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true ) diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index a1f26a0e92..8a211ac3ec 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -487,6 +487,22 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 1.82, accuracy: 0.01) } + + func testLoopGetStateRecommendsManualBolusForCarbEntry() { + setUp(for: .highAndStable) + let exp = expectation(description: #function) + + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + // MockCarbStore does not predict any glucose effects, so the recommendation must be 0 + XCTAssertEqual(recommendedBolus!.amount, 0.0, accuracy: 0.01) + } func testLoopGetStateRecommendsManualBolusWithMomentum() { setUp(for: .highAndRisingWithCOB) From d16fbea2ce3c0808b8f5fd4550e4efd04aa82d25 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Fri, 29 Dec 2023 10:27:47 +0200 Subject: [PATCH 02/43] Support carb prediction for testing recommendations --- .../Managers/LoopDataManagerDosingTests.swift | 5 +- LoopTests/Managers/LoopDataManagerTests.swift | 5 +- LoopTests/Mock Stores/MockCarbStore.swift | 54 +++++++++++++++++-- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index 8a211ac3ec..da09d571bb 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -489,7 +489,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } func testLoopGetStateRecommendsManualBolusForCarbEntry() { - setUp(for: .highAndStable) + setUp(for: .highAndStable, predictGlucose: true) let exp = expectation(description: #function) var recommendedBolus: ManualBolusRecommendation? @@ -500,8 +500,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { exp.fulfill() } wait(for: [exp], timeout: 100000.0) - // MockCarbStore does not predict any glucose effects, so the recommendation must be 0 - XCTAssertEqual(recommendedBolus!.amount, 0.0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.amount, 0.5, accuracy: 0.01) } func testLoopGetStateRecommendsManualBolusWithMomentum() { diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 32c7d66f19..d82935ab03 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -127,7 +127,8 @@ class LoopDataManagerTests: XCTestCase { basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil, maxBolus: Double = 10, maxBasalRate: Double = 5.0, - dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly) + dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly, + predictGlucose: Bool = false) { let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") let insulinSensitivitySchedule = InsulinSensitivitySchedule( @@ -163,7 +164,7 @@ class LoopDataManagerTests: XCTestCase { doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile doseStore.sensitivitySchedule = insulinSensitivitySchedule let glucoseStore = MockGlucoseStore(for: test) - let carbStore = MockCarbStore(for: test) + let carbStore = MockCarbStore(for: test, predictGlucose: predictGlucose) carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule carbStore.carbRatioSchedule = carbRatioSchedule diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 4a5c016eb5..59baaa81ec 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -11,10 +11,12 @@ import LoopKit @testable import Loop class MockCarbStore: CarbStoreProtocol { + var predictGlucose: Bool var carbHistory: [StoredCarbEntry]? - init(for scenario: DosingTestScenario = .flatAndStable) { + init(for scenario: DosingTestScenario = .flatAndStable, predictGlucose: Bool = false) { self.scenario = scenario // The store returns different effect values based on the scenario + self.predictGlucose = predictGlucose self.carbHistory = loadHistoricCarbEntries(scenario: scenario) } @@ -52,14 +54,23 @@ class MockCarbStore: CarbStoreProtocol { return defaultAbsorptionTimes.slow * 2 } + var delay: TimeInterval = .minutes(10) var delta: TimeInterval = .minutes(5) var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) + var absorptionTimeOverrun = CarbMath.defaultAbsorptionTimeOverrun + + // copied from CarbStore.CarbModelSettings defaults + var absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() + var initialAbsorptionTimeOverrun: Double = 1.5 + var adaptiveAbsorptionRateEnabled: Bool = false + var adaptiveRateStandbyIntervalFraction: Double = 0.2 + var authorizationRequired: Bool = false var sharingDenied: Bool = false - + func authorize(toShare: Bool, read: Bool, _ completion: @escaping (HealthKitSampleStoreResult) -> Void) { completion(.success(true)) } @@ -81,7 +92,44 @@ class MockCarbStore: CarbStoreProtocol { } func glucoseEffects(of samples: [Sample], startingAt start: Date, endingAt end: Date?, effectVelocities: [LoopKit.GlucoseEffectVelocity]) throws -> [LoopKit.GlucoseEffect] where Sample : LoopKit.CarbEntry { - return [] + + guard predictGlucose && samples.count > 0 else { + return [] + } + + // this is basically copied over from CarbStore + + let carbDates = samples.map { $0.startDate } + let maxCarbDate = carbDates.max()! + let minCarbDate = carbDates.min()! + + guard let carbRatio = self.carbRatioScheduleApplyingOverrideHistory?.between(start: minCarbDate, end: maxCarbDate), + let insulinSensitivity = self.insulinSensitivityScheduleApplyingOverrideHistory?.quantitiesBetween(start: minCarbDate, end: maxCarbDate) else + { + return [] + } + + return samples.map( + to: effectVelocities, + carbRatio: carbRatio, + insulinSensitivity: insulinSensitivity, + absorptionTimeOverrun: absorptionTimeOverrun, + defaultAbsorptionTime: defaultAbsorptionTimes.medium, + delay: delay, + initialAbsorptionTimeOverrun: initialAbsorptionTimeOverrun, + absorptionModel: absorptionModel, + adaptiveAbsorptionRateEnabled: adaptiveAbsorptionRateEnabled, + adaptiveRateStandbyIntervalFraction: adaptiveRateStandbyIntervalFraction + ).dynamicGlucoseEffects( + from: start, + to: end, + carbRatios: carbRatio, + insulinSensitivities: insulinSensitivity, + defaultAbsorptionTime: defaultAbsorptionTimes.medium, + absorptionModel: absorptionModel, + delay: delay, + delta: delta + ) } func getCarbsOnBoardValues(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbValue]>) -> Void) { From d4ba097161da02aac52cda16cf9557c5bff16d81 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sun, 31 Dec 2023 00:01:00 +0200 Subject: [PATCH 03/43] Add feature flag to control whether used or not --- Common/FeatureFlags.swift | 7 +++++++ Loop/Managers/LoopDataManager.swift | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 0c6440b564..e9a14c7a99 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -39,6 +39,7 @@ struct FeatureFlagConfiguration: Decodable { let profileExpirationSettingsViewEnabled: Bool let missedMealNotifications: Bool let allowAlgorithmExperiments: Bool + let recommendBolusForCarbsWithoutCorrection: Bool fileprivate init() { @@ -232,6 +233,12 @@ struct FeatureFlagConfiguration: Decodable { #else self.allowAlgorithmExperiments = false #endif + + #if RECOMMEND_BOLUS_FOR_CARBS_WITHOUT_CORRECTION + self.recommendBolusForCarbsWithoutCorrection = true + #else + self.recommendBolusForCarbsWithoutCorrection = false + #endif } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 3f54bd5f0f..1f4800b560 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1475,7 +1475,7 @@ extension LoopDataManager { return nil } - guard potentialCarbEntry != nil else { + guard FeatureFlags.recommendBolusForCarbsWithoutCorrection && potentialCarbEntry != nil else { return recommendation } From 742352de46574ed1fe605c313991f99ffd3ae37a Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sun, 31 Dec 2023 20:54:18 +0200 Subject: [PATCH 04/43] Enable recommendation bolus breakdown --- Loop/Managers/LoopDataManager.swift | 18 ++--- Loop/View Models/BolusEntryViewModel.swift | 42 +++++++++- Loop/Views/BolusEntryView.swift | 80 ++++++++++++++++--- .../Managers/LoopDataManagerDosingTests.swift | 3 +- 4 files changed, 119 insertions(+), 24 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 1f4800b560..0dd7403ed7 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1475,23 +1475,19 @@ extension LoopDataManager { return nil } - guard FeatureFlags.recommendBolusForCarbsWithoutCorrection && potentialCarbEntry != nil else { - return recommendation + guard potentialCarbEntry != nil else { + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: 0.0, correctionAmount: recommendation!.amount) } - // when adding new carb entries, don't try to cover corrections. In particular when using - // auto-bolus, this prevents including the entire correction as part of the bolus for carbs let predictionWithoutCarbs = try predictGlucose(using: .all, potentialBolus: nil, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) let recommendationWithoutCarbs = try recommendBolusValidatingDataRecency(forPrediction: predictionWithoutCarbs, consideringPotentialCarbEntry: nil) - guard recommendationWithoutCarbs != nil && recommendationWithoutCarbs!.amount > 0 else { - return recommendation + guard recommendationWithoutCarbs != nil else { + return recommendation // unable to differentiate between carbs and correction } - - let updatedAmount = volumeRounder()(recommendation!.amount - recommendationWithoutCarbs!.amount) - - return ManualBolusRecommendation(amount: updatedAmount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice) + + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: recommendation!.amount - recommendationWithoutCarbs!.amount, correctionAmount: recommendationWithoutCarbs!.amount) } /// - Throws: @@ -2012,7 +2008,7 @@ protocol LoopState { /// Computes the recommended bolus for correcting a glucose prediction, optionally considering a potential carb entry. /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` - /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. + /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. /// - Returns: A bolus recommendation, or `nil` if not applicable /// - Throws: LoopError.missingDataError if recommendation cannot be computed func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index a86f20e0cc..cb85027dfb 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -113,6 +113,14 @@ final class BolusEntryViewModel: ObservableObject { let potentialCarbEntry: NewCarbEntry? let selectedCarbAbsorptionTimeEmoji: String? + @Published var carbBolus: HKQuantity? + var carbBolusAmount: Double? { + carbBolus?.doubleValue(for: .internationalUnit()) + } + @Published var correctionBolus: HKQuantity? + var correctionBolusAmount: Double? { + correctionBolus?.doubleValue(for: .internationalUnit()) + } @Published var recommendedBolus: HKQuantity? var recommendedBolusAmount: Double? { recommendedBolus?.doubleValue(for: .internationalUnit()) @@ -656,12 +664,26 @@ final class BolusEntryViewModel: ObservableObject { let now = Date() var recommendation: ManualBolusRecommendation? + let carbBolus: HKQuantity? + let correctionBolus: HKQuantity? let recommendedBolus: HKQuantity? let notice: Notice? do { recommendation = try computeBolusRecommendation(from: state) if let recommendation = recommendation { + if let carbsAmount = recommendation.carbsAmount { + carbBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: carbsAmount)) + } else { + carbBolus = nil + } + + if let correctionAmount = recommendation.correctionAmount { + correctionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: correctionAmount)) + } else { + correctionBolus = nil + } + recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: recommendation.amount)) //recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) @@ -680,10 +702,14 @@ final class BolusEntryViewModel: ObservableObject { notice = nil } } else { + carbBolus = nil + correctionBolus = nil recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) notice = nil } } catch { + carbBolus = nil + correctionBolus = nil recommendedBolus = nil switch error { @@ -700,6 +726,8 @@ final class BolusEntryViewModel: ObservableObject { DispatchQueue.main.async { let priorRecommendedBolus = self.recommendedBolus + self.carbBolus = carbBolus + self.correctionBolus = correctionBolus self.recommendedBolus = recommendedBolus self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } self.activeNotice = notice @@ -729,7 +757,7 @@ final class BolusEntryViewModel: ObservableObject { return try state.recommendBolus( consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: originalCarbEntry, - considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses + considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses ) } } @@ -793,8 +821,18 @@ final class BolusEntryViewModel: ObservableObject { bolusAmountFormatter.string(from: bolusAmount) ?? String(bolusAmount) } + var carbBolusString: String { + return bolusString(carbBolusAmount) + } + var correctionBolusString: String { + return bolusString(correctionBolusAmount) + } var recommendedBolusString: String { - guard let amount = recommendedBolusAmount else { + return bolusString(recommendedBolusAmount) + } + + func bolusString(_ bolusAmount: Double?) -> String { + guard let amount = bolusAmount else { return "–" } return formatBolusAmount(amount) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 4dd0c11a52..51822ac71e 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -191,7 +191,7 @@ struct BolusEntryView: View { if viewModel.isManualGlucoseEntryEnabled && viewModel.potentialCarbEntry != nil { potentialCarbEntryRow } - + if viewModel.isManualGlucoseEntryEnabled || viewModel.potentialCarbEntry != nil { recommendedBolusRow } @@ -226,20 +226,74 @@ struct BolusEntryView: View { } } } - + + private func displayRecommendationBreakdown() -> Bool { + return viewModel.potentialCarbEntry != nil && viewModel.carbBolus != nil && viewModel.correctionBolus != nil + } + + @State + private var recommendationBreakdownExpanded = false + + @ViewBuilder private var recommendedBolusRow: some View { - HStack { - Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") - Spacer() + + VStack { HStack(alignment: .firstTextBaseline) { - Text(viewModel.recommendedBolusString) - .font(.title) - .foregroundColor(Color(.label)) - bolusUnitsLabel + if displayRecommendationBreakdown() { + Text(recommendationBreakdownExpanded ? "-": "+") + } + Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.recommendedBolusString) + .font(.title) + .foregroundColor(Color(.label)) + bolusUnitsLabel + } + } + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .onTapGesture { + if displayRecommendationBreakdown() { + recommendationBreakdownExpanded.toggle() + } + } + if recommendationBreakdownExpanded { + VStack { + HStack { + Text(" ") + Text("Carb Bolus", comment: "Label for carb bolus row on bolus screen") + .font(.footnote) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.carbBolusString) + .font(.footnote) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + HStack { + Text(" ") + Text("Correction Bolus", comment: "Label for correction bolus row on bolus screen") + .font(.footnote) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.correctionBolusString) + .font(.footnote) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + } + .accessibilityElement(children: .combine) + .transition(.slide) } } - .accessibilityElement(children: .combine) + } + private func didBeginEditing() { if !editedBolusAmount { @@ -275,6 +329,12 @@ struct BolusEntryView: View { Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } + + private var breakdownBolusUnitsLabel: some View { + Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + .font(.footnote) + .foregroundColor(Color(.secondaryLabel)) + } private var enteredBolusStringBinding: Binding { Binding( diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index da09d571bb..acef0af422 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -500,7 +500,8 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { exp.fulfill() } wait(for: [exp], timeout: 100000.0) - XCTAssertEqual(recommendedBolus!.amount, 0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.correctionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) } func testLoopGetStateRecommendsManualBolusWithMomentum() { From 332f2e3c4bfc42897d078741b52d580c7e4e0d06 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Mon, 1 Jan 2024 13:09:43 +0200 Subject: [PATCH 05/43] Use chevron icon --- Loop/Views/BolusEntryView.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 51822ac71e..2624bfb684 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -239,10 +239,12 @@ struct BolusEntryView: View { VStack { HStack(alignment: .firstTextBaseline) { + Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") if displayRecommendationBreakdown() { - Text(recommendationBreakdownExpanded ? "-": "+") + Image(systemName: "chevron.right.circle") + .imageScale(.small) + .rotationEffect(.degrees(recommendationBreakdownExpanded ? 90 : 0)) } - Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.recommendedBolusString) @@ -289,6 +291,7 @@ struct BolusEntryView: View { } .accessibilityElement(children: .combine) .transition(.slide) + .animation(.smooth, value: recommendationBreakdownExpanded) } } From ac655eb70b0d38b2ab06759c64979e2ba21acb22 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Mon, 1 Jan 2024 13:20:45 +0200 Subject: [PATCH 06/43] Fix spacing issue --- Loop/Views/BolusEntryView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 2624bfb684..5a75467c45 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -237,7 +237,7 @@ struct BolusEntryView: View { @ViewBuilder private var recommendedBolusRow: some View { - VStack { + Section { HStack(alignment: .firstTextBaseline) { Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") if displayRecommendationBreakdown() { From 146493311867e7f0ec1d3e71a3a89f20637dd0de Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 2 Jan 2024 17:58:34 +0200 Subject: [PATCH 07/43] Work in progress for breakdown --- Loop/Managers/LoopDataManager.swift | 58 ++++++++++++++++------ Loop/View Models/BolusEntryViewModel.swift | 4 +- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 0dd7403ed7..9ae06c0dfd 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1465,7 +1465,7 @@ extension LoopDataManager { // successful in any case. return nil } - + let pendingInsulin = try getPendingInsulin() let shouldIncludePendingInsulin = pendingInsulin > 0 let prediction = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) @@ -1479,17 +1479,39 @@ extension LoopDataManager { return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: 0.0, correctionAmount: recommendation!.amount) } + let breakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .CarbBreakdown) + + guard breakdownRecommendation != nil else { + return recommendation // unable to differentiate between carbs and correction + } + let predictionWithoutCarbs = try predictGlucose(using: .all, potentialBolus: nil, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - let recommendationWithoutCarbs = try recommendBolusValidatingDataRecency(forPrediction: predictionWithoutCarbs, consideringPotentialCarbEntry: nil) + let breakdownRecommendationWithoutCarbs = try recommendBolusValidatingDataRecency(forPrediction: predictionWithoutCarbs, consideringPotentialCarbEntry: nil, usage: .CarbBreakdown) - guard recommendationWithoutCarbs != nil else { + guard breakdownRecommendationWithoutCarbs != nil else { return recommendation // unable to differentiate between carbs and correction } + + let carbsAmount = breakdownRecommendation!.amount - breakdownRecommendationWithoutCarbs!.amount - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: recommendation!.amount - recommendationWithoutCarbs!.amount, correctionAmount: recommendationWithoutCarbs!.amount) + var correctionAmount = recommendation!.amount - carbsAmount + + if recommendation!.amount == 0 { + let breakdownRecommendationForCorrection = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .CorrectionBreakdown) + + if let breakdownRecommendationForCorrection = breakdownRecommendationForCorrection { + correctionAmount = breakdownRecommendationForCorrection.amount - carbsAmount + } + } + + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: round(100 * carbsAmount) / 100, correctionAmount: round(100 * correctionAmount) / 100) } + fileprivate enum ManualBolusRecommendationUsage { + case Standard, CarbBreakdown, CorrectionBreakdown + } + /// - Throws: /// - LoopError.missingDataError /// - LoopError.glucoseTooOld @@ -1497,7 +1519,8 @@ extension LoopDataManager { /// - LoopError.pumpDataTooOld /// - LoopError.configurationError fileprivate func recommendBolusValidatingDataRecency(forPrediction predictedGlucose: [Sample], - consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { + consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, + usage: ManualBolusRecommendationUsage = .Standard) throws -> ManualBolusRecommendation? { guard let glucose = glucoseStore.latestGlucose else { throw LoopError.missingDataError(.glucose) } @@ -1529,7 +1552,7 @@ extension LoopDataManager { throw LoopError.missingDataError(.insulinEffect) } - return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry) + return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry, usage: usage) } private func volumeRounder() -> ((Double) -> Double) { @@ -1541,14 +1564,21 @@ extension LoopDataManager { /// - Throws: LoopError.configurationError private func recommendManualBolus(forPrediction predictedGlucose: [Sample], - consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { - guard let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { - throw LoopError.configurationError(.glucoseTargetRangeSchedule) - } + consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, + usage: ManualBolusRecommendationUsage = .Standard) throws -> ManualBolusRecommendation? { guard let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { throw LoopError.configurationError(.insulinSensitivitySchedule) } - guard let maxBolus = settings.maximumBolus else { + + let breakdownGlucoseRangeSchedule = GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: -1E5, maxValue: -1E5)) + ]) + + guard let glucoseTargetRange = usage == .CarbBreakdown ? breakdownGlucoseRangeSchedule : + settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { + throw LoopError.configurationError(.glucoseTargetRangeSchedule) + } + guard let maxBolus = usage == .Standard ? settings.maximumBolus : 1E15 else { throw LoopError.configurationError(.maximumBolus) } @@ -1561,16 +1591,16 @@ extension LoopDataManager { } let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) - + return predictedGlucose.recommendedManualBolus( to: glucoseTargetRange, at: now(), - suspendThreshold: settings.suspendThreshold?.quantity, + suspendThreshold: usage == .Standard ? settings.suspendThreshold?.quantity : breakdownGlucoseRangeSchedule?.quantityRange(at: now()).lowerBound, sensitivity: insulinSensitivity, model: model, pendingInsulin: 0, // Pending insulin is already reflected in the prediction maxBolus: maxBolus, - volumeRounder: volumeRounder() + volumeRounder: usage == .Standard ? volumeRounder() : nil ) } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index cb85027dfb..1d7f1b29af 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -673,13 +673,13 @@ final class BolusEntryViewModel: ObservableObject { if let recommendation = recommendation { if let carbsAmount = recommendation.carbsAmount { - carbBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: carbsAmount)) + carbBolus = HKQuantity(unit: .internationalUnit(), doubleValue: carbsAmount) } else { carbBolus = nil } if let correctionAmount = recommendation.correctionAmount { - correctionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: correctionAmount)) + correctionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: correctionAmount) } else { correctionBolus = nil } From 6c61b0ad1dd8427188b849f48ed03b225d8f569a Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Wed, 3 Jan 2024 12:20:19 +0200 Subject: [PATCH 08/43] Fix up calculations. Add support for max bolus to breakdown --- Loop/Managers/LoopDataManager.swift | 138 ++++++++++++++++----- Loop/View Models/BolusEntryViewModel.swift | 45 +++++-- Loop/Views/BolusEntryView.swift | 65 ++++++---- 3 files changed, 190 insertions(+), 58 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 9ae06c0dfd..d098664e3b 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1456,7 +1456,7 @@ extension LoopDataManager { let prediction = try predictGlucoseFromManualGlucose(glucose, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) return try recommendManualBolus(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) } - + /// - Throws: LoopError.missingDataError fileprivate func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { guard lastRequestedBolus == nil else { @@ -1474,44 +1474,123 @@ extension LoopDataManager { guard recommendation != nil else { return nil } + + let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) - guard potentialCarbEntry != nil else { - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: 0.0, correctionAmount: recommendation!.amount) + guard carbBreakdownRecommendation != nil else { + if potentialCarbEntry != nil { + return recommendation // unable to differentiate between carbs and correction + } + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: 0.0, correctionAmount: recommendation!.amount, missingAmount: recommendation!.missingAmount) } - let breakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .CarbBreakdown) - - guard breakdownRecommendation != nil else { - return recommendation // unable to differentiate between carbs and correction + guard potentialCarbEntry != nil else { + let extra = Swift.max(recommendation!.missingAmount ?? 0, 0) + + var correctionAmount = recommendation!.amount + extra + + if (correctionAmount == 0) { + let correctionBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .correctionBreakdown) + + if correctionBreakdownRecommendation != nil { + correctionAmount = calcCorrectionAmount(carbsAmount: 0, carbBreakdownRecommendation: carbBreakdownRecommendation!, correctionBreakdownRecommendation: correctionBreakdownRecommendation!) + } else { + correctionAmount = 0 + } + } + + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: 0.0, correctionAmount: correctionAmount, missingAmount: recommendation!.missingAmount) } + let predictionWithoutCarbs = try predictGlucose(using: .all, potentialBolus: nil, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - let breakdownRecommendationWithoutCarbs = try recommendBolusValidatingDataRecency(forPrediction: predictionWithoutCarbs, consideringPotentialCarbEntry: nil, usage: .CarbBreakdown) + let carbBreakdownRecommendationWithoutCarbs = try recommendBolusValidatingDataRecency(forPrediction: predictionWithoutCarbs, consideringPotentialCarbEntry: nil, usage: .carbBreakdown) - guard breakdownRecommendationWithoutCarbs != nil else { - return recommendation // unable to differentiate between carbs and correction + guard carbBreakdownRecommendationWithoutCarbs != nil else { + return recommendation // unable to directly calculate carbsAmount } - let carbsAmount = breakdownRecommendation!.amount - breakdownRecommendationWithoutCarbs!.amount - - var correctionAmount = recommendation!.amount - carbsAmount - - if recommendation!.amount == 0 { - let breakdownRecommendationForCorrection = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .CorrectionBreakdown) + let carbsAmount = carbBreakdownRecommendation!.amount - carbBreakdownRecommendationWithoutCarbs!.amount - if let breakdownRecommendationForCorrection = breakdownRecommendationForCorrection { - correctionAmount = breakdownRecommendationForCorrection.amount - carbsAmount - } + let correctionBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .correctionBreakdown) + + let correctionAmount : Double + + if recommendation!.amount <= 0 && correctionBreakdownRecommendation != nil { + correctionAmount = calcCorrectionAmount(carbsAmount: carbsAmount, carbBreakdownRecommendation: carbBreakdownRecommendation!, correctionBreakdownRecommendation: correctionBreakdownRecommendation!) + } else { + correctionAmount = recommendation!.amount - carbsAmount } - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: round(100 * carbsAmount) / 100, correctionAmount: round(100 * correctionAmount) / 100) + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: carbsAmount, correctionAmount: correctionAmount, missingAmount: recommendation!.missingAmount) + } + + fileprivate func calcCorrectionAmount(carbsAmount: Double, + carbBreakdownRecommendation : ManualBolusRecommendation, + correctionBreakdownRecommendation: ManualBolusRecommendation) -> Double { + // carbs + correction + y = a + // carbs + correction + ratio*y = b + // --> y = (b-a)/(ratio - 1) + // --> correction = a - y - carbs + + let ratio = ManualBolusRecommendationUsage.correctionBreakdown.targetsAdjustment / ManualBolusRecommendationUsage.carbBreakdown.targetsAdjustment + let delta = correctionBreakdownRecommendation.amount - carbBreakdownRecommendation.amount + + return carbBreakdownRecommendation.amount - delta / (ratio - 1) - carbsAmount } fileprivate enum ManualBolusRecommendationUsage { - case Standard, CarbBreakdown, CorrectionBreakdown + case standard, carbBreakdown, correctionBreakdown + + func suspendThresholdOverride(_ suspendThreshold: HKQuantity?) -> HKQuantity? { + switch self { + case .standard: return suspendThreshold + default: return nil + } + } + + func maxBolusOverride(_ maxBolus: Double) -> Double { + switch self { + case .standard: return maxBolus + default: return 1E15 + } + } + + func volumeRounderOverride(_ volumeRounder: @escaping (Double) -> Double) -> ((Double) -> Double)? { + switch self { + case .standard: return volumeRounder + default: return nil + } + } + + var targetsAdjustment : Double { + switch self { + case .standard: return 0.0 + case .carbBreakdown: return -1E5 + case .correctionBreakdown: return -2E5 + } + } + + func glucoseTargetsOverride(_ schedule: GlucoseRangeSchedule) -> GlucoseRangeSchedule{ + switch self { + case .standard: return schedule + default: return adjustSchedule(schedule, amount: self.targetsAdjustment) + } + } + + private func adjustSchedule(_ schedule: GlucoseRangeSchedule, amount: Double) -> GlucoseRangeSchedule { + return GlucoseRangeSchedule(unit: schedule.unit, + dailyItems: schedule.items.map{scheduleValue in + scheduleValue.map{range in + DoubleRange(minValue: range.minValue + amount, maxValue: range.maxValue + amount)}}, + timeZone: schedule.timeZone)! + } + + } + /// - Throws: /// - LoopError.missingDataError /// - LoopError.glucoseTooOld @@ -1520,7 +1599,7 @@ extension LoopDataManager { /// - LoopError.configurationError fileprivate func recommendBolusValidatingDataRecency(forPrediction predictedGlucose: [Sample], consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, - usage: ManualBolusRecommendationUsage = .Standard) throws -> ManualBolusRecommendation? { + usage: ManualBolusRecommendationUsage = .standard) throws -> ManualBolusRecommendation? { guard let glucose = glucoseStore.latestGlucose else { throw LoopError.missingDataError(.glucose) } @@ -1565,7 +1644,7 @@ extension LoopDataManager { /// - Throws: LoopError.configurationError private func recommendManualBolus(forPrediction predictedGlucose: [Sample], consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, - usage: ManualBolusRecommendationUsage = .Standard) throws -> ManualBolusRecommendation? { + usage: ManualBolusRecommendationUsage = .standard) throws -> ManualBolusRecommendation? { guard let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { throw LoopError.configurationError(.insulinSensitivitySchedule) } @@ -1574,11 +1653,10 @@ extension LoopDataManager { RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: -1E5, maxValue: -1E5)) ]) - guard let glucoseTargetRange = usage == .CarbBreakdown ? breakdownGlucoseRangeSchedule : - settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { + guard let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { throw LoopError.configurationError(.glucoseTargetRangeSchedule) } - guard let maxBolus = usage == .Standard ? settings.maximumBolus : 1E15 else { + guard let maxBolus = settings.maximumBolus else { throw LoopError.configurationError(.maximumBolus) } @@ -1593,14 +1671,14 @@ extension LoopDataManager { let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) return predictedGlucose.recommendedManualBolus( - to: glucoseTargetRange, + to: usage.glucoseTargetsOverride(glucoseTargetRange), at: now(), - suspendThreshold: usage == .Standard ? settings.suspendThreshold?.quantity : breakdownGlucoseRangeSchedule?.quantityRange(at: now()).lowerBound, + suspendThreshold: usage.suspendThresholdOverride(settings.suspendThreshold?.quantity), sensitivity: insulinSensitivity, model: model, pendingInsulin: 0, // Pending insulin is already reflected in the prediction - maxBolus: maxBolus, - volumeRounder: usage == .Standard ? volumeRounder() : nil + maxBolus: usage.maxBolusOverride(maxBolus), + volumeRounder: usage.volumeRounderOverride(volumeRounder()) ) } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 1d7f1b29af..f7b057be32 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -121,6 +121,10 @@ final class BolusEntryViewModel: ObservableObject { var correctionBolusAmount: Double? { correctionBolus?.doubleValue(for: .internationalUnit()) } + @Published var missingBolus: HKQuantity? + var missingBolusAmount: Double? { + missingBolus?.doubleValue(for: .internationalUnit()) + } @Published var recommendedBolus: HKQuantity? var recommendedBolusAmount: Double? { recommendedBolus?.doubleValue(for: .internationalUnit()) @@ -452,6 +456,12 @@ final class BolusEntryViewModel: ObservableObject { formatter.numberFormatter.roundingMode = .down return formatter.numberFormatter }() + + private lazy var breakdownBolusAmountFormatter: NumberFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit()) + formatter.numberFormatter.roundingMode = .halfUp + return formatter.numberFormatter + }() private lazy var absorptionTimeFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -667,6 +677,7 @@ final class BolusEntryViewModel: ObservableObject { let carbBolus: HKQuantity? let correctionBolus: HKQuantity? let recommendedBolus: HKQuantity? + let missingBolus: HKQuantity? let notice: Notice? do { recommendation = try computeBolusRecommendation(from: state) @@ -683,6 +694,16 @@ final class BolusEntryViewModel: ObservableObject { } else { correctionBolus = nil } + + if let missingAmount = recommendation.missingAmount { + if missingAmount != 0 { + missingBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) + } else { + missingBolus = nil + } + } else { + missingBolus = nil + } recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: recommendation.amount)) //recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) @@ -704,12 +725,14 @@ final class BolusEntryViewModel: ObservableObject { } else { carbBolus = nil correctionBolus = nil + missingBolus = nil recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) notice = nil } } catch { carbBolus = nil correctionBolus = nil + missingBolus = nil recommendedBolus = nil switch error { @@ -728,6 +751,7 @@ final class BolusEntryViewModel: ObservableObject { let priorRecommendedBolus = self.recommendedBolus self.carbBolus = carbBolus self.correctionBolus = correctionBolus + self.missingBolus = missingBolus self.recommendedBolus = recommendedBolus self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } self.activeNotice = notice @@ -817,25 +841,32 @@ final class BolusEntryViewModel: ObservableObject { chartDateInterval = DateInterval(start: chartStartDate, duration: .hours(totalHours)) } - func formatBolusAmount(_ bolusAmount: Double) -> String { - bolusAmountFormatter.string(from: bolusAmount) ?? String(bolusAmount) + func formatBolusAmount(_ bolusAmount: Double, forBreakdown: Bool = false) -> String { + let formatter = forBreakdown ? breakdownBolusAmountFormatter : bolusAmountFormatter + return formatter.string(from: bolusAmount) ?? String(bolusAmount) } var carbBolusString: String { - return bolusString(carbBolusAmount) + return bolusString(carbBolusAmount, forBreakdown: true) } var correctionBolusString: String { - return bolusString(correctionBolusAmount) + return bolusString(correctionBolusAmount, forBreakdown: true) + } + var negativeMissingBolusString: String { + guard missingBolusAmount != nil else { + return bolusString(nil, forBreakdown: true) + } + return bolusString(-missingBolusAmount!, forBreakdown: true) } var recommendedBolusString: String { - return bolusString(recommendedBolusAmount) + return bolusString(recommendedBolusAmount, forBreakdown: false) } - func bolusString(_ bolusAmount: Double?) -> String { + func bolusString(_ bolusAmount: Double?, forBreakdown: Bool) -> String { guard let amount = bolusAmount else { return "–" } - return formatBolusAmount(amount) + return formatBolusAmount(amount, forBreakdown: forBreakdown) } func updateEnteredBolus(_ enteredBolusString: String) { diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 5a75467c45..a9e889a52e 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -228,7 +228,11 @@ struct BolusEntryView: View { } private func displayRecommendationBreakdown() -> Bool { - return viewModel.potentialCarbEntry != nil && viewModel.carbBolus != nil && viewModel.correctionBolus != nil + if viewModel.potentialCarbEntry != nil { + return viewModel.carbBolus != nil && viewModel.correctionBolus != nil + } else { + return viewModel.correctionBolus != nil + } } @State @@ -262,32 +266,51 @@ struct BolusEntryView: View { } if recommendationBreakdownExpanded { VStack { - HStack { - Text(" ") - Text("Carb Bolus", comment: "Label for carb bolus row on bolus screen") - .font(.footnote) - Spacer() - HStack(alignment: .firstTextBaseline) { - Text(viewModel.carbBolusString) + if viewModel.potentialCarbEntry != nil && viewModel.carbBolus != nil { + HStack { + Text(" ") + Text("Carb Bolus", comment: "Label for carb bolus row on bolus screen") .font(.footnote) - .foregroundColor(Color(.label)) - breakdownBolusUnitsLabel + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.carbBolusString) + .font(.footnote) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } } + .accessibilityElement(children: .combine) } - .accessibilityElement(children: .combine) - HStack { - Text(" ") - Text("Correction Bolus", comment: "Label for correction bolus row on bolus screen") - .font(.footnote) - Spacer() - HStack(alignment: .firstTextBaseline) { - Text(viewModel.correctionBolusString) + if viewModel.correctionBolus != nil { + HStack { + Text(" ") + Text("Correction Bolus", comment: "Label for correction bolus row on bolus screen") + .font(.footnote) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.correctionBolusString) + .font(.footnote) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + } + if viewModel.missingBolus != nil { + HStack { + Text(" ") + Text("Limit to Max Bolus", comment: "Label for max bolus row on bolus screen") .font(.footnote) - .foregroundColor(Color(.label)) - breakdownBolusUnitsLabel + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.negativeMissingBolusString) + .font(.footnote) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } } + .accessibilityElement(children: .combine) } - .accessibilityElement(children: .combine) } .accessibilityElement(children: .combine) .transition(.slide) From 9600aa671c82540fc8bca45ab274fa014f725cec Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Wed, 3 Jan 2024 13:39:42 +0200 Subject: [PATCH 09/43] Add unit tests --- .../high_and_stable_carb_effect.json | 5 +- .../Managers/LoopDataManagerDosingTests.swift | 75 +++++++++++++++++++ LoopTests/Managers/LoopDataManagerTests.swift | 12 ++- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json index 64848ef5a2..aa78c823b5 100644 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json +++ b/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json @@ -3,8 +3,7 @@ "date": "2020-08-12T12:05:00", "unit": "mg/dL", "amount": 0.0 - }, - { + }, { "date": "2020-08-12T12:10:00", "unit": "mg/dL", "amount": 0.0 @@ -319,4 +318,4 @@ "unit": "mg/dL", "amount": 22.5 } -] \ No newline at end of file +] diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index acef0af422..3b553655f0 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -486,6 +486,24 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.correctionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusMaxBolusClamping() { + setUp(for: .highAndStable, maxBolus: 1) + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 1, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.correctionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.missingAmount!, 0.82, accuracy: 0.01) } func testLoopGetStateRecommendsManualBolusForCarbEntry() { @@ -500,8 +518,65 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { exp.fulfill() } wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 2.32, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.correctionAmount!, 1.82, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForCarbEntryMaxBolusClamping() { + setUp(for: .highAndStable, maxBolus: 1, predictGlucose: true) + let exp = expectation(description: #function) + + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 1, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.correctionAmount!, 0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.missingAmount!, 1.32, accuracy: 0.01) + } + + func testLoopGetStateRecommendsManualBolusForBeneathRange() { + setUp(for: .highAndStable, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 230, maxValue: 230))])) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.correctionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForSuspendForCarbEntry() { + setUp(for: .highAndStable, predictGlucose: true, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 230, maxValue: 230))]), suspendThresholdValue: 180) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.correctionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) } func testLoopGetStateRecommendsManualBolusWithMomentum() { diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index d82935ab03..6bb76b5574 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -116,7 +116,7 @@ class LoopDataManagerTests: XCTestCase { RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! } - + // MARK: Mock stores var now: Date! var dosingDecisionStore: MockDosingDecisionStore! @@ -128,7 +128,10 @@ class LoopDataManagerTests: XCTestCase { maxBolus: Double = 10, maxBasalRate: Double = 5.0, dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly, - predictGlucose: Bool = false) + predictGlucose: Bool = false, + correctionRanges: GlucoseRangeSchedule? = nil, + suspendThresholdValue: Double? = nil + ) { let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") let insulinSensitivitySchedule = InsulinSensitivitySchedule( @@ -146,10 +149,13 @@ class LoopDataManagerTests: XCTestCase { ], timeZone: .utcTimeZone )! + let glucoseTargets = correctionRanges ?? glucoseTargetRangeSchedule + + let suspendThreshold = suspendThresholdValue == nil ? suspendThreshold : GlucoseThreshold(unit: .milligramsPerDeciliter, value: suspendThresholdValue!) let settings = LoopSettings( dosingEnabled: false, - glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + glucoseTargetRangeSchedule: glucoseTargets, insulinSensitivitySchedule: insulinSensitivitySchedule, basalRateSchedule: basalRateSchedule, carbRatioSchedule: carbRatioSchedule, From 8bcfcb03f8fade2f19d6593a6b4987603975f62c Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Wed, 3 Jan 2024 14:09:16 +0200 Subject: [PATCH 10/43] Fixup correction calc when clamped by max bolus --- Loop/Managers/LoopDataManager.swift | 3 ++- LoopTests/Managers/LoopDataManagerDosingTests.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index d098664e3b..b0cc9613c2 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1520,7 +1520,8 @@ extension LoopDataManager { if recommendation!.amount <= 0 && correctionBreakdownRecommendation != nil { correctionAmount = calcCorrectionAmount(carbsAmount: carbsAmount, carbBreakdownRecommendation: carbBreakdownRecommendation!, correctionBreakdownRecommendation: correctionBreakdownRecommendation!) } else { - correctionAmount = recommendation!.amount - carbsAmount + let extra = Swift.max(recommendation!.missingAmount ?? 0, 0) + correctionAmount = recommendation!.amount + extra - carbsAmount } return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: carbsAmount, correctionAmount: correctionAmount, missingAmount: recommendation!.missingAmount) diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index 3b553655f0..b1c3cc0fc5 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -537,7 +537,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 1, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.correctionAmount!, 0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.correctionAmount!, 1.82, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.missingAmount!, 1.32, accuracy: 0.01) } From eb785dea4c4e33130689da4a7ba5501f60c9b6f0 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Wed, 3 Jan 2024 14:27:18 +0200 Subject: [PATCH 11/43] Round to 2 fractional digits in UI --- Loop/View Models/BolusEntryViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index f7b057be32..411ec78afc 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -460,6 +460,7 @@ final class BolusEntryViewModel: ObservableObject { private lazy var breakdownBolusAmountFormatter: NumberFormatter = { let formatter = QuantityFormatter(for: .internationalUnit()) formatter.numberFormatter.roundingMode = .halfUp + formatter.numberFormatter.maximumFractionDigits = 2 return formatter.numberFormatter }() From b66ce5b7061c97e38ab34c70641aed9839a387de Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Wed, 3 Jan 2024 20:53:09 +0200 Subject: [PATCH 12/43] Change icon for bidi languages --- Loop/Views/BolusEntryView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index a9e889a52e..af1f7258b0 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -245,7 +245,7 @@ struct BolusEntryView: View { HStack(alignment: .firstTextBaseline) { Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") if displayRecommendationBreakdown() { - Image(systemName: "chevron.right.circle") + Image(systemName: "chevron.forward.circle") .imageScale(.small) .rotationEffect(.degrees(recommendationBreakdownExpanded ? 90 : 0)) } From 2ce47773fdc531f634f9a294f4a566a0c3a0cfdf Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 4 Jan 2024 08:18:03 +0200 Subject: [PATCH 13/43] Add support for Limit to 0 Bolus --- Loop/Managers/LoopDataManager.swift | 10 ++++++++-- Loop/Views/BolusEntryView.swift | 11 ++++++++--- LoopTests/Managers/LoopDataManagerDosingTests.swift | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index b0cc9613c2..0b2832da8b 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1515,16 +1515,22 @@ extension LoopDataManager { let correctionBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .correctionBreakdown) + var missingAmount = recommendation!.missingAmount let correctionAmount : Double if recommendation!.amount <= 0 && correctionBreakdownRecommendation != nil { correctionAmount = calcCorrectionAmount(carbsAmount: carbsAmount, carbBreakdownRecommendation: carbBreakdownRecommendation!, correctionBreakdownRecommendation: correctionBreakdownRecommendation!) + + let amount = carbsAmount + correctionAmount + if volumeRounder()(amount) != 0 { + missingAmount = amount + } } else { - let extra = Swift.max(recommendation!.missingAmount ?? 0, 0) + let extra = Swift.max(recommendation!.missingAmount ?? 0, 0) correctionAmount = recommendation!.amount + extra - carbsAmount } - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: carbsAmount, correctionAmount: correctionAmount, missingAmount: recommendation!.missingAmount) + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: carbsAmount, correctionAmount: correctionAmount, missingAmount: missingAmount) } fileprivate func calcCorrectionAmount(carbsAmount: Double, diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index af1f7258b0..60a4fa9433 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -296,11 +296,16 @@ struct BolusEntryView: View { } .accessibilityElement(children: .combine) } - if viewModel.missingBolus != nil { + if viewModel.missingBolus != nil && viewModel.recommendedBolusAmount != nil { HStack { Text(" ") - Text("Limit to Max Bolus", comment: "Label for max bolus row on bolus screen") - .font(.footnote) + if viewModel.recommendedBolusAmount! != 0 { + Text("Limit to Max Bolus", comment: "Label for max bolus row on bolus screen") + .font(.footnote) + } else { + Text("Limit to 0 Bolus", comment: "Label for 0 bolus row on bolus screen") + .font(.footnote) + } Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.negativeMissingBolusString) diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index b1c3cc0fc5..111acc0099 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -576,7 +576,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.correctionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) - XCTAssertNil(recommendedBolus!.missingAmount) + XCTAssertEqual(recommendedBolus!.missingAmount!, 0.5 + (176.21882841682697 - 230) / 45, accuracy: 0.01) } func testLoopGetStateRecommendsManualBolusWithMomentum() { From ed552c739db5d0c258d4363e100aea57fc001219 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 4 Jan 2024 08:40:12 +0200 Subject: [PATCH 14/43] Don't calc correction when in range --- Loop/Managers/LoopDataManager.swift | 2 +- .../Managers/LoopDataManagerDosingTests.swift | 41 +++++++++++++++++-- LoopTests/Managers/LoopDataManagerTests.swift | 4 +- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 0b2832da8b..313c5ded19 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1518,7 +1518,7 @@ extension LoopDataManager { var missingAmount = recommendation!.missingAmount let correctionAmount : Double - if recommendation!.amount <= 0 && correctionBreakdownRecommendation != nil { + if recommendation!.amount <= 0 && correctionBreakdownRecommendation != nil && recommendation!.notice != .predictedGlucoseInRange { correctionAmount = calcCorrectionAmount(carbsAmount: carbsAmount, carbBreakdownRecommendation: carbBreakdownRecommendation!, correctionBreakdownRecommendation: correctionBreakdownRecommendation!) let amount = carbsAmount + correctionAmount diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index 111acc0099..a7bb24422e 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -507,7 +507,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } func testLoopGetStateRecommendsManualBolusForCarbEntry() { - setUp(for: .highAndStable, predictGlucose: true) + setUp(for: .highAndStable, predictCarbGlucoseEffects: true) let exp = expectation(description: #function) var recommendedBolus: ManualBolusRecommendation? @@ -525,7 +525,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } func testLoopGetStateRecommendsManualBolusForCarbEntryMaxBolusClamping() { - setUp(for: .highAndStable, maxBolus: 1, predictGlucose: true) + setUp(for: .highAndStable, maxBolus: 1, predictCarbGlucoseEffects: true) let exp = expectation(description: #function) var recommendedBolus: ManualBolusRecommendation? @@ -560,7 +560,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } func testLoopGetStateRecommendsManualBolusForSuspendForCarbEntry() { - setUp(for: .highAndStable, predictGlucose: true, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 230, maxValue: 230))]), suspendThresholdValue: 180) let exp = expectation(description: #function) @@ -578,6 +578,41 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.missingAmount!, 0.5 + (176.21882841682697 - 230) / 45, accuracy: 0.01) } + + func testLoopGetStateRecommendsManualBolusForInRangeCarbEntry() { + setUp(for: .flatAndStable, predictCarbGlucoseEffects: true) + + let exp1 = expectation(description: #function) + var recommendedBolus1: ManualBolusRecommendation? + + let exp2 = expectation(description: #function) + var recommendedBolus2: ManualBolusRecommendation? + + let carbEntry1 = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus1 = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry1, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp1.fulfill() + } + wait(for: [exp1], timeout: 100000.0) + + let carbEntry2 = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 4.8), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus2 = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry2, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp2.fulfill() + } + wait(for: [exp2], timeout: 100000.0) + + + XCTAssertEqual(recommendedBolus1!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus1!.correctionAmount!, -0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus1!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertNil(recommendedBolus1!.missingAmount) + + XCTAssertEqual(recommendedBolus2!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus2!.correctionAmount!, -0.48, accuracy: 0.01) + XCTAssertEqual(recommendedBolus2!.carbsAmount!, 0.48, accuracy: 0.01) + XCTAssertNil(recommendedBolus2!.missingAmount) + } func testLoopGetStateRecommendsManualBolusWithMomentum() { setUp(for: .highAndRisingWithCOB) diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 6bb76b5574..12a8131be6 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -128,7 +128,7 @@ class LoopDataManagerTests: XCTestCase { maxBolus: Double = 10, maxBasalRate: Double = 5.0, dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly, - predictGlucose: Bool = false, + predictCarbGlucoseEffects: Bool = false, correctionRanges: GlucoseRangeSchedule? = nil, suspendThresholdValue: Double? = nil ) @@ -170,7 +170,7 @@ class LoopDataManagerTests: XCTestCase { doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile doseStore.sensitivitySchedule = insulinSensitivitySchedule let glucoseStore = MockGlucoseStore(for: test) - let carbStore = MockCarbStore(for: test, predictGlucose: predictGlucose) + let carbStore = MockCarbStore(for: test, predictGlucose: predictCarbGlucoseEffects) carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule carbStore.carbRatioSchedule = carbRatioSchedule From 2e5a709db093c2320eedd8e7122fda54d5f2e63b Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 4 Jan 2024 10:46:09 +0200 Subject: [PATCH 15/43] Don't calc correction when in range --- Loop/Managers/LoopDataManager.swift | 4 +++ .../Managers/LoopDataManagerDosingTests.swift | 25 ++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 313c5ded19..7f95773b4b 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1494,6 +1494,10 @@ extension LoopDataManager { if correctionBreakdownRecommendation != nil { correctionAmount = calcCorrectionAmount(carbsAmount: 0, carbBreakdownRecommendation: carbBreakdownRecommendation!, correctionBreakdownRecommendation: correctionBreakdownRecommendation!) + + if recommendation!.notice == .predictedGlucoseInRange { + correctionAmount = Swift.min(correctionAmount, 0) // ensure 0 if in range but above the mid-point + } } else { correctionAmount = 0 } diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index a7bb24422e..c41fe55db9 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -559,6 +559,23 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertNil(recommendedBolus!.missingAmount) } + func testLoopGetStateRecommendsManualBolusForInRangeAboveMidPoint() { + setUp(for: .flatAndStable, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 80, maxValue: 110))])) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.correctionAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + func testLoopGetStateRecommendsManualBolusForSuspendForCarbEntry() { setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 230, maxValue: 230))]), suspendThresholdValue: 180) @@ -580,15 +597,17 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } func testLoopGetStateRecommendsManualBolusForInRangeCarbEntry() { - setUp(for: .flatAndStable, predictCarbGlucoseEffects: true) - + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 170, maxValue: 210))])) + let exp1 = expectation(description: #function) var recommendedBolus1: ManualBolusRecommendation? let exp2 = expectation(description: #function) var recommendedBolus2: ManualBolusRecommendation? - let carbEntry1 = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + // note that 176.218 + 5/10*45 < 210 + let carbEntry1 = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) loopDataManager.getLoopState { (_, loopState) in recommendedBolus1 = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry1, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) exp1.fulfill() From 816fb6a6e750c072aee1ffb9c9d41a450c41477d Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sat, 23 Mar 2024 16:32:37 +0200 Subject: [PATCH 16/43] initial update for cob and bg correction split --- Common/FeatureFlags.swift | 7 -- Loop/Managers/LoopDataManager.swift | 99 +++++++++++++------ Loop/View Models/BolusEntryViewModel.swift | 41 +++++--- Loop/Views/BolusEntryView.swift | 27 +++-- .../Managers/LoopDataManagerDosingTests.swift | 48 +++++++-- 5 files changed, 156 insertions(+), 66 deletions(-) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index e9a14c7a99..0c6440b564 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -39,7 +39,6 @@ struct FeatureFlagConfiguration: Decodable { let profileExpirationSettingsViewEnabled: Bool let missedMealNotifications: Bool let allowAlgorithmExperiments: Bool - let recommendBolusForCarbsWithoutCorrection: Bool fileprivate init() { @@ -233,12 +232,6 @@ struct FeatureFlagConfiguration: Decodable { #else self.allowAlgorithmExperiments = false #endif - - #if RECOMMEND_BOLUS_FOR_CARBS_WITHOUT_CORRECTION - self.recommendBolusForCarbsWithoutCorrection = true - #else - self.recommendBolusForCarbsWithoutCorrection = false - #endif } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 7f95773b4b..772ce37347 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1474,14 +1474,26 @@ extension LoopDataManager { guard recommendation != nil else { return nil } - - let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) + + let cobPrediction = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + + let totalCobAmount : Double + + if let cobBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: cobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown) { + + totalCobAmount = cobBreakdownRecommendation.amount + } else { + return recommendation // unable to differentiate between correction amounts, this generally shouldn't happen + } + + + let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: cobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) guard carbBreakdownRecommendation != nil else { - if potentialCarbEntry != nil { - return recommendation // unable to differentiate between carbs and correction - } - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: 0.0, correctionAmount: recommendation!.amount, missingAmount: recommendation!.missingAmount) + let carbsAmount = potentialCarbEntry == nil ? 0.0 : nil + let bgAmount = recommendation!.amount - totalCobAmount + + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: carbsAmount, cobCorrectionAmount: totalCobAmount, bgCorrectionAmount: bgAmount, missingAmount: recommendation!.missingAmount) } guard potentialCarbEntry != nil else { @@ -1489,42 +1501,42 @@ extension LoopDataManager { var correctionAmount = recommendation!.amount + extra - if (correctionAmount == 0) { - let correctionBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .correctionBreakdown) - - if correctionBreakdownRecommendation != nil { - correctionAmount = calcCorrectionAmount(carbsAmount: 0, carbBreakdownRecommendation: carbBreakdownRecommendation!, correctionBreakdownRecommendation: correctionBreakdownRecommendation!) + if correctionAmount == 0 { + if let calcAmount = try calcCorrectionAmount(carbsAmount: 0, prediction: prediction, potentialCarbEntry: potentialCarbEntry) { + correctionAmount = calcAmount if recommendation!.notice == .predictedGlucoseInRange { correctionAmount = Swift.min(correctionAmount, 0) // ensure 0 if in range but above the mid-point } - } else { - correctionAmount = 0 } } - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: 0.0, correctionAmount: correctionAmount, missingAmount: recommendation!.missingAmount) + let bgAmount = correctionAmount - totalCobAmount + + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: 0.0, cobCorrectionAmount: totalCobAmount, bgCorrectionAmount: bgAmount, missingAmount: recommendation!.missingAmount) } + let zeroCarbEntry = replacedCarbEntry == nil ? nil : NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: potentialCarbEntry!.startDate, foodType: nil, absorptionTime: potentialCarbEntry!.absorptionTime) - let predictionWithoutCarbs = try predictGlucose(using: .all, potentialBolus: nil, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) + let predictionWithoutCarbs = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: zeroCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) - let carbBreakdownRecommendationWithoutCarbs = try recommendBolusValidatingDataRecency(forPrediction: predictionWithoutCarbs, consideringPotentialCarbEntry: nil, usage: .carbBreakdown) + let carbBreakdownRecommendationWithoutCarbs = try recommendBolusValidatingDataRecency(forPrediction: predictionWithoutCarbs, consideringPotentialCarbEntry: zeroCarbEntry, usage: .carbBreakdown) guard carbBreakdownRecommendationWithoutCarbs != nil else { return recommendation // unable to directly calculate carbsAmount } let carbsAmount = carbBreakdownRecommendation!.amount - carbBreakdownRecommendationWithoutCarbs!.amount + let cobAmount = max(totalCobAmount - carbsAmount, 0) - let correctionBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .correctionBreakdown) var missingAmount = recommendation!.missingAmount let correctionAmount : Double - if recommendation!.amount <= 0 && correctionBreakdownRecommendation != nil && recommendation!.notice != .predictedGlucoseInRange { - correctionAmount = calcCorrectionAmount(carbsAmount: carbsAmount, carbBreakdownRecommendation: carbBreakdownRecommendation!, correctionBreakdownRecommendation: correctionBreakdownRecommendation!) + if recommendation!.amount <= 0, recommendation!.notice != .predictedGlucoseInRange, + let calcAmount = try calcCorrectionAmount(carbsAmount: carbsAmount, prediction: prediction, potentialCarbEntry: potentialCarbEntry) { + correctionAmount = calcAmount let amount = carbsAmount + correctionAmount if volumeRounder()(amount) != 0 { missingAmount = amount @@ -1533,30 +1545,44 @@ extension LoopDataManager { let extra = Swift.max(recommendation!.missingAmount ?? 0, 0) correctionAmount = recommendation!.amount + extra - carbsAmount } - - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: carbsAmount, correctionAmount: correctionAmount, missingAmount: missingAmount) + + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: carbsAmount, cobCorrectionAmount: cobAmount, bgCorrectionAmount: correctionAmount - cobAmount, missingAmount: missingAmount) } fileprivate func calcCorrectionAmount(carbsAmount: Double, - carbBreakdownRecommendation : ManualBolusRecommendation, - correctionBreakdownRecommendation: ManualBolusRecommendation) -> Double { + prediction: [PredictedGlucoseValue], + potentialCarbEntry: NewCarbEntry?) throws -> Double? { + + let recommendationAmountForCarbs = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown)?.amount + + guard recommendationAmountForCarbs != nil else { + return nil + } + + let recommendationAmountForCorrection = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .correctionBreakdown)?.amount + + guard recommendationAmountForCorrection != nil else { + return nil + } + // carbs + correction + y = a // carbs + correction + ratio*y = b // --> y = (b-a)/(ratio - 1) // --> correction = a - y - carbs let ratio = ManualBolusRecommendationUsage.correctionBreakdown.targetsAdjustment / ManualBolusRecommendationUsage.carbBreakdown.targetsAdjustment - let delta = correctionBreakdownRecommendation.amount - carbBreakdownRecommendation.amount + let y = (recommendationAmountForCorrection! - recommendationAmountForCarbs!) / (ratio - 1) - return carbBreakdownRecommendation.amount - delta / (ratio - 1) - carbsAmount + return recommendationAmountForCarbs! - y - carbsAmount } fileprivate enum ManualBolusRecommendationUsage { - case standard, carbBreakdown, correctionBreakdown + case standard, cobBreakdown, carbBreakdown, correctionBreakdown func suspendThresholdOverride(_ suspendThreshold: HKQuantity?) -> HKQuantity? { switch self { case .standard: return suspendThreshold + case .cobBreakdown: return HKQuantity(unit: HKUnit.milligramsPerDeciliter, doubleValue: -1E30) default: return nil } } @@ -1578,14 +1604,19 @@ extension LoopDataManager { var targetsAdjustment : Double { switch self { case .standard: return 0.0 + case .cobBreakdown: return 0.0 case .carbBreakdown: return -1E5 case .correctionBreakdown: return -2E5 } } - func glucoseTargetsOverride(_ schedule: GlucoseRangeSchedule) -> GlucoseRangeSchedule{ + func glucoseTargetsOverride(_ schedule: GlucoseRangeSchedule, _ startingGlucose: HKQuantity) -> GlucoseRangeSchedule{ switch self { case .standard: return schedule + case .cobBreakdown: + let target = startingGlucose.doubleValue(for: .milligramsPerDeciliter) + return GlucoseRangeSchedule(unit: .milligramsPerDeciliter, + dailyItems: [RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: target, maxValue: target))])! default: return adjustSchedule(schedule, amount: self.targetsAdjustment) } } @@ -1659,10 +1690,6 @@ extension LoopDataManager { guard let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { throw LoopError.configurationError(.insulinSensitivitySchedule) } - - let breakdownGlucoseRangeSchedule = GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [ - RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: -1E5, maxValue: -1E5)) - ]) guard let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { throw LoopError.configurationError(.glucoseTargetRangeSchedule) @@ -1681,8 +1708,16 @@ extension LoopDataManager { let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) + var startingGlucose : HKQuantity + + if let latestGlucose = self.glucoseStore.latestGlucose?.quantity { + startingGlucose = latestGlucose + } else { + startingGlucose = predictedGlucose[0].quantity + } + return predictedGlucose.recommendedManualBolus( - to: usage.glucoseTargetsOverride(glucoseTargetRange), + to: usage.glucoseTargetsOverride(glucoseTargetRange, startingGlucose), at: now(), suspendThreshold: usage.suspendThresholdOverride(settings.suspendThreshold?.quantity), sensitivity: insulinSensitivity, diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 411ec78afc..85cac0257d 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -117,9 +117,13 @@ final class BolusEntryViewModel: ObservableObject { var carbBolusAmount: Double? { carbBolus?.doubleValue(for: .internationalUnit()) } - @Published var correctionBolus: HKQuantity? - var correctionBolusAmount: Double? { - correctionBolus?.doubleValue(for: .internationalUnit()) + @Published var cobCorrectionBolus: HKQuantity? + var cobCorrectionBolusAmount: Double? { + cobCorrectionBolus?.doubleValue(for: .internationalUnit()) + } + @Published var bgCorrectionBolus: HKQuantity? + var bgCorrectionBolusAmount: Double? { + bgCorrectionBolus?.doubleValue(for: .internationalUnit()) } @Published var missingBolus: HKQuantity? var missingBolusAmount: Double? { @@ -676,7 +680,8 @@ final class BolusEntryViewModel: ObservableObject { let now = Date() var recommendation: ManualBolusRecommendation? let carbBolus: HKQuantity? - let correctionBolus: HKQuantity? + let cobCorrectionBolus: HKQuantity? + let bgCorrectionBolus: HKQuantity? let recommendedBolus: HKQuantity? let missingBolus: HKQuantity? let notice: Notice? @@ -690,10 +695,16 @@ final class BolusEntryViewModel: ObservableObject { carbBolus = nil } - if let correctionAmount = recommendation.correctionAmount { - correctionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: correctionAmount) + if let cobCorrectionAmount = recommendation.cobCorrectionAmount { + cobCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: cobCorrectionAmount) } else { - correctionBolus = nil + cobCorrectionBolus = nil + } + + if let bgCorrectionAmount = recommendation.bgCorrectionAmount { + bgCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: bgCorrectionAmount) + } else { + bgCorrectionBolus = nil } if let missingAmount = recommendation.missingAmount { @@ -725,14 +736,16 @@ final class BolusEntryViewModel: ObservableObject { } } else { carbBolus = nil - correctionBolus = nil + cobCorrectionBolus = nil + bgCorrectionBolus = nil missingBolus = nil recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) notice = nil } } catch { carbBolus = nil - correctionBolus = nil + cobCorrectionBolus = nil + bgCorrectionBolus = nil missingBolus = nil recommendedBolus = nil @@ -751,7 +764,8 @@ final class BolusEntryViewModel: ObservableObject { DispatchQueue.main.async { let priorRecommendedBolus = self.recommendedBolus self.carbBolus = carbBolus - self.correctionBolus = correctionBolus + self.cobCorrectionBolus = cobCorrectionBolus + self.bgCorrectionBolus = bgCorrectionBolus self.missingBolus = missingBolus self.recommendedBolus = recommendedBolus self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } @@ -850,8 +864,11 @@ final class BolusEntryViewModel: ObservableObject { var carbBolusString: String { return bolusString(carbBolusAmount, forBreakdown: true) } - var correctionBolusString: String { - return bolusString(correctionBolusAmount, forBreakdown: true) + var cobCorrectionBolusString: String { + return bolusString(cobCorrectionBolusAmount, forBreakdown: true) + } + var bgCorrectionBolusString: String { + return bolusString(bgCorrectionBolusAmount, forBreakdown: true) } var negativeMissingBolusString: String { guard missingBolusAmount != nil else { diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 60a4fa9433..fac6434c06 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -229,9 +229,9 @@ struct BolusEntryView: View { private func displayRecommendationBreakdown() -> Bool { if viewModel.potentialCarbEntry != nil { - return viewModel.carbBolus != nil && viewModel.correctionBolus != nil + return viewModel.bgCorrectionBolus != nil && (viewModel.carbBolus != nil || viewModel.cobCorrectionBolus != nil) } else { - return viewModel.correctionBolus != nil + return viewModel.bgCorrectionBolus != nil } } @@ -269,7 +269,7 @@ struct BolusEntryView: View { if viewModel.potentialCarbEntry != nil && viewModel.carbBolus != nil { HStack { Text(" ") - Text("Carb Bolus", comment: "Label for carb bolus row on bolus screen") + Text("Carb Entry Bolus", comment: "Label for carb bolus row on bolus screen") .font(.footnote) Spacer() HStack(alignment: .firstTextBaseline) { @@ -281,14 +281,29 @@ struct BolusEntryView: View { } .accessibilityElement(children: .combine) } - if viewModel.correctionBolus != nil { + if viewModel.cobCorrectionBolus != nil { HStack { Text(" ") - Text("Correction Bolus", comment: "Label for correction bolus row on bolus screen") + Text("COB Correction Bolus", comment: "Label for COB correction bolus row on bolus screen") .font(.footnote) Spacer() HStack(alignment: .firstTextBaseline) { - Text(viewModel.correctionBolusString) + Text(viewModel.cobCorrectionBolusString) + .font(.footnote) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + } + if viewModel.bgCorrectionBolus != nil { + HStack { + Text(" ") + Text("BG Correction Bolus", comment: "Label for BG correction bolus row on bolus screen") + .font(.footnote) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.bgCorrectionBolusString) .font(.footnote) .foregroundColor(Color(.label)) breakdownBolusUnitsLabel diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index c41fe55db9..d6acd58f69 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -486,7 +486,8 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 1.82, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.correctionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -501,7 +502,8 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 1, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.correctionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.missingAmount!, 0.82, accuracy: 0.01) } @@ -519,7 +521,8 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 2.32, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.correctionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -537,7 +540,8 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 1, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.correctionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.missingAmount!, 1.32, accuracy: 0.01) } @@ -554,7 +558,8 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.correctionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -571,7 +576,8 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.correctionAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -591,11 +597,33 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.correctionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.missingAmount!, 0.5 + (176.21882841682697 - 230) / 45, accuracy: 0.01) } + func testLoopGetStateRecommendsManualBolusForSuspendNoCarbEntry() { + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 230, maxValue: 230))]), suspendThresholdValue: 180) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.0, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + func testLoopGetStateRecommendsManualBolusForInRangeCarbEntry() { setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 170, maxValue: 210))])) @@ -623,12 +651,14 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(recommendedBolus1!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus1!.correctionAmount!, -0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus1!.bgCorrectionAmount!, -0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus1!.cobCorrectionAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus1!.carbsAmount!, 0.5, accuracy: 0.01) XCTAssertNil(recommendedBolus1!.missingAmount) XCTAssertEqual(recommendedBolus2!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus2!.correctionAmount!, -0.48, accuracy: 0.01) + XCTAssertEqual(recommendedBolus2!.bgCorrectionAmount!, -0.48, accuracy: 0.01) + XCTAssertEqual(recommendedBolus2!.cobCorrectionAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus2!.carbsAmount!, 0.48, accuracy: 0.01) XCTAssertNil(recommendedBolus2!.missingAmount) } From 01b89173dc9b75c648f653c6c1690a6cfbcf55bf Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sun, 24 Mar 2024 15:00:03 +0200 Subject: [PATCH 17/43] refine COB calculation --- Loop/Managers/LoopDataManager.swift | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 772ce37347..0f7ce6aed0 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1475,7 +1475,13 @@ extension LoopDataManager { return nil } - let cobPrediction = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + let totalCobPrediction = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + + guard !totalCobPrediction.isEmpty else { + return recommendation // unable to differentiate between correction amounts, + } + + let cobPrediction = totalCobPrediction.map{PredictedGlucoseValue(startDate: $0.startDate, quantity: totalCobPrediction.last!.quantity)} let totalCobAmount : Double @@ -1483,11 +1489,11 @@ extension LoopDataManager { totalCobAmount = cobBreakdownRecommendation.amount } else { - return recommendation // unable to differentiate between correction amounts, this generally shouldn't happen + return recommendation // unable to differentiate between correction amounts } - let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: cobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) + let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: totalCobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) guard carbBreakdownRecommendation != nil else { let carbsAmount = potentialCarbEntry == nil ? 0.0 : nil @@ -1526,6 +1532,7 @@ extension LoopDataManager { return recommendation // unable to directly calculate carbsAmount } + // carbs + cob = totalCob; in particular if user saves without bolusing and then requests a new recommendation it will be totalCob let carbsAmount = carbBreakdownRecommendation!.amount - carbBreakdownRecommendationWithoutCarbs!.amount let cobAmount = max(totalCobAmount - carbsAmount, 0) @@ -1582,7 +1589,6 @@ extension LoopDataManager { func suspendThresholdOverride(_ suspendThreshold: HKQuantity?) -> HKQuantity? { switch self { case .standard: return suspendThreshold - case .cobBreakdown: return HKQuantity(unit: HKUnit.milligramsPerDeciliter, doubleValue: -1E30) default: return nil } } @@ -1697,6 +1703,10 @@ extension LoopDataManager { guard let maxBolus = settings.maximumBolus else { throw LoopError.configurationError(.maximumBolus) } + + guard let startingGlucose = self.glucoseStore.latestGlucose?.quantity else { + throw LoopError.missingDataError(.glucose) + } guard lastRequestedBolus == nil else { @@ -1707,15 +1717,7 @@ extension LoopDataManager { } let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) - - var startingGlucose : HKQuantity - - if let latestGlucose = self.glucoseStore.latestGlucose?.quantity { - startingGlucose = latestGlucose - } else { - startingGlucose = predictedGlucose[0].quantity - } - + return predictedGlucose.recommendedManualBolus( to: usage.glucoseTargetsOverride(glucoseTargetRange, startingGlucose), at: now(), From 2145564dcb419a84addcc537d7da42984c86a75b Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Mon, 25 Mar 2024 20:08:17 +0200 Subject: [PATCH 18/43] initial unit testing --- Loop/Managers/LoopDataManager.swift | 7 +- .../Managers/LoopDataManagerDosingTests.swift | 76 +++++++++++++++++++ LoopTests/Managers/LoopDataManagerTests.swift | 13 +++- LoopTests/Mock Stores/MockCarbStore.swift | 4 +- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 0f7ce6aed0..e79eb0efcd 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1483,16 +1483,11 @@ extension LoopDataManager { let cobPrediction = totalCobPrediction.map{PredictedGlucoseValue(startDate: $0.startDate, quantity: totalCobPrediction.last!.quantity)} - let totalCobAmount : Double - - if let cobBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: cobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown) { + guard let totalCobAmount = try recommendBolusValidatingDataRecency(forPrediction: cobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown)?.amount else { - totalCobAmount = cobBreakdownRecommendation.amount - } else { return recommendation // unable to differentiate between correction amounts } - let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: totalCobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) guard carbBreakdownRecommendation != nil else { diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index d6acd58f69..cd5fb99ff9 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -508,6 +508,82 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(recommendedBolus!.missingAmount!, 0.82, accuracy: 0.01) } + func dummyReplacementEntry() -> StoredCarbEntry{ + StoredCarbEntry(startDate: now, quantity: HKQuantity(unit: .gram(), doubleValue: -1)) + } + + func dummyCarbEntry() -> NewCarbEntry { + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: now.addingTimeInterval(TimeInterval(days: -2)), + foodType: nil, absorptionTime: TimeInterval(hours: 3)) + } + + func testLoopGetStateRecommendsManualBolusForCob() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedCobCorrectionAmount = 0.5 + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue))]}) + + let exp = expectation(description: #function) + + var recommendedBolus: ManualBolusRecommendation? + + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: self.dummyCarbEntry(), replacingCarbEntry: self.dummyReplacementEntry(), considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, expectedBgCorrectionAmount + expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, expectedBgCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForCobAndReducingCarbEntry() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedCobCorrectionAmount = 0.6 + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + let expectedCarbsAmount = 0.5 + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[ + StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue)), + StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: 10)) + ]}) + + let exp = expectation(description: #function) + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: expectedCarbsAmount * cir), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1)) + + var recommendedBolus: ManualBolusRecommendation? + + loopDataManager.getLoopState { (_, loopState) in + + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: StoredCarbEntry(startDate: self.now, quantity: HKQuantity(unit: .gram(), doubleValue: 10)), considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, expectedCarbsAmount + expectedBgCorrectionAmount + expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, expectedBgCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.carbsAmount!, expectedCarbsAmount, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + func testLoopGetStateRecommendsManualBolusForCarbEntry() { setUp(for: .highAndStable, predictCarbGlucoseEffects: true) let exp = expectation(description: #function) diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 12a8131be6..fbff92e1c3 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -130,7 +130,10 @@ class LoopDataManagerTests: XCTestCase { dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly, predictCarbGlucoseEffects: Bool = false, correctionRanges: GlucoseRangeSchedule? = nil, - suspendThresholdValue: Double? = nil + suspendThresholdValue: Double? = nil, + // note that carbHistory is independent from carb effects; + // one can use dummy replacement carb entry to force recalculation when getting a manual bolus recommendation + carbHistorySupplier: ((Date) -> [StoredCarbEntry]?)? = nil ) { let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") @@ -170,13 +173,15 @@ class LoopDataManagerTests: XCTestCase { doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile doseStore.sensitivitySchedule = insulinSensitivitySchedule let glucoseStore = MockGlucoseStore(for: test) - let carbStore = MockCarbStore(for: test, predictGlucose: predictCarbGlucoseEffects) - carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule - carbStore.carbRatioSchedule = carbRatioSchedule let currentDate = glucoseStore.latestGlucose!.startDate now = currentDate + let carbStore = MockCarbStore(for: test, predictGlucose: predictCarbGlucoseEffects, carbHistory: carbHistorySupplier?(now)) + carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule + carbStore.carbRatioSchedule = carbRatioSchedule + + dosingDecisionStore = MockDosingDecisionStore() automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) loopDataManager = LoopDataManager( diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 59baaa81ec..15d27b4d7e 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -14,10 +14,10 @@ class MockCarbStore: CarbStoreProtocol { var predictGlucose: Bool var carbHistory: [StoredCarbEntry]? - init(for scenario: DosingTestScenario = .flatAndStable, predictGlucose: Bool = false) { + init(for scenario: DosingTestScenario = .flatAndStable, predictGlucose: Bool = false, carbHistory: [StoredCarbEntry]? = nil) { self.scenario = scenario // The store returns different effect values based on the scenario self.predictGlucose = predictGlucose - self.carbHistory = loadHistoricCarbEntries(scenario: scenario) + self.carbHistory = carbHistory ?? loadHistoricCarbEntries(scenario: scenario) } var scenario: DosingTestScenario From b2d568c3c8b1ef6148f928573153ec5c27b98838 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Wed, 27 Mar 2024 21:48:07 +0200 Subject: [PATCH 19/43] Updates to tests, add controls to UI --- Loop/Managers/LoopDataManager.swift | 13 +-- Loop/View Models/BolusEntryViewModel.swift | 65 ++++++++++- Loop/Views/BolusEntryView.swift | 81 +++++++++---- .../Managers/LoopDataManagerDosingTests.swift | 110 ++++++++++++------ 4 files changed, 195 insertions(+), 74 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index e79eb0efcd..7b89a10fa5 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1492,9 +1492,8 @@ extension LoopDataManager { guard carbBreakdownRecommendation != nil else { let carbsAmount = potentialCarbEntry == nil ? 0.0 : nil - let bgAmount = recommendation!.amount - totalCobAmount - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: carbsAmount, cobCorrectionAmount: totalCobAmount, bgCorrectionAmount: bgAmount, missingAmount: recommendation!.missingAmount) + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, bolusBreakdown: BolusBreakdown(fullCarbsAmount: carbsAmount, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: recommendation!.amount)) } guard potentialCarbEntry != nil else { @@ -1512,11 +1511,10 @@ extension LoopDataManager { } } - let bgAmount = correctionAmount - totalCobAmount - - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: 0.0, cobCorrectionAmount: totalCobAmount, bgCorrectionAmount: bgAmount, missingAmount: recommendation!.missingAmount) + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, missingAmount: recommendation!.missingAmount, bolusBreakdown: BolusBreakdown(fullCarbsAmount: 0.0, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: correctionAmount)) } + // the insulin needed to cover the zeroCarbEntry will underflow to 0 once added/subtracted let zeroCarbEntry = replacedCarbEntry == nil ? nil : NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: potentialCarbEntry!.startDate, foodType: nil, absorptionTime: potentialCarbEntry!.absorptionTime) let predictionWithoutCarbs = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: zeroCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) @@ -1527,10 +1525,7 @@ extension LoopDataManager { return recommendation // unable to directly calculate carbsAmount } - // carbs + cob = totalCob; in particular if user saves without bolusing and then requests a new recommendation it will be totalCob let carbsAmount = carbBreakdownRecommendation!.amount - carbBreakdownRecommendationWithoutCarbs!.amount - let cobAmount = max(totalCobAmount - carbsAmount, 0) - var missingAmount = recommendation!.missingAmount let correctionAmount : Double @@ -1548,7 +1543,7 @@ extension LoopDataManager { correctionAmount = recommendation!.amount + extra - carbsAmount } - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, carbsAmount: carbsAmount, cobCorrectionAmount: cobAmount, bgCorrectionAmount: correctionAmount - cobAmount, missingAmount: missingAmount) + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, missingAmount: missingAmount, bolusBreakdown: BolusBreakdown(fullCarbsAmount: carbsAmount, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: correctionAmount)) } fileprivate func calcCorrectionAmount(carbsAmount: Double, diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 85cac0257d..2d6937ec3e 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -114,18 +114,23 @@ final class BolusEntryViewModel: ObservableObject { let selectedCarbAbsorptionTimeEmoji: String? @Published var carbBolus: HKQuantity? + @Published var carbBolusIncluded = true var carbBolusAmount: Double? { carbBolus?.doubleValue(for: .internationalUnit()) } @Published var cobCorrectionBolus: HKQuantity? + @Published var cobCorrectionBolusIncluded = true var cobCorrectionBolusAmount: Double? { cobCorrectionBolus?.doubleValue(for: .internationalUnit()) } @Published var bgCorrectionBolus: HKQuantity? + @Published var bgCorrectionBolusIncluded = true var bgCorrectionBolusAmount: Double? { bgCorrectionBolus?.doubleValue(for: .internationalUnit()) } @Published var missingBolus: HKQuantity? + @Published var missingBolusIncluded = true + @Published var missingBolusIsMaxBolus = false var missingBolusAmount: Double? { missingBolus?.doubleValue(for: .internationalUnit()) } @@ -222,7 +227,7 @@ final class BolusEntryViewModel: ObservableObject { self.observeElapsedTime() self.observeEnteredManualGlucoseChanges() self.observeEnteredBolusChanges() - + self.observeBolusBreakdownChanges() } private func observeLoopUpdates() { @@ -255,6 +260,37 @@ final class BolusEntryViewModel: ObservableObject { } .store(in: &cancellables) } + + private func observeBolusBreakdownChanges() { + $carbBolusIncluded + .sink { [weak self] _ in + self?.delegate?.withLoopState { [weak self] state in + self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + } + } + .store(in: &cancellables) + $cobCorrectionBolusIncluded + .sink { [weak self] _ in + self?.delegate?.withLoopState { [weak self] state in + self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + } + } + .store(in: &cancellables) + $bgCorrectionBolusIncluded + .sink { [weak self] _ in + self?.delegate?.withLoopState { [weak self] state in + self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + } + } + .store(in: &cancellables) + $missingBolusIncluded + .sink { [weak self] _ in + self?.delegate?.withLoopState { [weak self] state in + self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + } + } + .store(in: &cancellables) + } private func observeEnteredManualGlucoseChanges() { $manualGlucoseQuantity @@ -668,6 +704,10 @@ final class BolusEntryViewModel: ObservableObject { } } } + + private func resolveValue(_ useValue: Bool, _ value: Double) -> Double { + useValue ? round(1000*value) / 1000 : 0 + } private func updateRecommendedBolusAndNotice(from state: LoopState, isUpdatingFromUserInput: Bool) { dispatchPrecondition(condition: .notOnQueue(.main)) @@ -684,25 +724,31 @@ final class BolusEntryViewModel: ObservableObject { let bgCorrectionBolus: HKQuantity? let recommendedBolus: HKQuantity? let missingBolus: HKQuantity? + var missingBolusIsMaxBolus = false let notice: Notice? do { recommendation = try computeBolusRecommendation(from: state) if let recommendation = recommendation { - if let carbsAmount = recommendation.carbsAmount { + var totalRecommendation = 0.0 + + if let carbsAmount = recommendation.bolusBreakdown?.carbsAmount { carbBolus = HKQuantity(unit: .internationalUnit(), doubleValue: carbsAmount) + totalRecommendation += resolveValue(carbBolusIncluded, carbsAmount) } else { carbBolus = nil } - if let cobCorrectionAmount = recommendation.cobCorrectionAmount { + if let cobCorrectionAmount = recommendation.bolusBreakdown?.cobCorrectionAmount { cobCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: cobCorrectionAmount) + totalRecommendation += resolveValue(cobCorrectionBolusIncluded, cobCorrectionAmount) } else { cobCorrectionBolus = nil } - if let bgCorrectionAmount = recommendation.bgCorrectionAmount { + if let bgCorrectionAmount = recommendation.bolusBreakdown?.bgCorrectionAmount { bgCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: bgCorrectionAmount) + totalRecommendation += resolveValue(bgCorrectionBolusIncluded, bgCorrectionAmount) } else { bgCorrectionBolus = nil } @@ -710,14 +756,20 @@ final class BolusEntryViewModel: ObservableObject { if let missingAmount = recommendation.missingAmount { if missingAmount != 0 { missingBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) + missingBolusIsMaxBolus = recommendation.amount != 0 + totalRecommendation += resolveValue(missingBolusIncluded, missingAmount) } else { missingBolus = nil } } else { missingBolus = nil } - - recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: recommendation.amount)) + + if carbBolusIncluded, cobCorrectionBolusIncluded, bgCorrectionBolusIncluded, missingBolusIncluded { + totalRecommendation = recommendation.amount // avoid possible rounding issues + } + + recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: max(0, totalRecommendation))) //recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) switch recommendation.notice { @@ -767,6 +819,7 @@ final class BolusEntryViewModel: ObservableObject { self.cobCorrectionBolus = cobCorrectionBolus self.bgCorrectionBolus = bgCorrectionBolus self.missingBolus = missingBolus + self.missingBolusIsMaxBolus = missingBolusIsMaxBolus self.recommendedBolus = recommendedBolus self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } self.activeNotice = notice diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index fac6434c06..11e1685441 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -237,16 +237,17 @@ struct BolusEntryView: View { @State private var recommendationBreakdownExpanded = false - + @ViewBuilder private var recommendedBolusRow: some View { - + let breakdownFont = Font.subheadline Section { HStack(alignment: .firstTextBaseline) { Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") if displayRecommendationBreakdown() { Image(systemName: "chevron.forward.circle") .imageScale(.small) + .foregroundColor(.accentColor) .rotationEffect(.degrees(recommendationBreakdownExpanded ? 90 : 0)) } Spacer() @@ -266,70 +267,104 @@ struct BolusEntryView: View { } if recommendationBreakdownExpanded { VStack { - if viewModel.potentialCarbEntry != nil && viewModel.carbBolus != nil { + if viewModel.potentialCarbEntry != nil, viewModel.carbBolus != nil { HStack { - Text(" ") - Text("Carb Entry Bolus", comment: "Label for carb bolus row on bolus screen") - .font(.footnote) + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.carbBolusIncluded ? 1 : 0) + Text("Carb Entry", comment: "Label for carb bolus row on bolus screen") + .font(breakdownFont) Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.carbBolusString) - .font(.footnote) + .font(.subheadline) .foregroundColor(Color(.label)) breakdownBolusUnitsLabel } } .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.carbBolusIncluded.toggle() + } } if viewModel.cobCorrectionBolus != nil { HStack { - Text(" ") - Text("COB Correction Bolus", comment: "Label for COB correction bolus row on bolus screen") - .font(.footnote) + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.cobCorrectionBolusIncluded ? 1 : 0) + Text("COB Correction", comment: "Label for COB correction bolus row on bolus screen") + .font(breakdownFont) Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.cobCorrectionBolusString) - .font(.footnote) + .font(breakdownFont) .foregroundColor(Color(.label)) breakdownBolusUnitsLabel } } .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.cobCorrectionBolusIncluded.toggle() + } + } if viewModel.bgCorrectionBolus != nil { HStack { - Text(" ") - Text("BG Correction Bolus", comment: "Label for BG correction bolus row on bolus screen") - .font(.footnote) + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.bgCorrectionBolusIncluded ? 1 : 0) + Text("BG Correction", comment: "Label for BG correction bolus row on bolus screen") + .font(breakdownFont) Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.bgCorrectionBolusString) - .font(.footnote) + .font(breakdownFont) .foregroundColor(Color(.label)) breakdownBolusUnitsLabel } } .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.bgCorrectionBolusIncluded.toggle() + } } - if viewModel.missingBolus != nil && viewModel.recommendedBolusAmount != nil { + if viewModel.missingBolus != nil { HStack { - Text(" ") - if viewModel.recommendedBolusAmount! != 0 { - Text("Limit to Max Bolus", comment: "Label for max bolus row on bolus screen") - .font(.footnote) + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.missingBolusIncluded ? 1 : 0) + if viewModel.missingBolusIsMaxBolus { + Text("Max Bolus Limit", comment: "Label for max bolus row on bolus screen") + .font(breakdownFont) } else { - Text("Limit to 0 Bolus", comment: "Label for 0 bolus row on bolus screen") - .font(.footnote) + Text("Glucose Safety Limit", comment: "Label for glucose safety limit row on bolus screen") + .font(breakdownFont) } Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.negativeMissingBolusString) - .font(.footnote) + .font(breakdownFont) .foregroundColor(Color(.label)) breakdownBolusUnitsLabel } } .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.missingBolusIncluded.toggle() + } + } } .accessibilityElement(children: .combine) diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index cd5fb99ff9..b62f718696 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -486,9 +486,9 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 1.82, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, 1.82, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -502,9 +502,9 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 1, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, 1.82, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.missingAmount!, 0.82, accuracy: 0.01) } @@ -541,9 +541,9 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, expectedBgCorrectionAmount + expectedCobCorrectionAmount, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, expectedBgCorrectionAmount, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, expectedCobCorrectionAmount, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, expectedBgCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -578,9 +578,47 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, expectedCarbsAmount + expectedBgCorrectionAmount + expectedCobCorrectionAmount, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, expectedBgCorrectionAmount, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, expectedCobCorrectionAmount, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.carbsAmount!, expectedCarbsAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, expectedBgCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, expectedCarbsAmount, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForZeroCorrectionCobAndCarbEntry() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedCobCorrectionAmount = 0.0 + let expectedCarbsAmount = 0.5 + let expectedBgOffset = -0.2 + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf + expectedBgOffset + + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedBgOffset) + + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[ + StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue)) + ]}) + + let exp = expectation(description: #function) + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: expectedCarbsAmount * cir), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1)) + + var recommendedBolus: ManualBolusRecommendation? + + loopDataManager.getLoopState { (_, loopState) in + + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: self.dummyReplacementEntry(), considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, expectedCarbsAmount + expectedBgCorrectionAmount + expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, expectedBgCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, expectedCarbsAmount, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -597,9 +635,9 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 2.32, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, 1.82, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0.5, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -616,9 +654,9 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 1, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, 1.82, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0.5, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.missingAmount!, 1.32, accuracy: 0.01) } @@ -634,9 +672,9 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -652,9 +690,9 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -673,9 +711,9 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0.5, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.missingAmount!, 0.5 + (176.21882841682697 - 230) / 45, accuracy: 0.01) } @@ -694,9 +732,9 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bgCorrectionAmount!, (176.21882841682697 - 230) / 45, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.cobCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.carbsAmount!, 0.0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0.0, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } @@ -727,15 +765,15 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(recommendedBolus1!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus1!.bgCorrectionAmount!, -0.5, accuracy: 0.01) - XCTAssertEqual(recommendedBolus1!.cobCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus1!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus1!.bolusBreakdown!.bgCorrectionAmount, -0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus1!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus1!.bolusBreakdown!.carbsAmount!, 0.5, accuracy: 0.01) XCTAssertNil(recommendedBolus1!.missingAmount) XCTAssertEqual(recommendedBolus2!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus2!.bgCorrectionAmount!, -0.48, accuracy: 0.01) - XCTAssertEqual(recommendedBolus2!.cobCorrectionAmount!, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus2!.carbsAmount!, 0.48, accuracy: 0.01) + XCTAssertEqual(recommendedBolus2!.bolusBreakdown!.bgCorrectionAmount, -0.48, accuracy: 0.01) + XCTAssertEqual(recommendedBolus2!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus2!.bolusBreakdown!.carbsAmount!, 0.48, accuracy: 0.01) XCTAssertNil(recommendedBolus2!.missingAmount) } From 8d88f77a48c4bbaef56b21511f97430dcb85aa9b Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 28 Mar 2024 10:16:38 +0200 Subject: [PATCH 20/43] Support both maxBolusExcess and safetyLimit --- Loop/View Models/BolusEntryViewModel.swift | 87 +++++++++++++++------- Loop/Views/BolusEntryView.swift | 37 ++++++--- 2 files changed, 89 insertions(+), 35 deletions(-) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 2d6937ec3e..7f471e13d0 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -128,11 +128,15 @@ final class BolusEntryViewModel: ObservableObject { var bgCorrectionBolusAmount: Double? { bgCorrectionBolus?.doubleValue(for: .internationalUnit()) } - @Published var missingBolus: HKQuantity? - @Published var missingBolusIncluded = true - @Published var missingBolusIsMaxBolus = false - var missingBolusAmount: Double? { - missingBolus?.doubleValue(for: .internationalUnit()) + @Published var maxExcessBolus: HKQuantity? + @Published var maxExcessBolusIncluded = true + var maxExcessBolusAmount: Double? { + maxExcessBolus?.doubleValue(for: .internationalUnit()) + } + @Published var safetyLimitBolus: HKQuantity? + @Published var safetyLimitBolusIncluded = true + var safetyLimitBolusAmount: Double? { + safetyLimitBolus?.doubleValue(for: .internationalUnit()) } @Published var recommendedBolus: HKQuantity? var recommendedBolusAmount: Double? { @@ -283,13 +287,21 @@ final class BolusEntryViewModel: ObservableObject { } } .store(in: &cancellables) - $missingBolusIncluded + $maxExcessBolusIncluded + .sink { [weak self] _ in + self?.delegate?.withLoopState { [weak self] state in + self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + } + } + .store(in: &cancellables) + $safetyLimitBolusIncluded .sink { [weak self] _ in self?.delegate?.withLoopState { [weak self] state in self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) } } .store(in: &cancellables) + } private func observeEnteredManualGlucoseChanges() { @@ -499,7 +511,7 @@ final class BolusEntryViewModel: ObservableObject { private lazy var breakdownBolusAmountFormatter: NumberFormatter = { let formatter = QuantityFormatter(for: .internationalUnit()) - formatter.numberFormatter.roundingMode = .halfUp + formatter.numberFormatter.roundingMode = .floor // round towards 0 formatter.numberFormatter.maximumFractionDigits = 2 return formatter.numberFormatter }() @@ -723,8 +735,8 @@ final class BolusEntryViewModel: ObservableObject { let cobCorrectionBolus: HKQuantity? let bgCorrectionBolus: HKQuantity? let recommendedBolus: HKQuantity? - let missingBolus: HKQuantity? - var missingBolusIsMaxBolus = false + var maxExcessBolus: HKQuantity? = nil + var safetyLimitBolus: HKQuantity? = nil let notice: Notice? do { recommendation = try computeBolusRecommendation(from: state) @@ -754,18 +766,33 @@ final class BolusEntryViewModel: ObservableObject { } if let missingAmount = recommendation.missingAmount { - if missingAmount != 0 { - missingBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) - missingBolusIsMaxBolus = recommendation.amount != 0 - totalRecommendation += resolveValue(missingBolusIncluded, missingAmount) - } else { - missingBolus = nil + var amount = missingAmount + + if let maxBolus = maximumBolus?.doubleValue(for: .internationalUnit()) { + if missingAmount > maxBolus { + safetyLimitBolus = maximumBolus! + maxExcessBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount - maxBolus) + } + } + + if safetyLimitBolus == nil { + if recommendation.amount != 0 { + maxExcessBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) + } else { + safetyLimitBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) + } + } + + if let maxExcessAmount = maxExcessBolus?.doubleValue(for: .internationalUnit()) { + totalRecommendation += resolveValue(maxExcessBolusIncluded, -maxExcessAmount) + } + + if let safetyLimitAmount = safetyLimitBolus?.doubleValue(for: .internationalUnit()) { + totalRecommendation += resolveValue(safetyLimitBolusIncluded, -safetyLimitAmount) } - } else { - missingBolus = nil } - if carbBolusIncluded, cobCorrectionBolusIncluded, bgCorrectionBolusIncluded, missingBolusIncluded { + if carbBolusIncluded, cobCorrectionBolusIncluded, bgCorrectionBolusIncluded, maxExcessBolusIncluded, safetyLimitBolusIncluded { totalRecommendation = recommendation.amount // avoid possible rounding issues } @@ -790,7 +817,8 @@ final class BolusEntryViewModel: ObservableObject { carbBolus = nil cobCorrectionBolus = nil bgCorrectionBolus = nil - missingBolus = nil + maxExcessBolus = nil + safetyLimitBolus = nil recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) notice = nil } @@ -798,7 +826,8 @@ final class BolusEntryViewModel: ObservableObject { carbBolus = nil cobCorrectionBolus = nil bgCorrectionBolus = nil - missingBolus = nil + maxExcessBolus = nil + safetyLimitBolus = nil recommendedBolus = nil switch error { @@ -818,8 +847,8 @@ final class BolusEntryViewModel: ObservableObject { self.carbBolus = carbBolus self.cobCorrectionBolus = cobCorrectionBolus self.bgCorrectionBolus = bgCorrectionBolus - self.missingBolus = missingBolus - self.missingBolusIsMaxBolus = missingBolusIsMaxBolus + self.maxExcessBolus = maxExcessBolus + self.safetyLimitBolus = safetyLimitBolus self.recommendedBolus = recommendedBolus self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } self.activeNotice = notice @@ -923,12 +952,20 @@ final class BolusEntryViewModel: ObservableObject { var bgCorrectionBolusString: String { return bolusString(bgCorrectionBolusAmount, forBreakdown: true) } - var negativeMissingBolusString: String { - guard missingBolusAmount != nil else { + var negativeMaxExcessBolusString: String { + negativeBolusString(amount: maxExcessBolusAmount) + } + var negativeSafetyLimitString: String { + negativeBolusString(amount: safetyLimitBolusAmount) + } + + func negativeBolusString(amount: Double?) -> String { + guard amount != nil else { return bolusString(nil, forBreakdown: true) } - return bolusString(-missingBolusAmount!, forBreakdown: true) + return bolusString(-amount!, forBreakdown: true) } + var recommendedBolusString: String { return bolusString(recommendedBolusAmount, forBreakdown: false) } diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 11e1685441..21e2e2b82e 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -337,23 +337,41 @@ struct BolusEntryView: View { viewModel.bgCorrectionBolusIncluded.toggle() } } - if viewModel.missingBolus != nil { + if viewModel.maxExcessBolus != nil { HStack { Text(" ") Image(systemName: "checkmark") .imageScale(.small) .foregroundColor(.accentColor) - .opacity(viewModel.missingBolusIncluded ? 1 : 0) - if viewModel.missingBolusIsMaxBolus { - Text("Max Bolus Limit", comment: "Label for max bolus row on bolus screen") - .font(breakdownFont) - } else { - Text("Glucose Safety Limit", comment: "Label for glucose safety limit row on bolus screen") + .opacity(viewModel.maxExcessBolusIncluded ? 1 : 0) + Text("Max Bolus Limit", comment: "Label for max bolus row on bolus screen") + .font(breakdownFont) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.negativeMaxExcessBolusString) .font(breakdownFont) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.maxExcessBolusIncluded.toggle() + } + } + if viewModel.safetyLimitBolus != nil { + HStack { + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.safetyLimitBolusIncluded ? 1 : 0) + Text("Glucose Safety Limit", comment: "Label for glucose safety limit row on bolus screen") + .font(breakdownFont) Spacer() HStack(alignment: .firstTextBaseline) { - Text(viewModel.negativeMissingBolusString) + Text(viewModel.negativeSafetyLimitString) .font(breakdownFont) .foregroundColor(Color(.label)) breakdownBolusUnitsLabel @@ -362,9 +380,8 @@ struct BolusEntryView: View { .accessibilityElement(children: .combine) .contentShape(Rectangle()) .onTapGesture { - viewModel.missingBolusIncluded.toggle() + viewModel.safetyLimitBolusIncluded.toggle() } - } } .accessibilityElement(children: .combine) From 9ba70fef3d78d6f58ebd57a79c70f8b70e179613 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Fri, 29 Mar 2024 14:57:28 +0300 Subject: [PATCH 21/43] Fix rounding mode --- Loop/View Models/BolusEntryViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 7f471e13d0..bd8e89edf1 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -511,7 +511,7 @@ final class BolusEntryViewModel: ObservableObject { private lazy var breakdownBolusAmountFormatter: NumberFormatter = { let formatter = QuantityFormatter(for: .internationalUnit()) - formatter.numberFormatter.roundingMode = .floor // round towards 0 + formatter.numberFormatter.roundingMode = .down // round towards 0 formatter.numberFormatter.maximumFractionDigits = 2 return formatter.numberFormatter }() From 1aaee2db4953dbcdbe71a15cad967dbb190dc238 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 13 Jul 2024 14:43:49 -0500 Subject: [PATCH 22/43] Bump version to 3.5.0 to signify dev branch --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index 373efdca05..a7c7fe29d1 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -7,7 +7,7 @@ // // Version [DEFAULT] -LOOP_MARKETING_VERSION = 3.3.0 +LOOP_MARKETING_VERSION = 3.5.0 CURRENT_PROJECT_VERSION = 57 // Optional override From 7522d5482ec2274385ffe779418efb083ed40a2b Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Fri, 23 Aug 2024 09:08:13 +0300 Subject: [PATCH 23/43] Fixes --- Common/FeatureFlags.swift | 8 ++- Loop/Managers/LoopDataManager.swift | 55 ++++++++------ Loop/View Models/BolusEntryViewModel.swift | 42 ++++++++--- Loop/Views/BolusEntryView.swift | 2 + .../Managers/LoopDataManagerDosingTests.swift | 71 ++++++++++++------- .../ViewModels/BolusEntryViewModelTests.swift | 18 ++++- 6 files changed, 136 insertions(+), 60 deletions(-) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 0c6440b564..bf64a34afb 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -39,7 +39,7 @@ struct FeatureFlagConfiguration: Decodable { let profileExpirationSettingsViewEnabled: Bool let missedMealNotifications: Bool let allowAlgorithmExperiments: Bool - + let correctionWithCarbBolus: Bool fileprivate init() { // Swift compiler config is inverse, since the default state is enabled. @@ -232,6 +232,12 @@ struct FeatureFlagConfiguration: Decodable { #else self.allowAlgorithmExperiments = false #endif + + #if DISABLE_CORRECTION_WITH_CARB_BOLUS + self.correctionWithCarbBolus = false + #else + self.correctionWithCarbBolus = true + #endif } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 7b89a10fa5..dcef92f8d9 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1474,58 +1474,67 @@ extension LoopDataManager { guard recommendation != nil else { return nil } - - let totalCobPrediction = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) - - guard !totalCobPrediction.isEmpty else { + + guard !prediction.isEmpty else { return recommendation // unable to differentiate between correction amounts, } + + guard let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) else { + + return recommendation // unable to differentiate between correction amounts + } + + let noCarbsPrediction = try predictGlucose(using: .all.subtracting([.carbs]), potentialBolus: nil, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - let cobPrediction = totalCobPrediction.map{PredictedGlucoseValue(startDate: $0.startDate, quantity: totalCobPrediction.last!.quantity)} - - guard let totalCobAmount = try recommendBolusValidatingDataRecency(forPrediction: cobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown)?.amount else { + + guard let noCarbsBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: noCarbsPrediction, consideringPotentialCarbEntry: nil, usage: .carbBreakdown) else { return recommendation // unable to differentiate between correction amounts } - let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: totalCobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) + let totalCarbBolus = carbBreakdownRecommendation.amount - noCarbsBreakdownRecommendation.amount - guard carbBreakdownRecommendation != nil else { - let carbsAmount = potentialCarbEntry == nil ? 0.0 : nil - - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, bolusBreakdown: BolusBreakdown(fullCarbsAmount: carbsAmount, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: recommendation!.amount)) + let flatPrediction = prediction.map{PredictedGlucoseValue(startDate: $0.startDate, quantity: prediction.last!.quantity)} + + guard let maxCobAmount = try recommendBolusValidatingDataRecency(forPrediction: flatPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown)?.amount else { + + return recommendation // unable to differentiate between correction amounts } + // totalCobAmount is the additional insulin needed for correcting carbs + let totalCobAmount = Swift.min(maxCobAmount, totalCarbBolus) + guard potentialCarbEntry != nil else { - let extra = Swift.max(recommendation!.missingAmount ?? 0, 0) + var missingAmount = recommendation!.missingAmount - var correctionAmount = recommendation!.amount + extra + var correctionAmount = recommendation!.amount + Swift.max(missingAmount ?? 0, 0) if correctionAmount == 0 { - if let calcAmount = try calcCorrectionAmount(carbsAmount: 0, prediction: prediction, potentialCarbEntry: potentialCarbEntry) { + if let calcAmount = try calcCorrectionAmount(carbsAmount: 0, prediction: prediction, potentialCarbEntry: nil) { correctionAmount = calcAmount if recommendation!.notice == .predictedGlucoseInRange { correctionAmount = Swift.min(correctionAmount, 0) // ensure 0 if in range but above the mid-point + } else if correctionAmount > 0, volumeRounder()(correctionAmount) != 0 { + missingAmount = correctionAmount } } } - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, missingAmount: recommendation!.missingAmount, bolusBreakdown: BolusBreakdown(fullCarbsAmount: 0.0, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: correctionAmount)) + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, missingAmount: missingAmount, bolusBreakdown: BolusBreakdown(fullCarbsAmount: 0.0, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: correctionAmount)) } // the insulin needed to cover the zeroCarbEntry will underflow to 0 once added/subtracted let zeroCarbEntry = replacedCarbEntry == nil ? nil : NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: potentialCarbEntry!.startDate, foodType: nil, absorptionTime: potentialCarbEntry!.absorptionTime) - let predictionWithoutCarbs = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: zeroCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + let predictionWithZeroCarbEntry = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: zeroCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - let carbBreakdownRecommendationWithoutCarbs = try recommendBolusValidatingDataRecency(forPrediction: predictionWithoutCarbs, consideringPotentialCarbEntry: zeroCarbEntry, usage: .carbBreakdown) + guard let carbBreakdownRecommendationWithZeroCarbEntry = try recommendBolusValidatingDataRecency(forPrediction: predictionWithZeroCarbEntry, consideringPotentialCarbEntry: zeroCarbEntry, usage: .carbBreakdown) else { - guard carbBreakdownRecommendationWithoutCarbs != nil else { return recommendation // unable to directly calculate carbsAmount } - let carbsAmount = carbBreakdownRecommendation!.amount - carbBreakdownRecommendationWithoutCarbs!.amount + let carbsAmount = carbBreakdownRecommendation.amount - carbBreakdownRecommendationWithZeroCarbEntry.amount var missingAmount = recommendation!.missingAmount let correctionAmount : Double @@ -1535,7 +1544,7 @@ extension LoopDataManager { correctionAmount = calcAmount let amount = carbsAmount + correctionAmount - if volumeRounder()(amount) != 0 { + if amount > 0, volumeRounder()(amount) != 0 { missingAmount = amount } } else { @@ -1605,14 +1614,14 @@ extension LoopDataManager { case .correctionBreakdown: return -2E5 } } - + func glucoseTargetsOverride(_ schedule: GlucoseRangeSchedule, _ startingGlucose: HKQuantity) -> GlucoseRangeSchedule{ switch self { case .standard: return schedule case .cobBreakdown: let target = startingGlucose.doubleValue(for: .milligramsPerDeciliter) return GlucoseRangeSchedule(unit: .milligramsPerDeciliter, - dailyItems: [RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: target, maxValue: target))])! + dailyItems: [RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: target, maxValue: target))])! default: return adjustSchedule(schedule, amount: self.targetsAdjustment) } } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index bd8e89edf1..d5e621845f 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -120,11 +120,13 @@ final class BolusEntryViewModel: ObservableObject { } @Published var cobCorrectionBolus: HKQuantity? @Published var cobCorrectionBolusIncluded = true + @Published var userChangedCobCorrectionBolusIncluded = false var cobCorrectionBolusAmount: Double? { cobCorrectionBolus?.doubleValue(for: .internationalUnit()) } @Published var bgCorrectionBolus: HKQuantity? @Published var bgCorrectionBolusIncluded = true + @Published var userChangedBgCorrectionBolusIncluded = false var bgCorrectionBolusAmount: Double? { bgCorrectionBolus?.doubleValue(for: .internationalUnit()) } @@ -717,10 +719,6 @@ final class BolusEntryViewModel: ObservableObject { } } - private func resolveValue(_ useValue: Bool, _ value: Double) -> Double { - useValue ? round(1000*value) / 1000 : 0 - } - private func updateRecommendedBolusAndNotice(from state: LoopState, isUpdatingFromUserInput: Bool) { dispatchPrecondition(condition: .notOnQueue(.main)) @@ -733,34 +731,50 @@ final class BolusEntryViewModel: ObservableObject { var recommendation: ManualBolusRecommendation? let carbBolus: HKQuantity? let cobCorrectionBolus: HKQuantity? + var cobCorrectionBolusIncluded: Bool let bgCorrectionBolus: HKQuantity? + var bgCorrectionBolusIncluded: Bool let recommendedBolus: HKQuantity? var maxExcessBolus: HKQuantity? = nil var safetyLimitBolus: HKQuantity? = nil let notice: Notice? do { recommendation = try computeBolusRecommendation(from: state) + + // capture the value now that the recommendation is completed + bgCorrectionBolusIncluded = self.bgCorrectionBolusIncluded + cobCorrectionBolusIncluded = self.cobCorrectionBolusIncluded if let recommendation = recommendation { var totalRecommendation = 0.0 - + if let carbsAmount = recommendation.bolusBreakdown?.carbsAmount { carbBolus = HKQuantity(unit: .internationalUnit(), doubleValue: carbsAmount) - totalRecommendation += resolveValue(carbBolusIncluded, carbsAmount) + totalRecommendation += carbBolusIncluded ? carbsAmount : 0 } else { carbBolus = nil } + + if !FeatureFlags.correctionWithCarbBolus, potentialCarbEntry != nil, !userChangedBgCorrectionBolusIncluded, !userChangedCobCorrectionBolusIncluded { + let cobCorrectionAmount = recommendation.bolusBreakdown?.cobCorrectionAmount ?? 0.0 + let bgCorrectionAmount = recommendation.bolusBreakdown?.bgCorrectionAmount ?? 0.0 + + if cobCorrectionAmount + bgCorrectionAmount > 0 { + cobCorrectionBolusIncluded = false + bgCorrectionBolusIncluded = false + } + } if let cobCorrectionAmount = recommendation.bolusBreakdown?.cobCorrectionAmount { cobCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: cobCorrectionAmount) - totalRecommendation += resolveValue(cobCorrectionBolusIncluded, cobCorrectionAmount) + totalRecommendation += cobCorrectionBolusIncluded ? cobCorrectionAmount : 0 } else { cobCorrectionBolus = nil } if let bgCorrectionAmount = recommendation.bolusBreakdown?.bgCorrectionAmount { bgCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: bgCorrectionAmount) - totalRecommendation += resolveValue(bgCorrectionBolusIncluded, bgCorrectionAmount) + totalRecommendation += bgCorrectionBolusIncluded ? bgCorrectionAmount : 0 } else { bgCorrectionBolus = nil } @@ -784,16 +798,18 @@ final class BolusEntryViewModel: ObservableObject { } if let maxExcessAmount = maxExcessBolus?.doubleValue(for: .internationalUnit()) { - totalRecommendation += resolveValue(maxExcessBolusIncluded, -maxExcessAmount) + totalRecommendation += maxExcessBolusIncluded ? -maxExcessAmount : 0 } if let safetyLimitAmount = safetyLimitBolus?.doubleValue(for: .internationalUnit()) { - totalRecommendation += resolveValue(safetyLimitBolusIncluded, -safetyLimitAmount) + totalRecommendation += safetyLimitBolusIncluded ? -safetyLimitAmount : 0 } } if carbBolusIncluded, cobCorrectionBolusIncluded, bgCorrectionBolusIncluded, maxExcessBolusIncluded, safetyLimitBolusIncluded { totalRecommendation = recommendation.amount // avoid possible rounding issues + } else { + totalRecommendation = round(1000 * totalRecommendation) / 1000 } recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: max(0, totalRecommendation))) @@ -816,7 +832,9 @@ final class BolusEntryViewModel: ObservableObject { } else { carbBolus = nil cobCorrectionBolus = nil + cobCorrectionBolusIncluded = self.cobCorrectionBolusIncluded bgCorrectionBolus = nil + bgCorrectionBolusIncluded = self.bgCorrectionBolusIncluded maxExcessBolus = nil safetyLimitBolus = nil recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) @@ -825,7 +843,9 @@ final class BolusEntryViewModel: ObservableObject { } catch { carbBolus = nil cobCorrectionBolus = nil + cobCorrectionBolusIncluded = self.cobCorrectionBolusIncluded bgCorrectionBolus = nil + bgCorrectionBolusIncluded = self.bgCorrectionBolusIncluded maxExcessBolus = nil safetyLimitBolus = nil recommendedBolus = nil @@ -846,7 +866,9 @@ final class BolusEntryViewModel: ObservableObject { let priorRecommendedBolus = self.recommendedBolus self.carbBolus = carbBolus self.cobCorrectionBolus = cobCorrectionBolus + self.cobCorrectionBolusIncluded = cobCorrectionBolusIncluded self.bgCorrectionBolus = bgCorrectionBolus + self.bgCorrectionBolusIncluded = bgCorrectionBolusIncluded self.maxExcessBolus = maxExcessBolus self.safetyLimitBolus = safetyLimitBolus self.recommendedBolus = recommendedBolus diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 21e2e2b82e..78d23cf6fa 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -311,6 +311,7 @@ struct BolusEntryView: View { .contentShape(Rectangle()) .onTapGesture { viewModel.cobCorrectionBolusIncluded.toggle() + viewModel.userChangedCobCorrectionBolusIncluded = true } } @@ -335,6 +336,7 @@ struct BolusEntryView: View { .contentShape(Rectangle()) .onTapGesture { viewModel.bgCorrectionBolusIncluded.toggle() + viewModel.userChangedBgCorrectionBolusIncluded = true } } if viewModel.maxExcessBolus != nil { diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index b62f718696..935854ea78 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -475,9 +475,26 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) NotificationCenter.default.removeObserver(observer) } + + func dummyReplacementEntry() -> StoredCarbEntry{ + StoredCarbEntry(startDate: now, quantity: HKQuantity(unit: .gram(), doubleValue: -1)) + } + + func dummyCarbEntry() -> NewCarbEntry { + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: now.addingTimeInterval(TimeInterval(days: -2)), + foodType: nil, absorptionTime: TimeInterval(hours: 3)) + } + + func correctionRange(_ value: Double) -> GlucoseRangeSchedule { + correctionRange(value, value) + } + + func correctionRange(_ minValue: Double, _ maxValue: Double) -> GlucoseRangeSchedule { + GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: minValue, maxValue: maxValue))])! + } func testLoopGetStateRecommendsManualBolus() { - setUp(for: .highAndStable) + setUp(for: .flatAndStable, correctionRanges: correctionRange(106.26136802382213 - 1.82 * 55), suspendThresholdValue: 0.0) let exp = expectation(description: #function) var recommendedBolus: ManualBolusRecommendation? loopDataManager.getLoopState { (_, loopState) in @@ -493,7 +510,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } func testLoopGetStateRecommendsManualBolusMaxBolusClamping() { - setUp(for: .highAndStable, maxBolus: 1) + setUp(for: .flatAndStable, maxBolus: 1, correctionRanges: correctionRange(106.26136802382213 - 1.82 * 55), suspendThresholdValue: 0.0) let exp = expectation(description: #function) var recommendedBolus: ManualBolusRecommendation? loopDataManager.getLoopState { (_, loopState) in @@ -507,16 +524,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.missingAmount!, 0.82, accuracy: 0.01) } - - func dummyReplacementEntry() -> StoredCarbEntry{ - StoredCarbEntry(startDate: now, quantity: HKQuantity(unit: .gram(), doubleValue: -1)) - } - - func dummyCarbEntry() -> NewCarbEntry { - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: now.addingTimeInterval(TimeInterval(days: -2)), - foodType: nil, absorptionTime: TimeInterval(hours: 3)) - } - + func testLoopGetStateRecommendsManualBolusForCob() { // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) @@ -661,8 +669,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } func testLoopGetStateRecommendsManualBolusForBeneathRange() { - setUp(for: .highAndStable, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ - RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 230, maxValue: 230))])) + setUp(for: .flatAndStable, correctionRanges: correctionRange(160)) let exp = expectation(description: #function) var recommendedBolus: ManualBolusRecommendation? @@ -672,15 +679,14 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (106.21882841682697 - 160) / 55, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) XCTAssertNil(recommendedBolus!.missingAmount) } func testLoopGetStateRecommendsManualBolusForInRangeAboveMidPoint() { - setUp(for: .flatAndStable, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ - RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 80, maxValue: 110))])) + setUp(for: .flatAndStable, correctionRanges: correctionRange(80, 110)) let exp = expectation(description: #function) var recommendedBolus: ManualBolusRecommendation? @@ -697,8 +703,27 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } func testLoopGetStateRecommendsManualBolusForSuspendForCarbEntry() { - setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ - RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 230, maxValue: 230))]), suspendThresholdValue: 180) + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(230), suspendThresholdValue: 220) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 15.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 1.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.missingAmount!, 1.5 + (176.21882841682697 - 230) / 45, accuracy: 0.01) + } + + func testLoopGetStateRecommendsManualBolusNoMissingForSuspendForCarbEntry() { + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(230), suspendThresholdValue: 220) let exp = expectation(description: #function) var recommendedBolus: ManualBolusRecommendation? @@ -714,12 +739,11 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (176.21882841682697 - 230) / 45, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0.5, accuracy: 0.01) - XCTAssertEqual(recommendedBolus!.missingAmount!, 0.5 + (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) // carbsAmount + bgCorrectionAmount < 0, so nothing is missing } func testLoopGetStateRecommendsManualBolusForSuspendNoCarbEntry() { - setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ - RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 230, maxValue: 230))]), suspendThresholdValue: 180) + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(230), suspendThresholdValue: 180) let exp = expectation(description: #function) var recommendedBolus: ManualBolusRecommendation? @@ -739,8 +763,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } func testLoopGetStateRecommendsManualBolusForInRangeCarbEntry() { - setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ - RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 170, maxValue: 210))])) + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(170, 210)) let exp1 = expectation(description: #function) var recommendedBolus1: ManualBolusRecommendation? diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 7f2c421ebf..43c0c5fcf0 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -699,17 +699,31 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertNil(bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) } + func is24Hour() -> Bool { + let dateFormat = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: Locale.current)! + + return dateFormat.firstIndex(of: "a") == nil + } + func testCarbEntryDateAndAbsorptionTimeString() async throws { await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) - XCTAssertEqual("12:00 PM + 0m", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + if is24Hour() { + XCTAssertEqual("12:00 + 0m", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } else { + XCTAssertEqual("12:00 PM + 0m", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } } func testCarbEntryDateAndAbsorptionTimeString2() async throws { let potentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: nil) await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: potentialCarbEntry) - XCTAssertEqual("12:00 PM", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + if is24Hour() { + XCTAssertEqual("12:00", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } else { + XCTAssertEqual("12:00 PM", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } } func testIsManualGlucosePromptVisible() throws { From 79efd20daeba22399090ee645096fd3b9abf7e7a Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Fri, 23 Aug 2024 16:37:59 +0300 Subject: [PATCH 24/43] Separate out recommendation from UI interaction. Fix slowdown too --- Loop/View Models/BolusEntryViewModel.swift | 56 ++++++++++++++-------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index d5e621845f..8daf57052a 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -269,41 +269,50 @@ final class BolusEntryViewModel: ObservableObject { private func observeBolusBreakdownChanges() { $carbBolusIncluded - .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + .sink { [weak self] newValue in + if self?.carbBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } } } .store(in: &cancellables) $cobCorrectionBolusIncluded - .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + .sink { [weak self] newValue in + if self?.cobCorrectionBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } } } .store(in: &cancellables) $bgCorrectionBolusIncluded - .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + .sink { [weak self] newValue in + if self?.bgCorrectionBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } } } .store(in: &cancellables) $maxExcessBolusIncluded - .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + .sink { [weak self] newValue in + if self?.maxExcessBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } } } .store(in: &cancellables) $safetyLimitBolusIncluded - .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + .sink { [weak self] newValue in + if self?.safetyLimitBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } } } .store(in: &cancellables) - } private func observeEnteredManualGlucoseChanges() { @@ -720,6 +729,14 @@ final class BolusEntryViewModel: ObservableObject { } private func updateRecommendedBolusAndNotice(from state: LoopState, isUpdatingFromUserInput: Bool) { + updateRecommendedBolusAndNotice(recommendationSupplier: {try computeBolusRecommendation(from: state)}, isUpdatingFromUserInput: isUpdatingFromUserInput) + } + + private func updateRecommendedBolusAndNoticeForBolusBreakdownChange() { + updateRecommendedBolusAndNotice(recommendationSupplier: {self.dosingDecision.manualBolusRecommendation?.recommendation}, isUpdatingFromUserInput: true) + } + + private func updateRecommendedBolusAndNotice(recommendationSupplier: () throws -> ManualBolusRecommendation?, isUpdatingFromUserInput: Bool) { dispatchPrecondition(condition: .notOnQueue(.main)) guard let delegate = delegate else { @@ -739,9 +756,8 @@ final class BolusEntryViewModel: ObservableObject { var safetyLimitBolus: HKQuantity? = nil let notice: Notice? do { - recommendation = try computeBolusRecommendation(from: state) + recommendation = try recommendationSupplier() - // capture the value now that the recommendation is completed bgCorrectionBolusIncluded = self.bgCorrectionBolusIncluded cobCorrectionBolusIncluded = self.cobCorrectionBolusIncluded @@ -780,8 +796,6 @@ final class BolusEntryViewModel: ObservableObject { } if let missingAmount = recommendation.missingAmount { - var amount = missingAmount - if let maxBolus = maximumBolus?.doubleValue(for: .internationalUnit()) { if missingAmount > maxBolus { safetyLimitBolus = maximumBolus! @@ -874,7 +888,7 @@ final class BolusEntryViewModel: ObservableObject { self.recommendedBolus = recommendedBolus self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } self.activeNotice = notice - + if priorRecommendedBolus != nil, priorRecommendedBolus != recommendedBolus, !self.enacting, From a3696e71e5f39fdcfc712b05abd9f7b6cd084fba Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sat, 24 Aug 2024 13:09:34 +0300 Subject: [PATCH 25/43] Add Diable BG Correction options. Only use carbs and insulin for max COB correction --- Common/FeatureFlags.swift | 9 ++++++++- Loop/Managers/LoopDataManager.swift | 8 +++++--- Loop/View Models/BolusEntryViewModel.swift | 13 ++++++++++--- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index bf64a34afb..5d0ae4acd8 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -40,6 +40,7 @@ struct FeatureFlagConfiguration: Decodable { let missedMealNotifications: Bool let allowAlgorithmExperiments: Bool let correctionWithCarbBolus: Bool + let bgCorrectionWithCarbBolus: Bool fileprivate init() { // Swift compiler config is inverse, since the default state is enabled. @@ -237,7 +238,13 @@ struct FeatureFlagConfiguration: Decodable { self.correctionWithCarbBolus = false #else self.correctionWithCarbBolus = true - #endif + #endif + + #if DISABLE_BG_CORRECTION_WITH_CARB_BOLUS + self.bgCorrectionWithCarbBolus = false + #else + self.bgCorrectionWithCarbBolus = true + #endif } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index dcef92f8d9..ec1f1ccf54 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1494,14 +1494,16 @@ extension LoopDataManager { let totalCarbBolus = carbBreakdownRecommendation.amount - noCarbsBreakdownRecommendation.amount - let flatPrediction = prediction.map{PredictedGlucoseValue(startDate: $0.startDate, quantity: prediction.last!.quantity)} + let cobPrediction = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) - guard let maxCobAmount = try recommendBolusValidatingDataRecency(forPrediction: flatPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown)?.amount else { + let flatCobPrediction = cobPrediction.map{PredictedGlucoseValue(startDate: $0.startDate, quantity: cobPrediction.last!.quantity)} + + guard let maxCobAmount = try recommendBolusValidatingDataRecency(forPrediction: flatCobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown)?.amount else { return recommendation // unable to differentiate between correction amounts } - // totalCobAmount is the additional insulin needed for correcting carbs + // totalCobAmount is the additional insulin needed for correcting carbs (including potentialCarbEntry) let totalCobAmount = Swift.min(maxCobAmount, totalCarbBolus) guard potentialCarbEntry != nil else { diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 8daf57052a..edb302455c 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -780,6 +780,14 @@ final class BolusEntryViewModel: ObservableObject { bgCorrectionBolusIncluded = false } } + + if !FeatureFlags.bgCorrectionWithCarbBolus, potentialCarbEntry != nil, !userChangedBgCorrectionBolusIncluded { + let bgCorrectionAmount = recommendation.bolusBreakdown?.bgCorrectionAmount ?? 0.0 + + if bgCorrectionAmount > 0 { + bgCorrectionBolusIncluded = false + } + } if let cobCorrectionAmount = recommendation.bolusBreakdown?.cobCorrectionAmount { cobCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: cobCorrectionAmount) @@ -812,11 +820,11 @@ final class BolusEntryViewModel: ObservableObject { } if let maxExcessAmount = maxExcessBolus?.doubleValue(for: .internationalUnit()) { - totalRecommendation += maxExcessBolusIncluded ? -maxExcessAmount : 0 + totalRecommendation -= maxExcessBolusIncluded ? maxExcessAmount : 0 } if let safetyLimitAmount = safetyLimitBolus?.doubleValue(for: .internationalUnit()) { - totalRecommendation += safetyLimitBolusIncluded ? -safetyLimitAmount : 0 + totalRecommendation -= safetyLimitBolusIncluded ? safetyLimitAmount : 0 } } @@ -827,7 +835,6 @@ final class BolusEntryViewModel: ObservableObject { } recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: max(0, totalRecommendation))) - //recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) switch recommendation.notice { case .glucoseBelowSuspendThreshold: From 546d25c7e6c25ef577656c639b922519987096e6 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Wed, 28 Aug 2024 13:32:33 +0300 Subject: [PATCH 26/43] Handle negative insulin better for COB --- Loop/Managers/LoopDataManager.swift | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index ec1f1ccf54..96e473396b 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1478,34 +1478,29 @@ extension LoopDataManager { guard !prediction.isEmpty else { return recommendation // unable to differentiate between correction amounts, } - - guard let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) else { - + + let carbAndInsulinPrediction = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + + guard !carbAndInsulinPrediction.isEmpty else { return recommendation // unable to differentiate between correction amounts } - - let noCarbsPrediction = try predictGlucose(using: .all.subtracting([.carbs]), potentialBolus: nil, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - - guard let noCarbsBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: noCarbsPrediction, consideringPotentialCarbEntry: nil, usage: .carbBreakdown) else { - + let carbOnlyPrediction = try predictGlucose(using: [.carbs], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + + guard !carbOnlyPrediction.isEmpty else { return recommendation // unable to differentiate between correction amounts } - let totalCarbBolus = carbBreakdownRecommendation.amount - noCarbsBreakdownRecommendation.amount - - let cobPrediction = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + // cobCorrection includes insulin when its effects are to reduce BG, but doesn't include it if it raises it (e.g., negative IOB) + let cobPrediction = carbAndInsulinPrediction.last!.quantity < carbOnlyPrediction.last!.quantity ? carbAndInsulinPrediction : carbOnlyPrediction let flatCobPrediction = cobPrediction.map{PredictedGlucoseValue(startDate: $0.startDate, quantity: cobPrediction.last!.quantity)} - guard let maxCobAmount = try recommendBolusValidatingDataRecency(forPrediction: flatCobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown)?.amount else { + guard let totalCobAmount = try recommendBolusValidatingDataRecency(forPrediction: flatCobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown)?.amount else { return recommendation // unable to differentiate between correction amounts } - // totalCobAmount is the additional insulin needed for correcting carbs (including potentialCarbEntry) - let totalCobAmount = Swift.min(maxCobAmount, totalCarbBolus) - guard potentialCarbEntry != nil else { var missingAmount = recommendation!.missingAmount @@ -1526,6 +1521,11 @@ extension LoopDataManager { return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, missingAmount: missingAmount, bolusBreakdown: BolusBreakdown(fullCarbsAmount: 0.0, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: correctionAmount)) } + guard let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) else { + + return recommendation // unable to differentiate between correction amounts + } + // the insulin needed to cover the zeroCarbEntry will underflow to 0 once added/subtracted let zeroCarbEntry = replacedCarbEntry == nil ? nil : NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: potentialCarbEntry!.startDate, foodType: nil, absorptionTime: potentialCarbEntry!.absorptionTime) From f4345d9542ae9e6584da4f67a18c9095e2e5cf2c Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Mon, 23 Sep 2024 14:58:17 +0300 Subject: [PATCH 27/43] in progress --- Loop/Managers/LoopDataManager.swift | 46 +++++++++++++++++++---------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 96e473396b..94d3b81de7 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1504,16 +1504,18 @@ extension LoopDataManager { guard potentialCarbEntry != nil else { var missingAmount = recommendation!.missingAmount - var correctionAmount = recommendation!.amount + Swift.max(missingAmount ?? 0, 0) + var initialCorrectionAmount = recommendation!.amount + Swift.max(missingAmount ?? 0, 0) + var correctionAmount = initialCorrectionAmount - if correctionAmount == 0 { - if let calcAmount = try calcCorrectionAmount(carbsAmount: 0, prediction: prediction, potentialCarbEntry: nil) { - - correctionAmount = calcAmount - if recommendation!.notice == .predictedGlucoseInRange { - correctionAmount = Swift.min(correctionAmount, 0) // ensure 0 if in range but above the mid-point - } else if correctionAmount > 0, volumeRounder()(correctionAmount) != 0 { - missingAmount = correctionAmount + if let calcAmount = try calcCorrectionAmount(carbsAmount: 0, prediction: prediction, potentialCarbEntry: nil) { + + correctionAmount = calcAmount + if recommendation!.notice == .predictedGlucoseInRange { + correctionAmount = Swift.min(correctionAmount, 0) // ensure 0 if in range but above the mid-point + } else { + let totalMissingAmount = calcAmount - initialCorrectionAmount + if totalMissingAmount > 0, volumeRounder()(totalMissingAmount) != 0 { + missingAmount = totalMissingAmount } } } @@ -1539,16 +1541,30 @@ extension LoopDataManager { let carbsAmount = carbBreakdownRecommendation.amount - carbBreakdownRecommendationWithZeroCarbEntry.amount var missingAmount = recommendation!.missingAmount - let correctionAmount : Double + var correctionAmount : Double - if recommendation!.amount <= 0, recommendation!.notice != .predictedGlucoseInRange, - let calcAmount = try calcCorrectionAmount(carbsAmount: carbsAmount, prediction: prediction, potentialCarbEntry: potentialCarbEntry) { - + if let calcAmount = try calcCorrectionAmount(carbsAmount: carbsAmount, prediction: prediction, potentialCarbEntry: potentialCarbEntry) { correctionAmount = calcAmount - let amount = carbsAmount + correctionAmount + let extra = Swift.max(missingAmount ?? 0, 0) + let amount = carbsAmount + correctionAmount - (extra + recommendation!.amount) if amount > 0, volumeRounder()(amount) != 0 { - missingAmount = amount + missingAmount = amount + extra + } + + /* + if recommendation!.amount <= 0, recommendation!.notice != .predictedGlucoseInRange { + let amount = carbsAmount + correctionAmount + if amount > 0, volumeRounder()(amount) != 0 { + missingAmount = amount + } + } else { + + let amount = carbsAmount + correctionAmount - (extra + recommendation!.amount) + if amount > 0, volumeRounder()(amount) != 0 { + missingAmount = amount + extra + } } + */ } else { let extra = Swift.max(recommendation!.missingAmount ?? 0, 0) correctionAmount = recommendation!.amount + extra - carbsAmount From afceef4a7f6c3c51062b6f79abff6452fcf48783 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 10 Oct 2024 15:10:45 +0300 Subject: [PATCH 28/43] Unify code paths. Tests not yet passing --- Loop/Managers/LoopDataManager.swift | 79 ++++++++++------------------- 1 file changed, 26 insertions(+), 53 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 94d3b81de7..a6bc0a9521 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1501,76 +1501,49 @@ extension LoopDataManager { return recommendation // unable to differentiate between correction amounts } - guard potentialCarbEntry != nil else { - var missingAmount = recommendation!.missingAmount - - var initialCorrectionAmount = recommendation!.amount + Swift.max(missingAmount ?? 0, 0) - var correctionAmount = initialCorrectionAmount - - if let calcAmount = try calcCorrectionAmount(carbsAmount: 0, prediction: prediction, potentialCarbEntry: nil) { + var carbsAmount = 0.0 + + if potentialCarbEntry != nil { + guard let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) else { - correctionAmount = calcAmount - if recommendation!.notice == .predictedGlucoseInRange { - correctionAmount = Swift.min(correctionAmount, 0) // ensure 0 if in range but above the mid-point - } else { - let totalMissingAmount = calcAmount - initialCorrectionAmount - if totalMissingAmount > 0, volumeRounder()(totalMissingAmount) != 0 { - missingAmount = totalMissingAmount - } - } + return recommendation // unable to differentiate between correction amounts } - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, missingAmount: missingAmount, bolusBreakdown: BolusBreakdown(fullCarbsAmount: 0.0, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: correctionAmount)) - } - - guard let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) else { + // the insulin needed to cover the zeroCarbEntry will underflow to 0 once added/subtracted + let zeroCarbEntry = replacedCarbEntry == nil ? nil : NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: potentialCarbEntry!.startDate, foodType: nil, absorptionTime: potentialCarbEntry!.absorptionTime) - return recommendation // unable to differentiate between correction amounts - } - - // the insulin needed to cover the zeroCarbEntry will underflow to 0 once added/subtracted - let zeroCarbEntry = replacedCarbEntry == nil ? nil : NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: potentialCarbEntry!.startDate, foodType: nil, absorptionTime: potentialCarbEntry!.absorptionTime) - - let predictionWithZeroCarbEntry = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: zeroCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - - guard let carbBreakdownRecommendationWithZeroCarbEntry = try recommendBolusValidatingDataRecency(forPrediction: predictionWithZeroCarbEntry, consideringPotentialCarbEntry: zeroCarbEntry, usage: .carbBreakdown) else { - - return recommendation // unable to directly calculate carbsAmount + let predictionWithZeroCarbEntry = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: zeroCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) + + guard let carbBreakdownRecommendationWithZeroCarbEntry = try recommendBolusValidatingDataRecency(forPrediction: predictionWithZeroCarbEntry, consideringPotentialCarbEntry: zeroCarbEntry, usage: .carbBreakdown) else { + + return recommendation // unable to directly calculate carbsAmount + } + + carbsAmount = carbBreakdownRecommendation.amount - carbBreakdownRecommendationWithZeroCarbEntry.amount } - let carbsAmount = carbBreakdownRecommendation.amount - carbBreakdownRecommendationWithZeroCarbEntry.amount - var missingAmount = recommendation!.missingAmount + let extra = Swift.max(missingAmount ?? 0, 0) var correctionAmount : Double - + if let calcAmount = try calcCorrectionAmount(carbsAmount: carbsAmount, prediction: prediction, potentialCarbEntry: potentialCarbEntry) { correctionAmount = calcAmount - let extra = Swift.max(missingAmount ?? 0, 0) - let amount = carbsAmount + correctionAmount - (extra + recommendation!.amount) - if amount > 0, volumeRounder()(amount) != 0 { - missingAmount = amount + extra - } - - /* - if recommendation!.amount <= 0, recommendation!.notice != .predictedGlucoseInRange { - let amount = carbsAmount + correctionAmount - if amount > 0, volumeRounder()(amount) != 0 { - missingAmount = amount - } + + if recommendation!.notice == .predictedGlucoseInRange { + correctionAmount = Swift.min(correctionAmount, 0) // ensure 0 if in range but above the mid-point } else { - - let amount = carbsAmount + correctionAmount - (extra + recommendation!.amount) - if amount > 0, volumeRounder()(amount) != 0 { - missingAmount = amount + extra + let totalMissingAmount = carbsAmount + correctionAmount - recommendation!.amount + if totalMissingAmount > 0, volumeRounder()(totalMissingAmount) != 0 { + missingAmount = totalMissingAmount + } else { + missingAmount = nil } } - */ } else { - let extra = Swift.max(recommendation!.missingAmount ?? 0, 0) correctionAmount = recommendation!.amount + extra - carbsAmount } - return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, missingAmount: missingAmount, bolusBreakdown: BolusBreakdown(fullCarbsAmount: carbsAmount, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: correctionAmount)) + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, missingAmount: missingAmount, bolusBreakdown: BolusBreakdown(fullCarbsAmount: carbsAmount, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: correctionAmount)) } fileprivate func calcCorrectionAmount(carbsAmount: Double, From 4d1d0dff51e202066223a3534a803b0443aae47b Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Fri, 11 Oct 2024 12:27:18 +0300 Subject: [PATCH 29/43] After refactoring. Tests passing --- Loop/Managers/LoopDataManager.swift | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index a6bc0a9521..9e68597df0 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1524,23 +1524,26 @@ extension LoopDataManager { var missingAmount = recommendation!.missingAmount let extra = Swift.max(missingAmount ?? 0, 0) - var correctionAmount : Double + var correctionAmount = recommendation!.amount + extra - carbsAmount if let calcAmount = try calcCorrectionAmount(carbsAmount: carbsAmount, prediction: prediction, potentialCarbEntry: potentialCarbEntry) { - correctionAmount = calcAmount - + if recommendation!.notice == .predictedGlucoseInRange { - correctionAmount = Swift.min(correctionAmount, 0) // ensure 0 if in range but above the mid-point + correctionAmount = Swift.min(correctionAmount, calcAmount, 0) // ensure 0 if in range but above the mid-point + missingAmount = carbsAmount + correctionAmount - recommendation!.amount + if missingAmount! <= 0 || volumeRounder()(missingAmount!) == 0 { + missingAmount = nil + } } else { - let totalMissingAmount = carbsAmount + correctionAmount - recommendation!.amount - if totalMissingAmount > 0, volumeRounder()(totalMissingAmount) != 0 { + let totalMissingAmount = carbsAmount + calcAmount - recommendation!.amount + if totalMissingAmount > extra, volumeRounder()(totalMissingAmount - extra) != 0 { + correctionAmount = calcAmount missingAmount = totalMissingAmount - } else { + } else if recommendation!.amount == 0 && calcAmount < 0 { + correctionAmount = calcAmount missingAmount = nil } } - } else { - correctionAmount = recommendation!.amount + extra - carbsAmount } return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, missingAmount: missingAmount, bolusBreakdown: BolusBreakdown(fullCarbsAmount: carbsAmount, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: correctionAmount)) From 20409ad200b83c1635a559b07b941bfdb47cf550 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Fri, 11 Oct 2024 13:28:50 +0300 Subject: [PATCH 30/43] Add comment and minor generalization --- Loop/Managers/LoopDataManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 9e68597df0..4e2d22df83 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1532,11 +1532,11 @@ extension LoopDataManager { correctionAmount = Swift.min(correctionAmount, calcAmount, 0) // ensure 0 if in range but above the mid-point missingAmount = carbsAmount + correctionAmount - recommendation!.amount if missingAmount! <= 0 || volumeRounder()(missingAmount!) == 0 { - missingAmount = nil + missingAmount = nil // this MUST be the case since extra should be 0, and missingAmount <= extra by construction } } else { let totalMissingAmount = carbsAmount + calcAmount - recommendation!.amount - if totalMissingAmount > extra, volumeRounder()(totalMissingAmount - extra) != 0 { + if totalMissingAmount > extra, volumeRounder()(totalMissingAmount - extra) > 0 { correctionAmount = calcAmount missingAmount = totalMissingAmount } else if recommendation!.amount == 0 && calcAmount < 0 { From ae6fb73326e725f5a9a709b458d84aa189d2b3e5 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Fri, 11 Oct 2024 18:04:26 +0300 Subject: [PATCH 31/43] Add test for missing insulin due to suspend threshold effects on dosage --- .../Managers/LoopDataManagerDosingTests.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index 935854ea78..e0769066a8 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -722,6 +722,27 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(recommendedBolus!.missingAmount!, 1.5 + (176.21882841682697 - 230) / 45, accuracy: 0.01) } + func testLoopGetStateRecommendsManualBolusForBigAndSlowCarbEntry() { + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(176.2188), suspendThresholdValue: 176.218) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 100.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 4.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 7.27, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 9.99, accuracy: 0.01) // 9.99 and not 10 since there is 10 minute delay, leaving 0.01 remaining + XCTAssertEqual(recommendedBolus!.missingAmount!, 9.99 - 7.27, accuracy: 0.01) + } + + func testLoopGetStateRecommendsManualBolusNoMissingForSuspendForCarbEntry() { setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(230), suspendThresholdValue: 220) From 8c2a86d994f53f43fccbc15d70e016a4b473aeb1 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sun, 13 Oct 2024 10:28:33 +0300 Subject: [PATCH 32/43] Fix display calculations for max and safety limits --- Loop/View Models/BolusEntryViewModel.swift | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index edb302455c..10d719e33f 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -803,22 +803,22 @@ final class BolusEntryViewModel: ObservableObject { bgCorrectionBolus = nil } - if let missingAmount = recommendation.missingAmount { + if let missingAmount = recommendation.missingAmount, missingAmount > 0 { if let maxBolus = maximumBolus?.doubleValue(for: .internationalUnit()) { - if missingAmount > maxBolus { - safetyLimitBolus = maximumBolus! - maxExcessBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount - maxBolus) - } - } - - if safetyLimitBolus == nil { - if recommendation.amount != 0 { + if recommendation.amount >= maxBolus { + // while it is technically possible for some safetyLimitBolus too, this isn't identifiable, nor parituclarly relevant maxExcessBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) + } else if recommendation.amount + missingAmount > maxBolus { + safetyLimitBolus = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus - recommendation.amount) + maxExcessBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount + missingAmount - maxBolus) } else { safetyLimitBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) } + } else { + // generally we shouldn't be here, but if we don't know maxBolus we have to treat it all as safety limit + safetyLimitBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) } - + if let maxExcessAmount = maxExcessBolus?.doubleValue(for: .internationalUnit()) { totalRecommendation -= maxExcessBolusIncluded ? maxExcessAmount : 0 } From f208d25aaec67030772357560138bcb2bb287018 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 26 Dec 2024 11:58:01 +0200 Subject: [PATCH 33/43] Add Auto-Bolus Carbs Experiment --- Loop.xcodeproj/project.pbxproj | 4 + Loop/Managers/LoopDataManager.swift | 204 ++++++++++++------ .../StatusTableViewController.swift | 26 ++- Loop/Views/AutoBolusCarbsSelectionView.swift | 57 +++++ Loop/Views/ManualEntryDoseView.swift | 2 +- ...ingsView+algorithmExperimentsSection.swift | 27 +++ .../Managers/LoopDataManagerDosingTests.swift | 96 +++++++++ LoopTests/Managers/LoopDataManagerTests.swift | 10 +- 8 files changed, 357 insertions(+), 69 deletions(-) create mode 100644 Loop/Views/AutoBolusCarbsSelectionView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1181951609..5afe5cc9e7 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 12F04F012D19791B002B2121 /* AutoBolusCarbsSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */; }; 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; }; @@ -743,6 +744,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoBolusCarbsSelectionView.swift; sourceTree = ""; }; 142CB7582A60BF2E0075748A /* EditMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditMode.swift; sourceTree = ""; }; 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsView.swift; sourceTree = ""; }; 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodViewModel.swift; sourceTree = ""; }; @@ -2244,6 +2246,7 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( + 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */, 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */, B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, @@ -3736,6 +3739,7 @@ 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */, + 12F04F012D19791B002B2121 /* AutoBolusCarbsSelectionView.swift in Sources */, 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */, 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */, C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 4e2d22df83..a285abb3c9 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -420,6 +420,18 @@ final class LoopDataManager { } } private let lockedLastLoopCompleted: Locked + + var autoBolusCarbsEnabledAndActive: Bool { + guard UserDefaults.standard.autoBolusCarbsEnabled else { + return false + } + + guard let override = lockedSettings.value.scheduleOverride, override.isActive() else { + return UserDefaults.standard.autoBolusCarbsActiveByDefault + } + + return override.settings.autoBolusCarbsActive ?? UserDefaults.standard.autoBolusCarbsActiveByDefault + } fileprivate var lastLoopError: LoopError? @@ -1835,6 +1847,86 @@ extension LoopDataManager { suspendInsulinDeliveryEffect = suspendDoses.glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: insulinSensitivity).filterDateRange(startSuspend, endSuspend) } + fileprivate func getDosingRecommendation(dosingStrategy: AutomaticDosingStrategy, glucose: any GlucoseSampleValue, predictedGlucose: [PredictedGlucoseValue], glucoseTargetRange: GlucoseRangeSchedule?, insulinSensitivity: InsulinSensitivitySchedule?, basalRateSchedule: BasalRateSchedule?, startDate: Date, bolusApplicationFactor: Double? = nil) -> AutomaticDoseRecommendation? { + + let rateRounder = { (_ rate: Double) in + return self.delegate?.roundBasalRate(unitsPerHour: rate) ?? rate + } + + let lastTempBasal: DoseEntry? + + if case .some(.tempBasal(let dose)) = basalDeliveryState { + lastTempBasal = dose + } else { + lastTempBasal = nil + } + + let maxBolus = settings.maximumBolus! + let maxBasal = settings.maximumBasalRatePerHour! + + // automaticDosingIOBLimit calculated from the user entered maxBolus + let automaticDosingIOBLimit = maxBolus * 2.0 + let iobHeadroom = automaticDosingIOBLimit - self.insulinOnBoard!.value + + switch dosingStrategy { + case .automaticBolus: + let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() + + let effectiveBolusApplicationFactor: Double + + if bolusApplicationFactor != nil { + effectiveBolusApplicationFactor = bolusApplicationFactor! + } else { + // Create dosing strategy based on user setting + let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + ? GlucoseBasedApplicationFactorStrategy() + : ConstantApplicationFactorStrategy() + + effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( + for: glucose.quantity, + correctionRangeSchedule: correctionRangeSchedule!, + settings: settings + ) + } + + self.logger.debug(" *** Glucose: %{public}@, effectiveBolusApplicationFactor: %.2f", glucose.quantity.description, effectiveBolusApplicationFactor) + + // If a user customizes maxPartialApplicationFactor > 1; this respects maxBolus + let maxAutomaticBolus = min(iobHeadroom, maxBolus * min(effectiveBolusApplicationFactor, 1.0)) + + return predictedGlucose.recommendedAutomaticDose( + to: glucoseTargetRange!, + at: predictedGlucose[0].startDate, + suspendThreshold: settings.suspendThreshold?.quantity, + sensitivity: insulinSensitivity!, + model: doseStore.insulinModelProvider.model(for: pumpInsulinType), + basalRates: basalRateSchedule!, + maxAutomaticBolus: maxAutomaticBolus, + partialApplicationFactor: effectiveBolusApplicationFactor * self.timeBasedDoseApplicationFactor, + lastTempBasal: lastTempBasal, + volumeRounder: volumeRounder(), + rateRounder: rateRounder, + isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true + ) + case .tempBasalOnly: + + let temp = predictedGlucose.recommendedTempBasal( + to: glucoseTargetRange!, + at: predictedGlucose[0].startDate, + suspendThreshold: settings.suspendThreshold?.quantity, + sensitivity: insulinSensitivity!, + model: doseStore.insulinModelProvider.model(for: pumpInsulinType), + basalRates: basalRateSchedule!, + maxBasalRate: maxBasal, + additionalActiveInsulinClamp: iobHeadroom, + lastTempBasal: lastTempBasal, + rateRounder: rateRounder, + isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true + ) + return AutomaticDoseRecommendation(basalAdjustment: temp) + } + } + /// Runs the glucose prediction on the latest effect data. /// /// - Throws: @@ -1947,77 +2039,59 @@ extension LoopDataManager { dosingDecision.appendWarning(.bolusInProgress) return (dosingDecision, nil) } + + var dosingRecommendation: AutomaticDoseRecommendation? - let rateRounder = { (_ rate: Double) in - return self.delegate?.roundBasalRate(unitsPerHour: rate) ?? rate + var autoBolusCarbsAmount = -Double.infinity + + if autoBolusCarbsEnabledAndActive { + do { + if let bolusRecommendation = try recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses), let breakdown = bolusRecommendation.bolusBreakdown { + + let amount = min(bolusRecommendation.amount, volumeRounder()(breakdown.cobCorrectionAmount)) + + if amount > 0 { + autoBolusCarbsAmount = amount + } + } + } catch { + logger.error("Unexpected error, won't auto-bolus carbs: %{public}@", String(describing: error)) + } } - - let lastTempBasal: DoseEntry? - - if case .some(.tempBasal(let dose)) = basalDeliveryState { - lastTempBasal = dose + + let bolusApplicationFactor: Double? + + if autoBolusCarbsAmount > 0 { + switch settings.automaticDosingStrategy { + case .automaticBolus: bolusApplicationFactor = nil + case .tempBasalOnly: + // instead of temp basal, compare with automaticBolus with an adjusted bolusApplicationFactor reflecting 5 minutes of temp basal + bolusApplicationFactor = 5.0/30.0 + } } else { - lastTempBasal = nil + bolusApplicationFactor = nil } - - let dosingRecommendation: AutomaticDoseRecommendation? - - // automaticDosingIOBLimit calculated from the user entered maxBolus - let automaticDosingIOBLimit = maxBolus! * 2.0 - let iobHeadroom = automaticDosingIOBLimit - self.insulinOnBoard!.value - - switch settings.automaticDosingStrategy { - case .automaticBolus: - // Create dosing strategy based on user setting - let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - ? GlucoseBasedApplicationFactorStrategy() - : ConstantApplicationFactorStrategy() - - let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() - - let effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( - for: glucose.quantity, - correctionRangeSchedule: correctionRangeSchedule!, - settings: settings - ) - - self.logger.debug(" *** Glucose: %{public}@, effectiveBolusApplicationFactor: %.2f", glucose.quantity.description, effectiveBolusApplicationFactor) - // If a user customizes maxPartialApplicationFactor > 1; this respects maxBolus - let maxAutomaticBolus = min(iobHeadroom, maxBolus! * min(effectiveBolusApplicationFactor, 1.0)) - - dosingRecommendation = predictedGlucose.recommendedAutomaticDose( - to: glucoseTargetRange!, - at: predictedGlucose[0].startDate, - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity!, - model: doseStore.insulinModelProvider.model(for: pumpInsulinType), - basalRates: basalRateSchedule!, - maxAutomaticBolus: maxAutomaticBolus, - partialApplicationFactor: effectiveBolusApplicationFactor * self.timeBasedDoseApplicationFactor, - lastTempBasal: lastTempBasal, - volumeRounder: volumeRounder(), - rateRounder: rateRounder, - isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true - ) - case .tempBasalOnly: - - let temp = predictedGlucose.recommendedTempBasal( - to: glucoseTargetRange!, - at: predictedGlucose[0].startDate, - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity!, - model: doseStore.insulinModelProvider.model(for: pumpInsulinType), - basalRates: basalRateSchedule!, - maxBasalRate: maxBasal!, - additionalActiveInsulinClamp: iobHeadroom, - lastTempBasal: lastTempBasal, - rateRounder: rateRounder, - isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true - ) - dosingRecommendation = AutomaticDoseRecommendation(basalAdjustment: temp) + let dosingStrategty = autoBolusCarbsAmount > 0 ? .automaticBolus : settings.automaticDosingStrategy + + dosingRecommendation = getDosingRecommendation(dosingStrategy: dosingStrategty, glucose: glucose, predictedGlucose: predictedGlucose, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate, bolusApplicationFactor: bolusApplicationFactor) + + if dosingRecommendation?.bolusUnits != nil, autoBolusCarbsAmount > dosingRecommendation!.bolusUnits! { + logger.info("Recommendation is to auto-bolus carbs as it will give more insulin") + dosingRecommendation = AutomaticDoseRecommendation(basalAdjustment: dosingRecommendation?.basalAdjustment, bolusUnits: autoBolusCarbsAmount) + } else { + switch settings.automaticDosingStrategy { + case .tempBasalOnly: + if autoBolusCarbsAmount > 0 { + // we used automaticBolus before so now we need to switch over to the standard tempBasal recommendation + dosingRecommendation = getDosingRecommendation(dosingStrategy: .tempBasalOnly, glucose: glucose, predictedGlucose: predictedGlucose, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate) + } + default: + break + } } - + + if let dosingRecommendation = dosingRecommendation { self.logger.default("Recommending dose: %{public}@ at %{public}@", String(describing: dosingRecommendation), String(describing: startDate)) recommendedAutomaticDose = (recommendation: dosingRecommendation, date: startDate) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 6a4aadfcdd..1acf265911 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -601,6 +601,8 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { self.currentCOBDescription = nil } + // FIXME need to trigger an update of this value when the UserDefaults are changed + self.currentAutoBolusCarbsActive = self.deviceManager.loopManager.autoBolusCarbsEnabledAndActive self.tableView.beginUpdates() if let hudView = self.hudView { @@ -677,6 +679,8 @@ final class StatusTableViewController: LoopChartsTableViewController { // MARK: COB private var currentCOBDescription: String? + + private var currentAutoBolusCarbsActive = false // MARK: - Loop Status Section Data @@ -1003,7 +1007,15 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.setChartGenerator(generator: { [weak self] (frame) in return self?.statusCharts.cobChart(withFrame: frame)?.view }) - cell.setTitleLabelText(label: NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph")) + + let label = NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph"); + + // FIXME need to put in place a proper image indicating AutoBolusCarbs + if currentAutoBolusCarbsActive { + cell.setTitleLabelText(label: String(format: "%@ %@", label, "🔸")) + } else { + cell.setTitleLabelText(label: label) + } } self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) @@ -1165,6 +1177,15 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { cell.setSubtitleLabel(label: nil) } + + let label = NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph"); + + // FIXME need to put in place a proper image indicating AutoBolusCarbs + if currentAutoBolusCarbsActive { + cell.setTitleLabelText(label: String(format: "%@ %@", label, "🔸")) + } else { + cell.setTitleLabelText(label: label) + } } case .hud, .status, .alertWarning: break @@ -1233,7 +1254,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case .preMeal, .legacyWorkout: break default: - let vc = AddEditOverrideTableViewController(glucoseUnit: statusCharts.glucose.glucoseUnit) + let vc = AddEditOverrideTableViewController(glucoseUnit: statusCharts.glucose.glucoseUnit, autoBolusCarbsEnabled: UserDefaults.standard.autoBolusCarbsEnabled) vc.inputMode = .editOverride(override) vc.delegate = self show(vc, sender: tableView.cellForRow(at: indexPath)) @@ -1342,6 +1363,7 @@ final class StatusTableViewController: LoopChartsTableViewController { vc.glucoseUnit = statusCharts.glucose.glucoseUnit vc.overrideHistory = deviceManager.loopManager.overrideHistory.getEvents() vc.delegate = self + vc.autoBolusCarbsEnabled = UserDefaults.standard.autoBolusCarbsEnabled case let vc as PredictionTableViewController: vc.deviceManager = deviceManager default: diff --git a/Loop/Views/AutoBolusCarbsSelectionView.swift b/Loop/Views/AutoBolusCarbsSelectionView.swift new file mode 100644 index 0000000000..bea4685889 --- /dev/null +++ b/Loop/Views/AutoBolusCarbsSelectionView.swift @@ -0,0 +1,57 @@ +// +// AutoBolusCarbsSelectionView.swift +// Loop +// +// Created by Moti Nisenson-Ken on 23/12/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +public struct AutoBolusCarbsSelectionView: View { + @Binding var isAutoBolusCarbsEnabled: Bool + @Binding var autoBolusCarbsActiveByDefault: Bool + + public var body: some View { + + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Auto-Bolus Carbs", comment: "Title for auto-bolus carbs experiment description")) + .font(.headline) + .padding(.bottom, 20) + + Divider() + + Text(String(format: NSLocalizedString("Auto-Bolus Carbs (ABC) is a modification of how Loop corrects each loop cycle. When enabled and active, Loop will check how much insulin is needed to cover COB (similar to doing a manual bolus but without correcting for BG). If this amount is greater than the usual correction, a bolus for that amount will be given. Overrides can also be used to activate or deactivate. When ABC is enabled and active a %@ will appear beside Active Carbohydrates on the status screen.", comment: "Description of Auto-Bolus Carbs toggles."), "🔸")) + .foregroundColor(.secondary) + Divider() + + Toggle(NSLocalizedString("Auto-Bolus Carbs Enabled", comment: "Title for Auto-Bolus Carbs Enabled toggle"), isOn: $isAutoBolusCarbsEnabled) + .onChange(of: isAutoBolusCarbsEnabled) { newValue in + UserDefaults.standard.autoBolusCarbsEnabled = newValue + } + .padding(.top, 20) + + Toggle(NSLocalizedString("Auto-Bolus Carbs Active by Default", comment: "Title for Auto-Bolus Carbs Active by Default toggle"), isOn: $autoBolusCarbsActiveByDefault) + .onChange(of: autoBolusCarbsActiveByDefault) { newValue in + UserDefaults.standard.autoBolusCarbsActiveByDefault = newValue + } + .padding(.top, 20) + // in the future consider disabling unless available +// .disabled(isAutoBolusCarbsAvailable) + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } + +} + +struct AutoBolusCarbsSelectionView_Previews: PreviewProvider { + static var previews: some View { + AutoBolusCarbsSelectionView(isAutoBolusCarbsEnabled: .constant(true), autoBolusCarbsActiveByDefault: .constant(false)) + } +} diff --git a/Loop/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift index e81dccdabb..04b95a6cd7 100644 --- a/Loop/Views/ManualEntryDoseView.swift +++ b/Loop/Views/ManualEntryDoseView.swift @@ -164,7 +164,7 @@ struct ManualEntryDoseView: View { private var insulinTypePicker: some View { ExpandablePicker( with: viewModel.insulinTypePickerOptions, - selectedValue: $viewModel.selectedInsulinType, + selectedValue: $viewModel.selectedInsulinType, label: NSLocalizedString("Insulin Type", comment: "Insulin type label") ) } diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 54bd2c71a0..bbdeca71d3 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -41,6 +41,9 @@ public struct ExperimentRow: View { public struct ExperimentsSettingsView: View { @State private var isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled @State private var isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + @State private var isAutoBolusCarbsAvailable = UserDefaults.standard.autoBolusCarbsEnabled + @State private var autoBolusCarbsActiveByDefault = UserDefaults.standard.autoBolusCarbsActiveByDefault + var automaticDosingStrategy: AutomaticDosingStrategy public var body: some View { @@ -70,6 +73,11 @@ public struct ExperimentsSettingsView: View { name: NSLocalizedString("Integral Retrospective Correction", comment: "Title of integral retrospective correction experiment"), enabled: isIntegralRetrospectiveCorrectionEnabled) } + NavigationLink(destination: AutoBolusCarbsSelectionView(isAutoBolusCarbsEnabled: $isAutoBolusCarbsAvailable, autoBolusCarbsActiveByDefault: $autoBolusCarbsActiveByDefault)) { + ExperimentRow( + name: NSLocalizedString("Auto-Bolus Carbs", comment: "Title of auto-bolus carbs experiment"), + enabled: isAutoBolusCarbsAvailable) + } Spacer() } .padding() @@ -83,6 +91,8 @@ extension UserDefaults { private enum Key: String { case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" + case AutoBolusCarbsEnabled = "com.loopkit.algorithmExperiments.autoBolusCarbsEnabled" + case AutoBolusCarbsActiveByDefault = "com.loopkit.algorithmExperiments.autoBolusCarbsActiveByDefault" } var glucoseBasedApplicationFactorEnabled: Bool { @@ -102,5 +112,22 @@ extension UserDefaults { set(newValue, forKey: Key.IntegralRetrospectiveCorrectionEnabled.rawValue) } } + + var autoBolusCarbsEnabled: Bool { + get { + bool(forKey: Key.AutoBolusCarbsEnabled.rawValue) as Bool + } + set { + set(newValue, forKey: Key.AutoBolusCarbsEnabled.rawValue) + } + } + var autoBolusCarbsActiveByDefault: Bool { + get { + bool(forKey: Key.AutoBolusCarbsActiveByDefault.rawValue) as Bool + } + set { + set(newValue, forKey: Key.AutoBolusCarbsActiveByDefault.rawValue) + } + } } diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index e0769066a8..570ab1a8c9 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -213,6 +213,102 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(4.63, recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) } + func testHighAndStableWithAutoBolusCarbsForAutoBolusResult() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + + // 0.4*(cobCorrection + bgCorrection) will be the dosage compared against + // for this test we want cobCorrection < 0.4*(cobCorrection + bgCorrection) + // in other words we need cobCorrection < 2/3 * bgCorrection + let expectedCobCorrectionAmount = 0.6 * expectedBgCorrectionAmount + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, dosingStrategy: .automaticBolus, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue))]}, autoBolusCarbs: true) + + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBolus: Double? + self.loopDataManager.getLoopState { _, state in + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(0.4 * (expectedCobCorrectionAmount + expectedBgCorrectionAmount), recommendedBolus!, accuracy: defaultAccuracy) + } + + func testHighAndStableWithAutoBolusCarbsForTempBasalResult() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + + // (cobCorrection + bgCorrection)/6 will be the dosage compared against + // for this test we want cobCorrection < (cobCorrection + bgCorrection)/6 + // in other words we need cobCorrection < bgCorrection / 5 + let expectedCobCorrectionAmount = expectedBgCorrectionAmount / 6 + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, maxBasalRate: 7.0, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue))]}, autoBolusCarbs: true) + + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + // unadjusted basal rate is 1.0 + XCTAssertEqual(1.0 + 2*(expectedBgCorrectionAmount + expectedCobCorrectionAmount), recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func testHighAndStableWithAutoBolusCarbsForCarbBolusResult() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + + // (cobCorrection + bgCorrection)/6 will be the dosage compared against + // for this test we want cobCorrection > (cobCorrection + bgCorrection)/6 + // in other words we need cobCorrection > bgCorrection / 5 + let expectedCobCorrectionAmount = expectedBgCorrectionAmount / 4 + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue))]}, autoBolusCarbs: true) + + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBolus: Double? + self.loopDataManager.getLoopState { _, state in + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(expectedCobCorrectionAmount, recommendedBolus!, accuracy: defaultAccuracy) + } + func testHighAndFalling() { setUp(for: .highAndFalling) let predictedGlucoseOutput = loadLocalDateGlucoseEffect("high_and_falling_predicted_glucose") diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index fbff92e1c3..fcaa1d50ad 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -133,7 +133,8 @@ class LoopDataManagerTests: XCTestCase { suspendThresholdValue: Double? = nil, // note that carbHistory is independent from carb effects; // one can use dummy replacement carb entry to force recalculation when getting a manual bolus recommendation - carbHistorySupplier: ((Date) -> [StoredCarbEntry]?)? = nil + carbHistorySupplier: ((Date) -> [StoredCarbEntry]?)? = nil, + autoBolusCarbs: Bool = false ) { let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") @@ -201,10 +202,17 @@ class LoopDataManagerTests: XCTestCase { automaticDosingStatus: automaticDosingStatus, trustedTimeOffset: { 0 } ) + + if autoBolusCarbs { + UserDefaults.standard.autoBolusCarbsEnabled = true + UserDefaults.standard.autoBolusCarbsActiveByDefault = true + } } override func tearDownWithError() throws { loopDataManager = nil + UserDefaults.standard.autoBolusCarbsEnabled = false + UserDefaults.standard.autoBolusCarbsActiveByDefault = false } } From 621973d01489f17129f4c580b9b0450369e06b38 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 26 Dec 2024 13:09:06 +0200 Subject: [PATCH 34/43] Add missing tests and fix beneath correction range --- Loop/Managers/LoopDataManager.swift | 2 +- .../Managers/LoopDataManagerDosingTests.swift | 53 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index a285abb3c9..d6f8dcd1f8 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -2076,7 +2076,7 @@ extension LoopDataManager { dosingRecommendation = getDosingRecommendation(dosingStrategy: dosingStrategty, glucose: glucose, predictedGlucose: predictedGlucose, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate, bolusApplicationFactor: bolusApplicationFactor) - if dosingRecommendation?.bolusUnits != nil, autoBolusCarbsAmount > dosingRecommendation!.bolusUnits! { + if autoBolusCarbsAmount > dosingRecommendation?.bolusUnits ?? 0.0 { logger.info("Recommendation is to auto-bolus carbs as it will give more insulin") dosingRecommendation = AutomaticDoseRecommendation(basalAdjustment: dosingRecommendation?.basalAdjustment, bolusUnits: autoBolusCarbsAmount) } else { diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index 570ab1a8c9..7141020cfc 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -213,6 +213,25 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(4.63, recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) } + func testBeneathRangeForAutoBolusCarbs() { + // this scenario starts beneath the correction range + setUp(for: .highAndRisingWithCOB, correctionRanges: correctionRange(135.0), autoBolusCarbs: true) + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBolus: Double? + var manualBolusRecommendation: ManualBolusRecommendation? + self.loopDataManager.getLoopState { _, state in + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + manualBolusRecommendation = try? state.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses) + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(min(manualBolusRecommendation!.amount, manualBolusRecommendation!.bolusBreakdown!.cobCorrectionAmount), recommendedBolus!, accuracy: defaultAccuracy) + } + func testHighAndStableWithAutoBolusCarbsForAutoBolusResult() { // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) @@ -245,6 +264,38 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(0.4 * (expectedCobCorrectionAmount + expectedBgCorrectionAmount), recommendedBolus!, accuracy: defaultAccuracy) } + func testHighAndStableWithAutoBolusCarbsForABCResultVsAutoBolus() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + + // 0.4*(cobCorrection + bgCorrection) will be the dosage compared against + // for this test we want cobCorrection < 0.4*(cobCorrection + bgCorrection) + // in other words we need cobCorrection < 2/3 * bgCorrection + let expectedCobCorrectionAmount = 0.7 * expectedBgCorrectionAmount + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, dosingStrategy: .automaticBolus, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue))]}, autoBolusCarbs: true) + + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBolus: Double? + self.loopDataManager.getLoopState { _, state in + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(expectedCobCorrectionAmount, recommendedBolus!, accuracy: defaultAccuracy) + } + func testHighAndStableWithAutoBolusCarbsForTempBasalResult() { // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) @@ -277,7 +328,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(1.0 + 2*(expectedBgCorrectionAmount + expectedCobCorrectionAmount), recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) } - func testHighAndStableWithAutoBolusCarbsForCarbBolusResult() { + func testHighAndStableWithAutoBolusCarbsForABCResultvsTempBasal() { // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) let isf = 45.0 From b6dff3fa25390330dfeb158071fdbda597da6b0b Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sun, 5 Jan 2025 11:22:54 +0200 Subject: [PATCH 35/43] Support automaticDosingIOBLimit for ABC --- Loop/Managers/LoopDataManager.swift | 18 ++++++++--------- .../Managers/LoopDataManagerDosingTests.swift | 20 ++++++++++++++++++- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index d6f8dcd1f8..d798b09c03 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1847,7 +1847,7 @@ extension LoopDataManager { suspendInsulinDeliveryEffect = suspendDoses.glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: insulinSensitivity).filterDateRange(startSuspend, endSuspend) } - fileprivate func getDosingRecommendation(dosingStrategy: AutomaticDosingStrategy, glucose: any GlucoseSampleValue, predictedGlucose: [PredictedGlucoseValue], glucoseTargetRange: GlucoseRangeSchedule?, insulinSensitivity: InsulinSensitivitySchedule?, basalRateSchedule: BasalRateSchedule?, startDate: Date, bolusApplicationFactor: Double? = nil) -> AutomaticDoseRecommendation? { + fileprivate func getDosingRecommendation(dosingStrategy: AutomaticDosingStrategy, glucose: any GlucoseSampleValue, predictedGlucose: [PredictedGlucoseValue], iobHeadroom: Double, glucoseTargetRange: GlucoseRangeSchedule?, insulinSensitivity: InsulinSensitivitySchedule?, basalRateSchedule: BasalRateSchedule?, startDate: Date, bolusApplicationFactor: Double? = nil) -> AutomaticDoseRecommendation? { let rateRounder = { (_ rate: Double) in return self.delegate?.roundBasalRate(unitsPerHour: rate) ?? rate @@ -1863,10 +1863,6 @@ extension LoopDataManager { let maxBolus = settings.maximumBolus! let maxBasal = settings.maximumBasalRatePerHour! - - // automaticDosingIOBLimit calculated from the user entered maxBolus - let automaticDosingIOBLimit = maxBolus * 2.0 - let iobHeadroom = automaticDosingIOBLimit - self.insulinOnBoard!.value switch dosingStrategy { case .automaticBolus: @@ -2044,11 +2040,15 @@ extension LoopDataManager { var autoBolusCarbsAmount = -Double.infinity + // automaticDosingIOBLimit calculated from the user entered maxBolus + let automaticDosingIOBLimit = maxBolus! * 2.0 + let iobHeadroom = automaticDosingIOBLimit - self.insulinOnBoard!.value + if autoBolusCarbsEnabledAndActive { do { if let bolusRecommendation = try recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses), let breakdown = bolusRecommendation.bolusBreakdown { - let amount = min(bolusRecommendation.amount, volumeRounder()(breakdown.cobCorrectionAmount)) + let amount = min(bolusRecommendation.amount, volumeRounder()(min(iobHeadroom, breakdown.cobCorrectionAmount))) if amount > 0 { autoBolusCarbsAmount = amount @@ -2073,8 +2073,8 @@ extension LoopDataManager { } let dosingStrategty = autoBolusCarbsAmount > 0 ? .automaticBolus : settings.automaticDosingStrategy - - dosingRecommendation = getDosingRecommendation(dosingStrategy: dosingStrategty, glucose: glucose, predictedGlucose: predictedGlucose, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate, bolusApplicationFactor: bolusApplicationFactor) + + dosingRecommendation = getDosingRecommendation(dosingStrategy: dosingStrategty, glucose: glucose, predictedGlucose: predictedGlucose, iobHeadroom: iobHeadroom, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate, bolusApplicationFactor: bolusApplicationFactor) if autoBolusCarbsAmount > dosingRecommendation?.bolusUnits ?? 0.0 { logger.info("Recommendation is to auto-bolus carbs as it will give more insulin") @@ -2084,7 +2084,7 @@ extension LoopDataManager { case .tempBasalOnly: if autoBolusCarbsAmount > 0 { // we used automaticBolus before so now we need to switch over to the standard tempBasal recommendation - dosingRecommendation = getDosingRecommendation(dosingStrategy: .tempBasalOnly, glucose: glucose, predictedGlucose: predictedGlucose, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate) + dosingRecommendation = getDosingRecommendation(dosingStrategy: .tempBasalOnly, glucose: glucose, predictedGlucose: predictedGlucose, iobHeadroom: iobHeadroom, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate) } default: break diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index 7141020cfc..d6ca0bc7d3 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -215,7 +215,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { func testBeneathRangeForAutoBolusCarbs() { // this scenario starts beneath the correction range - setUp(for: .highAndRisingWithCOB, correctionRanges: correctionRange(135.0), autoBolusCarbs: true) + setUp(for: .highAndRisingWithCOB, correctionRanges: correctionRange(150.0), autoBolusCarbs: true) let updateGroup = DispatchGroup() updateGroup.enter() var recommendedBolus: Double? @@ -232,6 +232,24 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(min(manualBolusRecommendation!.amount, manualBolusRecommendation!.bolusBreakdown!.cobCorrectionAmount), recommendedBolus!, accuracy: defaultAccuracy) } + func testBeneathRangeForAutoBolusCarbsAutoIobMax() { + // this scenario starts beneath the correction range + // autoIobMax = 2*maxBolus and mockDoseStore has IOB of 9.5 - so headroom is 0.1 + setUp(for: .highAndRisingWithCOB, maxBolus: 4.8, correctionRanges: correctionRange(150.0), autoBolusCarbs: true) + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBolus: Double? + self.loopDataManager.getLoopState { _, state in + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(0.1, recommendedBolus!, accuracy: defaultAccuracy) + } + func testHighAndStableWithAutoBolusCarbsForAutoBolusResult() { // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) From e1c2382dc0f22cc4c9c75e8b8b863716546853e3 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Mon, 6 Jan 2025 21:18:54 +0200 Subject: [PATCH 36/43] Force reload from UserDefaults --- .../Views/SettingsView+algorithmExperimentsSection.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 54bd2c71a0..7f66560c43 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -39,8 +39,8 @@ public struct ExperimentRow: View { } public struct ExperimentsSettingsView: View { - @State private var isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - @State private var isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + @State private var isGlucoseBasedApplicationFactorEnabled = false + @State private var isIntegralRetrospectiveCorrectionEnabled = false var automaticDosingStrategy: AutomaticDosingStrategy public var body: some View { @@ -75,6 +75,11 @@ public struct ExperimentsSettingsView: View { .padding() } .navigationBarTitleDisplayMode(.inline) + .onAppear { + // force reloading of data from UserDefaults + isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + } } } From 4eb434dcdcdb71091758aa64f4169552a28381a9 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 7 Jan 2025 09:26:56 +0200 Subject: [PATCH 37/43] Use AppStorage instead --- .../GlucoseBasedApplicationFactorSelectionView.swift | 3 --- ...IntegralRetrospectiveCorrectionSelectionView.swift | 3 --- .../SettingsView+algorithmExperimentsSection.swift | 11 +++-------- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift b/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift index 09a68d58c1..0d88e65dfa 100644 --- a/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift +++ b/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift @@ -44,9 +44,6 @@ public struct GlucoseBasedApplicationFactorSelectionView: View { } } .padding() - .onChange(of: isGlucoseBasedApplicationFactorEnabled) { newValue in - UserDefaults.standard.glucoseBasedApplicationFactorEnabled = newValue - } } .navigationBarTitleDisplayMode(.inline) } diff --git a/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift b/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift index 9af84adf4f..8556e4fed6 100644 --- a/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift +++ b/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift @@ -27,9 +27,6 @@ public struct IntegralRetrospectiveCorrectionSelectionView: View { Divider() Toggle(NSLocalizedString("Enable Integral Retrospective Correction", comment: "Title for Integral Retrospective Correction toggle"), isOn: $isIntegralRetrospectiveCorrectionEnabled) - .onChange(of: isIntegralRetrospectiveCorrectionEnabled) { newValue in - UserDefaults.standard.integralRetrospectiveCorrectionEnabled = newValue - } .padding(.top, 20) } .padding() diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 7f66560c43..795fbdd860 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -39,8 +39,8 @@ public struct ExperimentRow: View { } public struct ExperimentsSettingsView: View { - @State private var isGlucoseBasedApplicationFactorEnabled = false - @State private var isIntegralRetrospectiveCorrectionEnabled = false + @AppStorage(UserDefaults.Key.GlucoseBasedApplicationFactorEnabled.rawValue) private var isGlucoseBasedApplicationFactorEnabled = false + @AppStorage(UserDefaults.Key.IntegralRetrospectiveCorrectionEnabled.rawValue) private var isIntegralRetrospectiveCorrectionEnabled = false var automaticDosingStrategy: AutomaticDosingStrategy public var body: some View { @@ -75,17 +75,12 @@ public struct ExperimentsSettingsView: View { .padding() } .navigationBarTitleDisplayMode(.inline) - .onAppear { - // force reloading of data from UserDefaults - isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled - } } } extension UserDefaults { - private enum Key: String { + fileprivate enum Key: String { case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" } From 9e4c32a21e318adb484614c750cd168a8806fe48 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 7 Jan 2025 19:10:17 +0200 Subject: [PATCH 38/43] Fix potential rounding issues that can occur with temp basals --- Loop/Managers/LoopDataManager.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index d798b09c03..d3da304e78 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1847,7 +1847,7 @@ extension LoopDataManager { suspendInsulinDeliveryEffect = suspendDoses.glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: insulinSensitivity).filterDateRange(startSuspend, endSuspend) } - fileprivate func getDosingRecommendation(dosingStrategy: AutomaticDosingStrategy, glucose: any GlucoseSampleValue, predictedGlucose: [PredictedGlucoseValue], iobHeadroom: Double, glucoseTargetRange: GlucoseRangeSchedule?, insulinSensitivity: InsulinSensitivitySchedule?, basalRateSchedule: BasalRateSchedule?, startDate: Date, bolusApplicationFactor: Double? = nil) -> AutomaticDoseRecommendation? { + fileprivate func getDosingRecommendation(dosingStrategy: AutomaticDosingStrategy, glucose: any GlucoseSampleValue, predictedGlucose: [PredictedGlucoseValue], iobHeadroom: Double, glucoseTargetRange: GlucoseRangeSchedule?, insulinSensitivity: InsulinSensitivitySchedule?, basalRateSchedule: BasalRateSchedule?, startDate: Date, bolusApplicationFactor: Double? = nil, volumeRounder: ((Double) -> Double)? = nil) -> AutomaticDoseRecommendation? { let rateRounder = { (_ rate: Double) in return self.delegate?.roundBasalRate(unitsPerHour: rate) ?? rate @@ -1900,7 +1900,7 @@ extension LoopDataManager { maxAutomaticBolus: maxAutomaticBolus, partialApplicationFactor: effectiveBolusApplicationFactor * self.timeBasedDoseApplicationFactor, lastTempBasal: lastTempBasal, - volumeRounder: volumeRounder(), + volumeRounder: volumeRounder ?? self.volumeRounder(), rateRounder: rateRounder, isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true ) @@ -2060,21 +2060,27 @@ extension LoopDataManager { } let bolusApplicationFactor: Double? + let volumeRounder: ((Double) -> Double)? if autoBolusCarbsAmount > 0 { switch settings.automaticDosingStrategy { - case .automaticBolus: bolusApplicationFactor = nil + case .automaticBolus: + bolusApplicationFactor = nil + volumeRounder = nil case .tempBasalOnly: // instead of temp basal, compare with automaticBolus with an adjusted bolusApplicationFactor reflecting 5 minutes of temp basal + // we avoid rounding this value so we can accurately know whether the temp basal would give more or less insulin over 5 minutes bolusApplicationFactor = 5.0/30.0 + volumeRounder = {$0} } } else { bolusApplicationFactor = nil + volumeRounder = nil } let dosingStrategty = autoBolusCarbsAmount > 0 ? .automaticBolus : settings.automaticDosingStrategy - dosingRecommendation = getDosingRecommendation(dosingStrategy: dosingStrategty, glucose: glucose, predictedGlucose: predictedGlucose, iobHeadroom: iobHeadroom, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate, bolusApplicationFactor: bolusApplicationFactor) + dosingRecommendation = getDosingRecommendation(dosingStrategy: dosingStrategty, glucose: glucose, predictedGlucose: predictedGlucose, iobHeadroom: iobHeadroom, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate, bolusApplicationFactor: bolusApplicationFactor, volumeRounder: volumeRounder) if autoBolusCarbsAmount > dosingRecommendation?.bolusUnits ?? 0.0 { logger.info("Recommendation is to auto-bolus carbs as it will give more insulin") From 1ee8993699e034f46350935be41e94215a782743 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 7 Jan 2025 21:19:50 +0200 Subject: [PATCH 39/43] Refactor so to optimize ABC Avoid calculating BG correction for ABC as it's not needed. --- Loop/Managers/LoopDataManager.swift | 62 ++++++++++++++++++----------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index d3da304e78..1afc646da9 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1469,8 +1469,34 @@ extension LoopDataManager { return try recommendManualBolus(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) } + + fileprivate func getTotalCobCorrectionAmount(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry? = nil, replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil, considerPositiveVelocityAndRC: Bool, pendingInsulin: Double) throws -> Double? { + + let shouldIncludePendingInsulin = pendingInsulin > 0 + + let carbAndInsulinPrediction = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + + guard !carbAndInsulinPrediction.isEmpty else { + return nil + } + + let carbOnlyPrediction = try predictGlucose(using: [.carbs], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + + guard !carbOnlyPrediction.isEmpty else { + return nil + } + + // cobCorrection includes insulin when its effects are to reduce BG, but doesn't include it if it raises it (e.g., negative IOB) + let cobPrediction = carbAndInsulinPrediction.last!.quantity < carbOnlyPrediction.last!.quantity ? carbAndInsulinPrediction : carbOnlyPrediction + + let flatCobPrediction = cobPrediction.map{PredictedGlucoseValue(startDate: $0.startDate, quantity: cobPrediction.last!.quantity)} + + return try recommendBolusValidatingDataRecency(forPrediction: flatCobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown)?.amount + } + + /// - Throws: LoopError.missingDataError - fileprivate func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { + fileprivate func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry? = nil, replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil, considerPositiveVelocityAndRC: Bool, pendingInsulin: Double, provideBreakdown: Bool) throws -> ManualBolusRecommendation? { guard lastRequestedBolus == nil else { // Don't recommend changes if a bolus was just requested. // Sending additional pump commands is not going to be @@ -1478,7 +1504,6 @@ extension LoopDataManager { return nil } - let pendingInsulin = try getPendingInsulin() let shouldIncludePendingInsulin = pendingInsulin > 0 let prediction = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) let recommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) @@ -1486,29 +1511,16 @@ extension LoopDataManager { guard recommendation != nil else { return nil } - - guard !prediction.isEmpty else { - return recommendation // unable to differentiate between correction amounts, - } - - let carbAndInsulinPrediction = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) - guard !carbAndInsulinPrediction.isEmpty else { - return recommendation // unable to differentiate between correction amounts + guard provideBreakdown else { + return recommendation } - - let carbOnlyPrediction = try predictGlucose(using: [.carbs], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) - - guard !carbOnlyPrediction.isEmpty else { - return recommendation // unable to differentiate between correction amounts + + guard !prediction.isEmpty else { + return recommendation // unable to differentiate between correction amounts, } - // cobCorrection includes insulin when its effects are to reduce BG, but doesn't include it if it raises it (e.g., negative IOB) - let cobPrediction = carbAndInsulinPrediction.last!.quantity < carbOnlyPrediction.last!.quantity ? carbAndInsulinPrediction : carbOnlyPrediction - - let flatCobPrediction = cobPrediction.map{PredictedGlucoseValue(startDate: $0.startDate, quantity: cobPrediction.last!.quantity)} - - guard let totalCobAmount = try recommendBolusValidatingDataRecency(forPrediction: flatCobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown)?.amount else { + guard let totalCobAmount = try getTotalCobCorrectionAmount(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC, pendingInsulin: pendingInsulin) else { return recommendation // unable to differentiate between correction amounts } @@ -2046,9 +2058,11 @@ extension LoopDataManager { if autoBolusCarbsEnabledAndActive { do { - if let bolusRecommendation = try recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses), let breakdown = bolusRecommendation.bolusBreakdown { + let posVelocityAndRC = FeatureFlags.usePositiveMomentumAndRCForManualBoluses + let pendingInsulin = try getPendingInsulin() + if let recommendation = try recommendBolus(considerPositiveVelocityAndRC: posVelocityAndRC, pendingInsulin: pendingInsulin, provideBreakdown: false), let totalCobAmount = try getTotalCobCorrectionAmount(considerPositiveVelocityAndRC: posVelocityAndRC, pendingInsulin: pendingInsulin) { - let amount = min(bolusRecommendation.amount, volumeRounder()(min(iobHeadroom, breakdown.cobCorrectionAmount))) + let amount = min(recommendation.amount, volumeRounder()(min(iobHeadroom, totalCobAmount))) if amount > 0 { autoBolusCarbsAmount = amount @@ -2345,7 +2359,7 @@ extension LoopDataManager { func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) + return try loopDataManager.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC, pendingInsulin: loopDataManager.getPendingInsulin(), provideBreakdown: true) } func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { From a4d9fcb00b2e8a8227339707d477fa7ddb783a77 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 21 Jan 2025 19:49:02 +0200 Subject: [PATCH 40/43] Update to reflect appstorage use --- Loop.xcodeproj/project.pbxproj | 2 +- Loop/Views/AutoBolusCarbsSelectionView.swift | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 5afe5cc9e7..3cef11f6ce 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -2246,7 +2246,6 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( - 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */, 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */, B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, @@ -2279,6 +2278,7 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */, ); path = Views; sourceTree = ""; diff --git a/Loop/Views/AutoBolusCarbsSelectionView.swift b/Loop/Views/AutoBolusCarbsSelectionView.swift index bea4685889..01c4a05fa9 100644 --- a/Loop/Views/AutoBolusCarbsSelectionView.swift +++ b/Loop/Views/AutoBolusCarbsSelectionView.swift @@ -30,18 +30,11 @@ public struct AutoBolusCarbsSelectionView: View { Divider() Toggle(NSLocalizedString("Auto-Bolus Carbs Enabled", comment: "Title for Auto-Bolus Carbs Enabled toggle"), isOn: $isAutoBolusCarbsEnabled) - .onChange(of: isAutoBolusCarbsEnabled) { newValue in - UserDefaults.standard.autoBolusCarbsEnabled = newValue - } .padding(.top, 20) Toggle(NSLocalizedString("Auto-Bolus Carbs Active by Default", comment: "Title for Auto-Bolus Carbs Active by Default toggle"), isOn: $autoBolusCarbsActiveByDefault) - .onChange(of: autoBolusCarbsActiveByDefault) { newValue in - UserDefaults.standard.autoBolusCarbsActiveByDefault = newValue - } .padding(.top, 20) - // in the future consider disabling unless available -// .disabled(isAutoBolusCarbsAvailable) + .disabled(!isAutoBolusCarbsEnabled) } .padding() } From 9ba803e5ef031318c7a3e13bf9b07be45f5a0cc7 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 21 Jan 2025 20:16:29 +0200 Subject: [PATCH 41/43] Under Development Section + Exclusions --- Common/FeatureFlags.swift | 14 --- Loop.xcodeproj/project.pbxproj | 8 ++ Loop/View Models/BolusEntryViewModel.swift | 73 +++++++++------ Loop/Views/BolusEntryView.swift | 40 +++++++-- Loop/Views/CarbBolusSelectionView.swift | 90 +++++++++++++++++++ ...ingsView+algorithmExperimentsSection.swift | 16 ++-- ...SettingsView+underDevelopmentSection.swift | 50 +++++++++++ Loop/Views/SettingsView.swift | 3 + 8 files changed, 239 insertions(+), 55 deletions(-) create mode 100644 Loop/Views/CarbBolusSelectionView.swift create mode 100644 Loop/Views/SettingsView+underDevelopmentSection.swift diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 5d0ae4acd8..a24ef1c453 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -39,8 +39,6 @@ struct FeatureFlagConfiguration: Decodable { let profileExpirationSettingsViewEnabled: Bool let missedMealNotifications: Bool let allowAlgorithmExperiments: Bool - let correctionWithCarbBolus: Bool - let bgCorrectionWithCarbBolus: Bool fileprivate init() { // Swift compiler config is inverse, since the default state is enabled. @@ -233,18 +231,6 @@ struct FeatureFlagConfiguration: Decodable { #else self.allowAlgorithmExperiments = false #endif - - #if DISABLE_CORRECTION_WITH_CARB_BOLUS - self.correctionWithCarbBolus = false - #else - self.correctionWithCarbBolus = true - #endif - - #if DISABLE_BG_CORRECTION_WITH_CARB_BOLUS - self.bgCorrectionWithCarbBolus = false - #else - self.bgCorrectionWithCarbBolus = true - #endif } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 3cef11f6ce..f01090fca7 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 12F04F012D19791B002B2121 /* AutoBolusCarbsSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */; }; + 12F4D9C82D4017D400FAEF5F /* SettingsView+underDevelopmentSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F4D9C72D4017D400FAEF5F /* SettingsView+underDevelopmentSection.swift */; }; + 12F4D9C92D4017D400FAEF5F /* CarbBolusSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F4D9C62D4017D400FAEF5F /* CarbBolusSelectionView.swift */; }; 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; }; @@ -745,6 +747,8 @@ /* Begin PBXFileReference section */ 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoBolusCarbsSelectionView.swift; sourceTree = ""; }; + 12F4D9C62D4017D400FAEF5F /* CarbBolusSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbBolusSelectionView.swift; sourceTree = ""; }; + 12F4D9C72D4017D400FAEF5F /* SettingsView+underDevelopmentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+underDevelopmentSection.swift"; sourceTree = ""; }; 142CB7582A60BF2E0075748A /* EditMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditMode.swift; sourceTree = ""; }; 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsView.swift; sourceTree = ""; }; 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodViewModel.swift; sourceTree = ""; }; @@ -2272,6 +2276,7 @@ 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, + 12F4D9C72D4017D400FAEF5F /* SettingsView+underDevelopmentSection.swift */, C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */, 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */, @@ -2279,6 +2284,7 @@ DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */, + 12F4D9C62D4017D400FAEF5F /* CarbBolusSelectionView.swift */, ); path = Views; sourceTree = ""; @@ -3831,6 +3837,8 @@ 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, + 12F4D9C82D4017D400FAEF5F /* SettingsView+underDevelopmentSection.swift in Sources */, + 12F4D9C92D4017D400FAEF5F /* CarbBolusSelectionView.swift in Sources */, DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 10d719e33f..0a371ee706 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -92,6 +92,8 @@ final class BolusEntryViewModel: ObservableObject { } } } + + final let MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY = 0.005 // MARK: - State @@ -120,13 +122,11 @@ final class BolusEntryViewModel: ObservableObject { } @Published var cobCorrectionBolus: HKQuantity? @Published var cobCorrectionBolusIncluded = true - @Published var userChangedCobCorrectionBolusIncluded = false var cobCorrectionBolusAmount: Double? { cobCorrectionBolus?.doubleValue(for: .internationalUnit()) } @Published var bgCorrectionBolus: HKQuantity? @Published var bgCorrectionBolusIncluded = true - @Published var userChangedBgCorrectionBolusIncluded = false var bgCorrectionBolusAmount: Double? { bgCorrectionBolus?.doubleValue(for: .internationalUnit()) } @@ -140,6 +140,11 @@ final class BolusEntryViewModel: ObservableObject { var safetyLimitBolusAmount: Double? { safetyLimitBolus?.doubleValue(for: .internationalUnit()) } + @Published var exclusionsBolus: HKQuantity? + @Published var exclusionsBolusIncluded = true + var exclusionsBolusAmount: Double? { + exclusionsBolus?.doubleValue(for: .internationalUnit()) + } @Published var recommendedBolus: HKQuantity? var recommendedBolusAmount: Double? { recommendedBolus?.doubleValue(for: .internationalUnit()) @@ -313,6 +318,15 @@ final class BolusEntryViewModel: ObservableObject { } } .store(in: &cancellables) + $exclusionsBolusIncluded + .sink { [weak self] newValue in + if self?.exclusionsBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } + } + } + .store(in: &cancellables) } private func observeEnteredManualGlucoseChanges() { @@ -748,12 +762,11 @@ final class BolusEntryViewModel: ObservableObject { var recommendation: ManualBolusRecommendation? let carbBolus: HKQuantity? let cobCorrectionBolus: HKQuantity? - var cobCorrectionBolusIncluded: Bool let bgCorrectionBolus: HKQuantity? - var bgCorrectionBolusIncluded: Bool let recommendedBolus: HKQuantity? var maxExcessBolus: HKQuantity? = nil var safetyLimitBolus: HKQuantity? = nil + var exclusionsBolus: HKQuantity? = nil let notice: Notice? do { recommendation = try recommendationSupplier() @@ -764,46 +777,50 @@ final class BolusEntryViewModel: ObservableObject { if let recommendation = recommendation { var totalRecommendation = 0.0 - if let carbsAmount = recommendation.bolusBreakdown?.carbsAmount { + let breakdown = recommendation.bolusBreakdown + + if let carbsAmount = breakdown?.carbsAmount, abs(carbsAmount) >= MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY{ carbBolus = HKQuantity(unit: .internationalUnit(), doubleValue: carbsAmount) totalRecommendation += carbBolusIncluded ? carbsAmount : 0 } else { carbBolus = nil } - if !FeatureFlags.correctionWithCarbBolus, potentialCarbEntry != nil, !userChangedBgCorrectionBolusIncluded, !userChangedCobCorrectionBolusIncluded { - let cobCorrectionAmount = recommendation.bolusBreakdown?.cobCorrectionAmount ?? 0.0 - let bgCorrectionAmount = recommendation.bolusBreakdown?.bgCorrectionAmount ?? 0.0 + if potentialCarbEntry != nil, UserDefaults.standard.carbBolusCarbEntryExcluded || UserDefaults.standard.carbBolusCobCorrectionExcluded || UserDefaults.standard.carbBolusBgCorrectionExcluded { + + var exclusionsAmount = -(recommendation.missingAmount ?? 0.0) - if cobCorrectionAmount + bgCorrectionAmount > 0 { - cobCorrectionBolusIncluded = false - bgCorrectionBolusIncluded = false + if UserDefaults.standard.carbBolusCarbEntryExcluded { + exclusionsAmount += breakdown?.carbsAmount ?? 0.0 + } + if UserDefaults.standard.carbBolusCobCorrectionExcluded { + exclusionsAmount += breakdown?.cobCorrectionAmount ?? 0.0 + } + if UserDefaults.standard.carbBolusBgCorrectionExcluded { + exclusionsAmount += breakdown?.bgCorrectionAmount ?? 0.0 } - } - - if !FeatureFlags.bgCorrectionWithCarbBolus, potentialCarbEntry != nil, !userChangedBgCorrectionBolusIncluded { - let bgCorrectionAmount = recommendation.bolusBreakdown?.bgCorrectionAmount ?? 0.0 - if bgCorrectionAmount > 0 { - bgCorrectionBolusIncluded = false + if exclusionsAmount >= MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY { + exclusionsBolus = HKQuantity(unit: .internationalUnit(), doubleValue: exclusionsAmount) + totalRecommendation -= exclusionsBolusIncluded ? exclusionsAmount : 0 } } - - if let cobCorrectionAmount = recommendation.bolusBreakdown?.cobCorrectionAmount { + + if let cobCorrectionAmount = breakdown?.cobCorrectionAmount, abs(cobCorrectionAmount) >= MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY { cobCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: cobCorrectionAmount) totalRecommendation += cobCorrectionBolusIncluded ? cobCorrectionAmount : 0 } else { cobCorrectionBolus = nil } - if let bgCorrectionAmount = recommendation.bolusBreakdown?.bgCorrectionAmount { + if let bgCorrectionAmount = breakdown?.bgCorrectionAmount, abs(bgCorrectionAmount) >= MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY { bgCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: bgCorrectionAmount) totalRecommendation += bgCorrectionBolusIncluded ? bgCorrectionAmount : 0 } else { bgCorrectionBolus = nil } - if let missingAmount = recommendation.missingAmount, missingAmount > 0 { + if let missingAmount = recommendation.missingAmount, missingAmount >= MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY { if let maxBolus = maximumBolus?.doubleValue(for: .internationalUnit()) { if recommendation.amount >= maxBolus { // while it is technically possible for some safetyLimitBolus too, this isn't identifiable, nor parituclarly relevant @@ -828,7 +845,7 @@ final class BolusEntryViewModel: ObservableObject { } } - if carbBolusIncluded, cobCorrectionBolusIncluded, bgCorrectionBolusIncluded, maxExcessBolusIncluded, safetyLimitBolusIncluded { + if carbBolusIncluded, cobCorrectionBolusIncluded, bgCorrectionBolusIncluded, maxExcessBolusIncluded, safetyLimitBolusIncluded, !exclusionsBolusIncluded || exclusionsBolus == nil { totalRecommendation = recommendation.amount // avoid possible rounding issues } else { totalRecommendation = round(1000 * totalRecommendation) / 1000 @@ -853,22 +870,20 @@ final class BolusEntryViewModel: ObservableObject { } else { carbBolus = nil cobCorrectionBolus = nil - cobCorrectionBolusIncluded = self.cobCorrectionBolusIncluded bgCorrectionBolus = nil - bgCorrectionBolusIncluded = self.bgCorrectionBolusIncluded maxExcessBolus = nil safetyLimitBolus = nil + exclusionsBolus = nil recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) notice = nil } } catch { carbBolus = nil cobCorrectionBolus = nil - cobCorrectionBolusIncluded = self.cobCorrectionBolusIncluded bgCorrectionBolus = nil - bgCorrectionBolusIncluded = self.bgCorrectionBolusIncluded maxExcessBolus = nil safetyLimitBolus = nil + exclusionsBolus = nil recommendedBolus = nil switch error { @@ -887,11 +902,10 @@ final class BolusEntryViewModel: ObservableObject { let priorRecommendedBolus = self.recommendedBolus self.carbBolus = carbBolus self.cobCorrectionBolus = cobCorrectionBolus - self.cobCorrectionBolusIncluded = cobCorrectionBolusIncluded self.bgCorrectionBolus = bgCorrectionBolus - self.bgCorrectionBolusIncluded = bgCorrectionBolusIncluded self.maxExcessBolus = maxExcessBolus self.safetyLimitBolus = safetyLimitBolus + self.exclusionsBolus = exclusionsBolus self.recommendedBolus = recommendedBolus self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } self.activeNotice = notice @@ -1001,6 +1015,9 @@ final class BolusEntryViewModel: ObservableObject { var negativeSafetyLimitString: String { negativeBolusString(amount: safetyLimitBolusAmount) } + var negativeCorrectLimitString: String { + negativeBolusString(amount: exclusionsBolusAmount) + } func negativeBolusString(amount: Double?) -> String { guard amount != nil else { diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 78d23cf6fa..1f863bb482 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -240,7 +240,9 @@ struct BolusEntryView: View { @ViewBuilder private var recommendedBolusRow: some View { + let exclusionsApply = viewModel.exclusionsBolus != nil && viewModel.exclusionsBolusIncluded let breakdownFont = Font.subheadline + Section { HStack(alignment: .firstTextBaseline) { Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") @@ -268,6 +270,7 @@ struct BolusEntryView: View { if recommendationBreakdownExpanded { VStack { if viewModel.potentialCarbEntry != nil, viewModel.carbBolus != nil { + let excluded = exclusionsApply && UserDefaults.standard.carbBolusCarbEntryExcluded HStack { Text(" ") Image(systemName: "checkmark") @@ -276,11 +279,12 @@ struct BolusEntryView: View { .opacity(viewModel.carbBolusIncluded ? 1 : 0) Text("Carb Entry", comment: "Label for carb bolus row on bolus screen") .font(breakdownFont) + .foregroundStyle(excluded ? .secondary : .primary) Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.carbBolusString) .font(.subheadline) - .foregroundColor(Color(.label)) + .foregroundColor(Color(excluded ? .secondaryLabel : .label)) breakdownBolusUnitsLabel } } @@ -291,6 +295,7 @@ struct BolusEntryView: View { } } if viewModel.cobCorrectionBolus != nil { + let excluded = exclusionsApply && UserDefaults.standard.carbBolusCobCorrectionExcluded HStack { Text(" ") Image(systemName: "checkmark") @@ -299,11 +304,12 @@ struct BolusEntryView: View { .opacity(viewModel.cobCorrectionBolusIncluded ? 1 : 0) Text("COB Correction", comment: "Label for COB correction bolus row on bolus screen") .font(breakdownFont) + .foregroundStyle(excluded ? .secondary : .primary) Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.cobCorrectionBolusString) .font(breakdownFont) - .foregroundColor(Color(.label)) + .foregroundColor(Color(excluded ? .secondaryLabel : .label)) breakdownBolusUnitsLabel } } @@ -311,11 +317,10 @@ struct BolusEntryView: View { .contentShape(Rectangle()) .onTapGesture { viewModel.cobCorrectionBolusIncluded.toggle() - viewModel.userChangedCobCorrectionBolusIncluded = true } - } if viewModel.bgCorrectionBolus != nil { + let excluded = exclusionsApply && UserDefaults.standard.carbBolusBgCorrectionExcluded HStack { Text(" ") Image(systemName: "checkmark") @@ -324,11 +329,12 @@ struct BolusEntryView: View { .opacity(viewModel.bgCorrectionBolusIncluded ? 1 : 0) Text("BG Correction", comment: "Label for BG correction bolus row on bolus screen") .font(breakdownFont) + .foregroundStyle(excluded ? .secondary : .primary) Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.bgCorrectionBolusString) .font(breakdownFont) - .foregroundColor(Color(.label)) + .foregroundColor(Color(excluded ? .secondaryLabel : .label)) breakdownBolusUnitsLabel } } @@ -336,7 +342,6 @@ struct BolusEntryView: View { .contentShape(Rectangle()) .onTapGesture { viewModel.bgCorrectionBolusIncluded.toggle() - viewModel.userChangedBgCorrectionBolusIncluded = true } } if viewModel.maxExcessBolus != nil { @@ -385,6 +390,29 @@ struct BolusEntryView: View { viewModel.safetyLimitBolusIncluded.toggle() } } + if viewModel.exclusionsBolus != nil { + HStack { + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.exclusionsBolusIncluded ? 1 : 0) + Text("Exclusions", comment: "Label for exclusions row on bolus screen") + .font(breakdownFont) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.negativeCorrectLimitString) + .font(breakdownFont) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.exclusionsBolusIncluded.toggle() + } + } } .accessibilityElement(children: .combine) .transition(.slide) diff --git a/Loop/Views/CarbBolusSelectionView.swift b/Loop/Views/CarbBolusSelectionView.swift new file mode 100644 index 0000000000..fda501b2a0 --- /dev/null +++ b/Loop/Views/CarbBolusSelectionView.swift @@ -0,0 +1,90 @@ +// +// CarbBolusSelectionView.swift +// Loop +// +// Created by Moti Nisenson-Ken on 14/01/2025. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +public struct CarbBolusSelectionView: View { + @AppStorage(UserDefaults.Key.CarbEntryExcluded.rawValue) var isCarbEntryExcluded = false + @AppStorage(UserDefaults.Key.CobCorrectionExcluded.rawValue) var isCobCorrectionExcluded = false + @AppStorage(UserDefaults.Key.BgCorrectionExcluded.rawValue) var isBgCorrectionExcluded = false + + public var body: some View { + + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Carb Bolus Recommendation", comment: "Title for carb bolus recommendation description")) + .font(.headline) + .padding(.bottom, 20) + + Divider() + + Text(NSLocalizedString("When bolusing for carbs one can decide which elements to exclude. The toggles below enable one to not bolus for the Carb Entry, or to not give COB or BG corrections. When these are relevant an extra Exclusions row will appear in the Recommendation Breakdown reducing the overall bolus. Rows included in the Exclusions calculated are grayed out. The excluded amount may be smaller than expected, as negative insulin from other rows can still apply.", comment: "carb bolus recommendation options description")) + .foregroundColor(.secondary) + Divider() + + Toggle(NSLocalizedString("Carb Entry Excluded", comment: "Title for Carb Entry Excluded toggle"), isOn: $isCarbEntryExcluded) + .padding(.top, 20) + + Toggle(NSLocalizedString("COB Correction Excluded", comment: "Title for COB Correction Excluded toggle"), isOn: $isCobCorrectionExcluded) + .padding(.top, 20) + + Toggle(NSLocalizedString("BG Correction Excluded", comment: "Title for BG Correction Excluded toggle"), isOn: $isBgCorrectionExcluded) + .padding(.top, 20) + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } + +} + +extension UserDefaults { + fileprivate enum Key: String { + case CarbEntryExcluded = "com.loopkit.underDevelopment.carbBolus.carbyEntryExcluded" + case CobCorrectionExcluded = "com.loopkit.underDevelopment.carbBolus.correctionExcluded" + case BgCorrectionExcluded = "com.loopkit.underDevelopment.carbBolus.bgCorrectionExcluded" + } + + var carbBolusCarbEntryExcluded : Bool { + get { + bool(forKey: Key.CarbEntryExcluded.rawValue) as Bool + } + set { + set(newValue, forKey: Key.CarbEntryExcluded.rawValue) + } + } + + var carbBolusCobCorrectionExcluded : Bool { + get { + bool(forKey: Key.CobCorrectionExcluded.rawValue) as Bool + } + set { + set(newValue, forKey: Key.CobCorrectionExcluded.rawValue) + } + } + + var carbBolusBgCorrectionExcluded: Bool { + get { + bool(forKey: Key.BgCorrectionExcluded.rawValue) as Bool + } + set { + set(newValue, forKey: Key.BgCorrectionExcluded.rawValue) + } + } +} + +struct CarbBolusSelectionView_Previews: PreviewProvider { + static var previews: some View { + CarbBolusSelectionView() + } +} + diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 211fca9db9..fead688fc1 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -21,15 +21,17 @@ extension SettingsView { public struct ExperimentRow: View { var name: String - var enabled: Bool + var enabled: Bool? public var body: some View { HStack { Text(name) .foregroundColor(.primary) Spacer() - Text(enabled ? "On" : "Off") - .foregroundColor(enabled ? .red : .secondary) + if let enabled = enabled { + Text(enabled ? "On" : "Off") + .foregroundColor(enabled ? .red : .secondary) + } } .padding() .background(Color(UIColor.secondarySystemBackground)) @@ -41,7 +43,7 @@ public struct ExperimentRow: View { public struct ExperimentsSettingsView: View { @AppStorage(UserDefaults.Key.GlucoseBasedApplicationFactorEnabled.rawValue) private var isGlucoseBasedApplicationFactorEnabled = false @AppStorage(UserDefaults.Key.IntegralRetrospectiveCorrectionEnabled.rawValue) private var isIntegralRetrospectiveCorrectionEnabled = false - @AppStorage(UserDefaults.Key.AutoBolusCarbsEnabled.rawValue) private var isAutoBolusCarbsAvailable = false + @AppStorage(UserDefaults.Key.AutoBolusCarbsEnabled.rawValue) private var isAutoBolusCarbsEnabled = false @AppStorage(UserDefaults.Key.AutoBolusCarbsActiveByDefault.rawValue) private var autoBolusCarbsActiveByDefault = false var automaticDosingStrategy: AutomaticDosingStrategy @@ -73,10 +75,10 @@ public struct ExperimentsSettingsView: View { name: NSLocalizedString("Integral Retrospective Correction", comment: "Title of integral retrospective correction experiment"), enabled: isIntegralRetrospectiveCorrectionEnabled) } - NavigationLink(destination: AutoBolusCarbsSelectionView(isAutoBolusCarbsEnabled: $isAutoBolusCarbsAvailable, autoBolusCarbsActiveByDefault: $autoBolusCarbsActiveByDefault)) { + NavigationLink(destination: AutoBolusCarbsSelectionView(isAutoBolusCarbsEnabled: $isAutoBolusCarbsEnabled, autoBolusCarbsActiveByDefault: $autoBolusCarbsActiveByDefault)) { ExperimentRow( name: NSLocalizedString("Auto-Bolus Carbs", comment: "Title of auto-bolus carbs experiment"), - enabled: isAutoBolusCarbsAvailable) + enabled: isAutoBolusCarbsEnabled) } Spacer() } @@ -112,7 +114,7 @@ extension UserDefaults { set(newValue, forKey: Key.IntegralRetrospectiveCorrectionEnabled.rawValue) } } - + var autoBolusCarbsEnabled: Bool { get { bool(forKey: Key.AutoBolusCarbsEnabled.rawValue) as Bool diff --git a/Loop/Views/SettingsView+underDevelopmentSection.swift b/Loop/Views/SettingsView+underDevelopmentSection.swift new file mode 100644 index 0000000000..c4ae6334bd --- /dev/null +++ b/Loop/Views/SettingsView+underDevelopmentSection.swift @@ -0,0 +1,50 @@ +// +// SettingsView+underDevelopmentSection.swift +// Loop +// +// Created by Moti Nisenson-Ken on 14/01/2025. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +extension SettingsView { + internal var underDevelopmentSection: some View { + NavigationLink(NSLocalizedString("🚧 Under Development 🚧", comment: "The title of the Under Development section in settings")) { + UnderDevelopmentSettingsView() + } + } +} + +public struct UnderDevelopmentSettingsView: View { + + public var body: some View { + ScrollView { + VStack(alignment: .center, spacing: 12) { + Text(NSLocalizedString("🚧 Under Development 🚧", comment: "Navigation title for under development screen")) + .font(.headline) + VStack { + Text("⚠️").font(.largeTitle) + Text("Caution") + } + Divider() + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("These features are under development. They may not have been tested thorougly.", comment: "Under Development description.")) + Text(NSLocalizedString("In future versions of Loop these features may change, end up as standard parts of Loop, or be removed from Loop entirely. Please follow along in the Loop Zulip chat to stay informed of possible changes to these features.", comment: "Under development description second paragraph.")) + } + .foregroundColor(.secondary) + + Divider() + NavigationLink(destination: CarbBolusSelectionView()) { + ExperimentRow(name: NSLocalizedString("Carb Bolus Recommendation", comment: "Title of carb bolus recommendation feature"), enabled: nil) + } + Spacer() + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c3ec98b8dd..edcc56801f 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -302,6 +302,9 @@ extension SettingsView { if FeatureFlags.allowAlgorithmExperiments { algorithmExperimentsSection } + if FeatureFlags.allowExperimentalFeatures { + underDevelopmentSection + } } } From abb2927e7f7216a396dafe166a07e53221b9f11d Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 21 Jan 2025 20:30:02 +0200 Subject: [PATCH 42/43] Update display on change to AlgorithmExperiments Needed e.g. when changing ABC Active by Default --- .../StatusTableViewController.swift | 23 ++++++++++++------- ...ingsView+algorithmExperimentsSection.swift | 15 ++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 1acf265911..ecdbf65f6d 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -42,9 +42,9 @@ final class StatusTableViewController: LoopChartsTableViewController { var alertMuter: AlertMuter! var supportManager: SupportManager! - + lazy private var cancellables = Set() - + override func viewDidLoad() { super.viewDidLoad() @@ -116,6 +116,13 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.reloadData(animated: true) } }, + + notificationCenter.addObserver(forName: .AlgorithmExperimentsChanged, object: UserDefaults.standard, queue: nil) { [weak self] (notification: Notification) in + DispatchQueue.main.async { + self?.refreshContext.update(with: .status) + self?.reloadData() + } + }, ] automaticDosingStatus.$automaticDosingEnabled @@ -601,7 +608,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { self.currentCOBDescription = nil } - // FIXME need to trigger an update of this value when the UserDefaults are changed + self.currentAutoBolusCarbsActive = self.deviceManager.loopManager.autoBolusCarbsEnabledAndActive self.tableView.beginUpdates() @@ -991,7 +998,8 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.setChartGenerator(generator: { [weak self] (frame) in return self?.statusCharts.glucoseChart(withFrame: frame)?.view }) - cell.setTitleLabelText(label: NSLocalizedString("Glucose", comment: "The title of the glucose and prediction graph")) + cell.setTitleLabelText(label: NSLocalizedString("Glucose", comment: "The title of the glucose and prediction graph")) + cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: cell.setChartGenerator(generator: { [weak self] (frame) in @@ -1008,9 +1016,8 @@ final class StatusTableViewController: LoopChartsTableViewController { return self?.statusCharts.cobChart(withFrame: frame)?.view }) - let label = NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph"); - - // FIXME need to put in place a proper image indicating AutoBolusCarbs + let label = NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph") + if currentAutoBolusCarbsActive { cell.setTitleLabelText(label: String(format: "%@ %@", label, "🔸")) } else { @@ -1154,6 +1161,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { cell.setSubtitleLabel(label: nil) } + cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: if let currentIOB = currentIOBDescription { @@ -1180,7 +1188,6 @@ final class StatusTableViewController: LoopChartsTableViewController { let label = NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph"); - // FIXME need to put in place a proper image indicating AutoBolusCarbs if currentAutoBolusCarbsActive { cell.setTitleLabelText(label: String(format: "%@ %@", label, "🔸")) } else { diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index fead688fc1..da86c59848 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -85,9 +85,24 @@ public struct ExperimentsSettingsView: View { .padding() } .navigationBarTitleDisplayMode(.inline) + .onChange(of: isGlucoseBasedApplicationFactorEnabled) { _ in + NotificationCenter.default.post(name: .AlgorithmExperimentsChanged, object: UserDefaults.standard, userInfo: nil) + } + .onChange(of: isIntegralRetrospectiveCorrectionEnabled) { _ in + NotificationCenter.default.post(name: .AlgorithmExperimentsChanged, object: UserDefaults.standard, userInfo: nil) + } + .onChange(of: isAutoBolusCarbsEnabled) { _ in + NotificationCenter.default.post(name: .AlgorithmExperimentsChanged, object: UserDefaults.standard, userInfo: nil) + } + .onChange(of: autoBolusCarbsActiveByDefault) { _ in + NotificationCenter.default.post(name: .AlgorithmExperimentsChanged, object: UserDefaults.standard, userInfo: nil) + } } } +extension Notification.Name { + static let AlgorithmExperimentsChanged = Notification.Name(rawValue: "com.loopKit.notification.AlgorithmExperimentsChanged") +} extension UserDefaults { fileprivate enum Key: String { From ad7b0ea2bf1ffd627395615a98de60be199f764a Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 23 Jan 2025 09:53:41 +0200 Subject: [PATCH 43/43] Style missing insulin rows when there are exclusions By definition missing insulin always participates in exclusion calculation. Therefore, if there is an exclusion row, this means that the missing insulin was completely used up and should be marked as excluded. --- Loop/Views/BolusEntryView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 1f863bb482..c7d292bedf 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -353,6 +353,7 @@ struct BolusEntryView: View { .opacity(viewModel.maxExcessBolusIncluded ? 1 : 0) Text("Max Bolus Limit", comment: "Label for max bolus row on bolus screen") .font(breakdownFont) + .foregroundStyle(exclusionsApply ? .secondary : .primary) Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.negativeMaxExcessBolusString) @@ -376,6 +377,7 @@ struct BolusEntryView: View { .opacity(viewModel.safetyLimitBolusIncluded ? 1 : 0) Text("Glucose Safety Limit", comment: "Label for glucose safety limit row on bolus screen") .font(breakdownFont) + .foregroundStyle(exclusionsApply ? .secondary : .primary) Spacer() HStack(alignment: .firstTextBaseline) { Text(viewModel.negativeSafetyLimitString)