diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 45c48734a0..da3ff8c76b 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -363,6 +363,7 @@ final class LoopDataManager: ObservableObject { for baseTime: Date? = nil, presumePresetEndingNow: Bool = false, ensureDosingCoverageStart: Date? = nil, + ensureCarbCoverageStart: Date? = nil, projectOngoingDoses: Bool = false ) async throws -> StoredDataAlgorithmInput { // Need to fetch doses back as far as t - (DIA + DCA) for Dynamic carbs @@ -417,6 +418,9 @@ final class LoopDataManager: ObservableObject { let forecastEndTime = baseTime.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(GlucoseMath.defaultDelta) let carbsStart = baseTime.addingTimeInterval(LoopConstants.maxCarbEntryPastTime + .minutes(-1)) // additional minute to handle difference in seconds between carb entry and carb ratio + // A pending carb entry can outlive this rolling window while the bolus screen is open. + // Cover it with schedule history without expanding the stored-carb or glucose queries. + let carbScheduleStart = min(carbsStart, ensureCarbCoverageStart ?? carbsStart) // Include future carbs in query, but filter out ones entered after basetime. The filtering is only applicable when running in a retrospective situation. let carbEntries = try await carbStore.getCarbEntries( @@ -427,7 +431,7 @@ final class LoopDataManager: ObservableObject { } let carbRatio = try await settingsProvider.getCarbRatioHistory( - startDate: carbsStart, + startDate: carbScheduleStart, endDate: forecastEndTime ) @@ -451,12 +455,12 @@ final class LoopDataManager: ObservableObject { recommendationEffectInterval: recommendationEffectInterval ) - // Carb entries (and a backdated manual-bolus entry) can extend back to carbsStart, and + // Carb entries (including a pending manual-bolus entry) can extend back to carbScheduleStart, and // CarbMath.map(to:) preconditionFailures if the ISF/carb-ratio timelines don't cover every // carb entry's start date. timelineIntervalForSensitivity derives its window from dose and // glucose history only — which can be more recent than carbsStart (e.g. after a CGM gap) — - // so extend the ISF (and override) window back to cover the carb window, matching carbRatio. - let sensitivityStart = min(neededSensitivityTimeline.start, carbsStart) + // so extend the ISF (and override) window back to cover every entry, matching carbRatio. + let sensitivityStart = min(neededSensitivityTimeline.start, carbScheduleStart) let sensitivity = try await settingsProvider.getInsulinSensitivityHistory( startDate: sensitivityStart, @@ -825,7 +829,11 @@ final class LoopDataManager: ObservableObject { endingPremealOverride = true } - var input = try await self.fetchData(for: now, presumePresetEndingNow: truncatingActiveOverride || endingPremealOverride) + var input = try await self.fetchData( + for: now, + presumePresetEndingNow: truncatingActiveOverride || endingPremealOverride, + ensureCarbCoverageStart: carbEntryChange?.startDate + ) .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseSample) .removingCarbEntry(carbEntry: carbEntryChange?.originalEntry) .addingCarbEntry(carbEntry: carbEntryChange?.asStoredCarbEntry) @@ -833,6 +841,7 @@ final class LoopDataManager: ObservableObject { input.includePositiveVelocityAndRC = usePositiveMomentumAndRCForManualBoluses input.recommendationType = .manualBolus + try input.validateCarbScheduleCoverage() let output = LoopAlgorithm.run(input: input) @@ -1325,7 +1334,21 @@ extension StoredDataAlgorithmInput { return rval } + func validateCarbScheduleCoverage() throws { + // CarbMath traps if either lookup is nil. Validate the final input, including pending + // additions/edits, so incomplete history becomes a recoverable recommendation error. + for entry in carbEntries { + guard carbRatio.closestPrior(to: entry.startDate) != nil else { + throw LoopError.configurationError(.carbRatioSchedule) + } + guard sensitivity.closestPrior(to: entry.startDate) != nil else { + throw LoopError.configurationError(.insulinSensitivitySchedule) + } + } + } + func predictGlucose(effectsOptions: AlgorithmEffectsOptions = .all) throws -> [PredictedGlucoseValue] { + try validateCarbScheduleCoverage() let prediction = LoopAlgorithm.generatePrediction( start: predictionStart, glucoseHistory: glucoseHistory, @@ -1553,7 +1576,11 @@ extension LoopDataManager: BolusEntryViewModelDelegate { endingPremealOverride = true } - var input = try await fetchData(for: now, presumePresetEndingNow: endingPremealOverride, ensureDosingCoverageStart: nil) + var input = try await fetchData( + for: now, + presumePresetEndingNow: endingPremealOverride, + ensureCarbCoverageStart: carbEntryChange?.startDate + ) // Add potential bolus, carbs, manual glucose input = input diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 18fa58e64e..3b0408a901 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -27,7 +27,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { var mostRecentGlucoseDataDate: Date? { get } var mostRecentPumpDataDate: Date? { get } - func fetchData(for baseTime: Date?, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?, projectOngoingDoses: Bool) async throws -> StoredDataAlgorithmInput + func fetchData(for baseTime: Date?, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?, ensureCarbCoverageStart: Date?, projectOngoingDoses: Bool) async throws -> StoredDataAlgorithmInput func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? func addCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 13bc5b470b..60a5e08c68 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -462,6 +462,116 @@ class LoopDataManagerTests: XCTestCase { } + private func pendingCarbEntry(at date: Date) -> CarbEntryChange { + .add(NewCarbEntry(date: now, syncIdentifier: UUID().uuidString, + quantity: LoopQuantity(unit: .gram, doubleValue: 20), + startDate: date, foodType: nil, absorptionTime: .hours(3))) + } + + private func assertManualBolusAndPreviewSucceed(for change: CarbEntryChange) async throws { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)) + ] + let recommendation = try await loopDataManager.recommendManualBolus(carbEntryChange: change) + XCTAssertNotNil(recommendation) + let preview = try await loopDataManager.generatePrediction( + carbEntryChange: change, potentialDose: nil, manualGlucose: nil) + XCTAssertFalse(preview.predictedGlucose.isEmpty) + } + + func testPendingCarbEntryRemainsCoveredAfterBolusScreenAges() async throws { + let openedAt = now + let change = pendingCarbEntry(at: d(.hours(-12))) + try await assertManualBolusAndPreviewSucceed(for: change) + + // The same pending entry survives a five-minute refresh. It is now older than + // the normal 12-hour-and-one-minute query window in fetchData. + TestingDate.setFixedTestingDate(openedAt.addingTimeInterval(.minutes(5))) + try await assertManualBolusAndPreviewSucceed(for: change) + } + + func testEditedCarbEntryOutsideNormalWindowIsCovered() async throws { + let original = StoredCarbEntry(startDate: d(.hours(-13)), + quantity: LoopQuantity(unit: .gram, doubleValue: 30), + absorptionTime: .hours(3)) + carbStore.carbHistory = [original] + let update = CarbEntryUpdate(date: now, quantity: LoopQuantity(unit: .gram, doubleValue: 20), + startDate: original.startDate, foodType: nil, absorptionTime: .hours(3)) + try await assertManualBolusAndPreviewSucceed(for: .edit(original, update)) + } + + func testExtendedCarbScheduleCoverageIncludesHistoricalOverride() async throws { + let entryDate = d(.hours(-13)) + let override = TemporaryScheduleOverride( + context: .custom, + settings: TemporaryPresetSettings(targetRange: nil, insulinNeedsScaleFactor: 2), + startDate: entryDate, + duration: .finite(.minutes(30)), + enactTrigger: .local, + syncIdentifier: UUID()) + temporaryPresetsManager.presetHistory.recordOverride(override) + + let input = try await loopDataManager.fetchData(for: now, ensureCarbCoverageStart: entryDate) + let ratio = try XCTUnwrap(input.carbRatio.closestPrior(to: entryDate)) + let sensitivity = try XCTUnwrap(input.sensitivity.closestPrior(to: entryDate)) + XCTAssertEqual(ratio.value, settingsProvider.settings.carbRatioSchedule!.value(at: entryDate) / 2) + XCTAssertEqual(sensitivity.value.doubleValue(for: .milligramsPerDeciliter), + settingsProvider.settings.insulinSensitivitySchedule!.value(at: entryDate) / 2) + } + + func testRecentPendingCarbDoesNotShortenNormalScheduleHistory() async throws { + let normal = try await loopDataManager.fetchData(for: now) + let recent = try await loopDataManager.fetchData(for: now, ensureCarbCoverageStart: d(.minutes(-5))) + XCTAssertEqual(normal.carbRatio, recent.carbRatio) + XCTAssertEqual(normal.sensitivity, recent.sensitivity) + } + + private func assertMissingCarbScheduleThrows(_ detail: ConfigurationErrorDetail, preview: Bool) async throws { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)) + ] + let change = pendingCarbEntry(at: d(.hours(-13))) + // Nonempty histories can still fail to cover the pending entry. Simulate a + // provider that cannot supply the requested older history. + if detail == .carbRatioSchedule { + settingsProvider.carbRatioHistory = [ + AbsoluteScheduleValue(startDate: d(.hours(-12)), endDate: d(.hours(8)), value: 10) + ] + } else { + settingsProvider.insulinSensitivityHistory = [ + AbsoluteScheduleValue(startDate: d(.hours(-12)), endDate: d(.hours(8)), + value: .glucose(value: 50)) + ] + } + do { + if preview { + _ = try await loopDataManager.generatePrediction( + carbEntryChange: change, potentialDose: nil, manualGlucose: nil) + } else { + _ = try await loopDataManager.recommendManualBolus(carbEntryChange: change) + } + XCTFail("Expected a recoverable missing-schedule error") + } catch LoopError.configurationError(let actual) { + XCTAssertEqual(actual, detail) + } + } + + func testManualBolusRejectsMissingCarbRatioCoverage() async throws { + try await assertMissingCarbScheduleThrows(.carbRatioSchedule, preview: false) + } + + func testManualBolusRejectsMissingSensitivityCoverage() async throws { + try await assertMissingCarbScheduleThrows(.insulinSensitivitySchedule, preview: false) + } + + func testPreviewRejectsMissingCarbRatioCoverage() async throws { + try await assertMissingCarbScheduleThrows(.carbRatioSchedule, preview: true) + } + + func testPreviewRejectsMissingSensitivityCoverage() async throws { + try await assertMissingCarbScheduleThrows(.insulinSensitivitySchedule, preview: true) + } + func testFetchDataWithHighInsulinNeedsPresetMitigation() async throws { var input = try await loopDataManager.fetchData(for: now) XCTAssertEqual(input.target.count, 1) diff --git a/LoopTests/Mocks/MockSettingsProvider.swift b/LoopTests/Mocks/MockSettingsProvider.swift index 7f9774ccee..ae8b194957 100644 --- a/LoopTests/Mocks/MockSettingsProvider.swift +++ b/LoopTests/Mocks/MockSettingsProvider.swift @@ -22,12 +22,19 @@ class MockSettingsProvider: SettingsProvider { var carbRatioHistory: [AbsoluteScheduleValue]? func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { - return carbRatioHistory ?? settings.carbRatioSchedule?.between(start: startDate, end: endDate) ?? [] + return carbRatioHistory ?? settings.carbRatioSchedule?.truncatingBetween(start: startDate, end: endDate) ?? [] } var insulinSensitivityHistory: [AbsoluteScheduleValue]? func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { - return insulinSensitivityHistory ?? settings.insulinSensitivitySchedule?.quantitiesBetween(start: startDate, end: endDate) ?? [] + if let insulinSensitivityHistory { return insulinSensitivityHistory } + guard let schedule = settings.insulinSensitivitySchedule else { return [] } + // Match SettingsStore: history begins at the requested date, not at the preceding + // daily schedule boundary (which can accidentally hide missing-history bugs). + return schedule.truncatingBetween(start: startDate, end: endDate).map { + AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, + value: LoopQuantity(unit: schedule.unit, doubleValue: $0.value)) + } } var targetRangeHistory: [AbsoluteScheduleValue>]? diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index dd26c27822..0b939ea0fa 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -190,7 +190,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdatePredictedGlucoseValues() async throws { do { - let input = try await delegate.fetchData(for: Self.exampleStartDate, presumePresetEndingNow: false, ensureDosingCoverageStart: nil, projectOngoingDoses: false) + let input = try await delegate.fetchData(for: Self.exampleStartDate, presumePresetEndingNow: false, ensureDosingCoverageStart: nil, ensureCarbCoverageStart: nil, projectOngoingDoses: false) let prediction = try input.predictGlucose() await bolusEntryViewModel.update() XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) @@ -201,7 +201,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdatePredictedGlucoseValuesWithManual() async throws { do { - let input = try await delegate.fetchData(for: Self.exampleStartDate, presumePresetEndingNow: false, ensureDosingCoverageStart: nil, projectOngoingDoses: false) + let input = try await delegate.fetchData(for: Self.exampleStartDate, presumePresetEndingNow: false, ensureDosingCoverageStart: nil, ensureCarbCoverageStart: nil, projectOngoingDoses: false) let prediction = try input.predictGlucose() await bolusEntryViewModel.update() bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity @@ -359,6 +359,19 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertNil(bolusEntryViewModel.activeNotice) } + func testMissingCarbScheduleClearsPreviousRecommendation() async throws { + for detail: ConfigurationErrorDetail in [.carbRatioSchedule, .insulinSensitivitySchedule] { + delegate.algorithmOutput.recommendationResult = .success(.init(manual: ManualBolusRecommendation(amount: 1.25))) + await bolusEntryViewModel.update() + XCTAssertNotNil(bolusEntryViewModel.recommendedBolus) + + delegate.algorithmOutput.recommendationResult = .failure(LoopError.configurationError(detail)) + await bolusEntryViewModel.update() + XCTAssertNil(bolusEntryViewModel.recommendedBolus) + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + } + } + func testUpdateRecommendedBolusThrowsMissingDataError() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) delegate.algorithmOutput.recommendationResult = .failure(LoopError.missingDataError(.glucose)) @@ -888,7 +901,7 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { automaticBolusApplicationFactor: 0.4 ) - func fetchData(for baseTime: Date?, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?, projectOngoingDoses: Bool) async throws -> StoredDataAlgorithmInput { + func fetchData(for baseTime: Date?, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?, ensureCarbCoverageStart: Date?, projectOngoingDoses: Bool) async throws -> StoredDataAlgorithmInput { loopStateInput.predictionStart = baseTime ?? Date() return loopStateInput }