Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions Loop/Managers/LoopDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -427,7 +431,7 @@ final class LoopDataManager: ObservableObject {
}

let carbRatio = try await settingsProvider.getCarbRatioHistory(
startDate: carbsStart,
startDate: carbScheduleStart,
endDate: forecastEndTime
)

Expand All @@ -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,
Expand Down Expand Up @@ -825,14 +829,19 @@ 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)

input.includePositiveVelocityAndRC = usePositiveMomentumAndRCForManualBoluses
input.recommendationType = .manualBolus

try input.validateCarbScheduleCoverage()
let output = LoopAlgorithm.run(input: input)


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Loop/View Models/BolusEntryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions LoopTests/Managers/LoopDataManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions LoopTests/Mocks/MockSettingsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@ class MockSettingsProvider: SettingsProvider {

var carbRatioHistory: [AbsoluteScheduleValue<Double>]?
func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue<Double>] {
return carbRatioHistory ?? settings.carbRatioSchedule?.between(start: startDate, end: endDate) ?? []
return carbRatioHistory ?? settings.carbRatioSchedule?.truncatingBetween(start: startDate, end: endDate) ?? []
}

var insulinSensitivityHistory: [AbsoluteScheduleValue<LoopQuantity>]?
func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue<LoopQuantity>] {
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<ClosedRange<LoopQuantity>>]?
Expand Down
19 changes: 16 additions & 3 deletions LoopTests/ViewModels/BolusEntryViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
}
Expand Down