Skip to content

Commit 0081d73

Browse files
committed
Keep unrecorded schedule history unknown in Trends
1 parent 150196b commit 0081d73

2 files changed

Lines changed: 104 additions & 22 deletions

File tree

‎Loop/Redesign/LoopHistoryProvider.swift‎

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,42 @@ final class LoopHistoryProvider {
9898

9999
/// The targets that actually applied, including dated presets and changes to the stored profile.
100100
func glucoseTargetBands(in interval: DateInterval) async throws -> [TargetBand] {
101-
if let store = settingsStore {
102-
let previous = try await store.getStoredSettings(end: interval.start, limit: 1).first
103-
if previous?.glucoseTargetRangeSchedule == nil {
104-
let changes = try await store.getStoredSettings(start: interval.start, end: interval.end)
105-
// LoopKit's historical query requires at least one valid profile, even if settings records exist.
106-
guard changes.contains(where: { $0.glucoseTargetRangeSchedule != nil }) else { return [] }
101+
guard let settingsStore else { return [] }
102+
return try await loopDataManager.glucoseTargetBands(in: interval, settingsStore: settingsStore)
103+
}
104+
105+
/// SettingsStore's schedule queries extend the first profile backwards to the requested start. Reporting must
106+
/// instead leave time before the first saved profile, and settings records without a profile, unknown.
107+
private static func settingsHistory(in interval: DateInterval, from store: SettingsStore) async throws -> [(settings: StoredSettings, interval: DateInterval)] {
108+
var records = Array(try await store.getStoredSettings(start: interval.start, end: interval.end).reversed())
109+
if let previous = try await store.getStoredSettings(end: interval.start, limit: 1).first {
110+
records.insert(previous, at: 0)
111+
}
112+
return records.enumerated().compactMap { index, settings in
113+
let start = max(interval.start, settings.date)
114+
let end = min(interval.end, index + 1 < records.count ? records[index + 1].date : interval.end)
115+
guard end > start else { return nil }
116+
return (settings, DateInterval(start: start, end: end))
117+
}
118+
}
119+
120+
static func basalHistory(in interval: DateInterval, from store: SettingsStore) async throws -> [AbsoluteScheduleValue<Double>] {
121+
let history = try await settingsHistory(in: interval, from: store)
122+
return history.flatMap { record in
123+
record.settings.basalRateSchedule?.truncatingBetween(start: record.interval.start, end: record.interval.end) ?? []
124+
}
125+
}
126+
127+
static func targetHistory(in interval: DateInterval, from store: SettingsStore) async throws -> [TargetBand] {
128+
let history = try await settingsHistory(in: interval, from: store)
129+
return history.flatMap { record -> [TargetBand] in
130+
guard let schedule = record.settings.glucoseTargetRangeSchedule else { return [] }
131+
return schedule.truncatingBetween(start: record.interval.start, end: record.interval.end).map {
132+
let range = $0.value.quantityRange(for: schedule.unit)
133+
return TargetBand(start: $0.startDate, end: $0.endDate,
134+
range: range.lowerBound.doubleValue(for: .milligramsPerDeciliter)...range.upperBound.doubleValue(for: .milligramsPerDeciliter))
107135
}
108136
}
109-
return try await loopDataManager.glucoseTargetBands(in: interval)
110137
}
111138

112139
// MARK: - Days
@@ -312,7 +339,12 @@ final class LoopHistoryProvider {
312339

313340
let boluses = doses.compactMap { BolusEvent(dose: $0, in: interval) }
314341

315-
let basalHistory = try await loopDataManager.settingsProvider.getBasalHistory(startDate: interval.start, endDate: interval.end)
342+
let basalHistory: [AbsoluteScheduleValue<Double>]
343+
if let store = settingsStore {
344+
basalHistory = try await Self.basalHistory(in: interval, from: store)
345+
} else {
346+
basalHistory = []
347+
}
316348
let temporaryPresetsManager = self.temporaryPresetsManager
317349
let overrides = await MainActor.run {
318350
temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: interval.start, endDate: interval.end)
@@ -433,13 +465,8 @@ final class LoopHistoryProvider {
433465

434466
extension LoopDataManager {
435467
/// A historical chart must use the profile and presets from that time, rather than repeat today's targets.
436-
func glucoseTargetBands(in interval: DateInterval) async throws -> [TargetBand] {
437-
guard settingsProvider.settings.glucoseTargetRangeSchedule != nil else { return [] }
438-
let schedule = try await settingsProvider.getTargetRangeHistory(startDate: interval.start, endDate: interval.end)
439-
var bands = schedule.map {
440-
TargetBand(start: max($0.startDate, interval.start), end: min($0.endDate, interval.end),
441-
range: $0.value.lowerBound.doubleValue(for: .milligramsPerDeciliter)...$0.value.upperBound.doubleValue(for: .milligramsPerDeciliter))
442-
}
468+
func glucoseTargetBands(in interval: DateInterval, settingsStore: SettingsStore) async throws -> [TargetBand] {
469+
var bands = try await LoopHistoryProvider.targetHistory(in: interval, from: settingsStore)
443470
let overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: interval.start, endDate: interval.end)
444471
for override in overrides.sorted(by: { $0.startDate < $1.startDate }) {
445472
guard let target = override.settings.targetRange,

‎LoopTests/Managers/LoopDataManagerTests.swift‎

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -700,18 +700,20 @@ class LoopDataManagerTests: XCTestCase {
700700
LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: low)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: high)
701701
}
702702
let window = DateInterval(start: d(-.hours(3)), end: d(-.minutes(30)))
703-
settingsProvider.targetRangeHistory = [
704-
AbsoluteScheduleValue(startDate: window.start, endDate: d(-.hours(2)), value: target(100, 110)),
705-
AbsoluteScheduleValue(startDate: d(-.hours(2)), endDate: window.end, value: target(120, 130)),
706-
]
703+
let store = try await reportingSettingsStore([
704+
StoredSettings(date: window.start, glucoseTargetRangeSchedule: GlucoseRangeSchedule(
705+
unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100, maxValue: 110))], timeZone: .utcTimeZone)),
706+
StoredSettings(date: d(-.hours(2)), glucoseTargetRangeSchedule: GlucoseRangeSchedule(
707+
unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 120, maxValue: 130))], timeZone: .utcTimeZone)),
708+
])
707709
var preset = TemporaryScheduleOverride(context: .custom,
708710
settings: TemporaryPresetSettings(targetRange: target(150, 160), insulinNeedsScaleFactor: 1),
709711
startDate: d(-.minutes(150)), duration: .finite(.hours(2)),
710712
enactTrigger: .local, syncIdentifier: UUID())
711713
preset.actualEnd = .early(d(-.minutes(90)))
712714
temporaryPresetsManager.presetHistory.recordOverride(preset, at: d(-.minutes(150)))
713715

714-
let bands = try await loopDataManager.glucoseTargetBands(in: window)
716+
let bands = try await loopDataManager.glucoseTargetBands(in: window, settingsStore: store)
715717
XCTAssertEqual(bands, [
716718
TargetBand(start: window.start, end: d(-.minutes(150)), range: 100...110),
717719
TargetBand(start: d(-.minutes(150)), end: d(-.minutes(90)), range: 150...160),
@@ -720,11 +722,64 @@ class LoopDataManagerTests: XCTestCase {
720722
}
721723

722724
func testTargetBandsAreUnknownBeforeTherapyIsConfigured() async throws {
723-
settingsProvider.settings.glucoseTargetRangeSchedule = nil
724-
let bands = try await loopDataManager.glucoseTargetBands(in: DateInterval(start: d(-.hours(3)), end: now))
725+
// Today's profile must not fill in a period whose saved settings had no profile.
726+
let store = try await reportingSettingsStore([StoredSettings(date: d(-.hours(4)))])
727+
let bands = try await loopDataManager.glucoseTargetBands(in: DateInterval(start: d(-.hours(3)), end: now), settingsStore: store)
725728
XCTAssertTrue(bands.isEmpty)
726729
}
727730

731+
func testReportingSchedulesLeaveUnrecordedHistoryUnknown() async throws {
732+
let window = DateInterval(start: d(-.hours(4)), end: now)
733+
let first = window.start.addingTimeInterval(.hours(1))
734+
let missing = first.addingTimeInterval(.hours(1))
735+
let resumed = missing.addingTimeInterval(.hours(1))
736+
func profile(at date: Date, rate: Double, target: Double) -> StoredSettings {
737+
StoredSettings(date: date, glucoseTargetRangeSchedule: GlucoseRangeSchedule(
738+
unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: target, maxValue: target + 10))], timeZone: .utcTimeZone),
739+
basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: rate)], timeZone: .utcTimeZone))
740+
}
741+
let store = try await reportingSettingsStore([
742+
profile(at: first, rate: 1, target: 100),
743+
StoredSettings(date: missing),
744+
profile(at: resumed, rate: 2, target: 120),
745+
])
746+
747+
let basal = try await LoopHistoryProvider.basalHistory(in: window, from: store)
748+
let schedule = basal.map { LoopHistoryMath.ScheduleValue(start: $0.startDate, end: $0.endDate, rate: $0.value) }
749+
let delivered = LoopHistoryMath.basalSegments(window: window, schedule: schedule, deliveries: [],
750+
programmedRates: schedule, recordsStart: window.start)
751+
XCTAssertEqual(delivered.reduce(0) { $0 + $1.units }, 3, accuracy: 1e-9,
752+
"Only the two recorded hours can supply inferred basal, not the earlier time or missing profile")
753+
XCTAssertNil(LoopHistoryMath.scheduledUnits(basal.map { ScheduledBasalPoint(start: $0.startDate, end: $0.endDate, rate: $0.value) }, over: window))
754+
let targets = try await loopDataManager.glucoseTargetBands(in: window, settingsStore: store)
755+
XCTAssertEqual(targets, [TargetBand(start: first, end: missing, range: 100...110),
756+
TargetBand(start: resumed, end: window.end, range: 120...130)])
757+
758+
// A query starting during a known profile still includes the setting saved before its start.
759+
let inside = DateInterval(start: first.addingTimeInterval(.minutes(30)), end: missing)
760+
let partial = try await LoopHistoryProvider.basalHistory(in: inside, from: store)
761+
XCTAssertEqual(partial.first?.startDate, inside.start)
762+
XCTAssertEqual(partial.reduce(0) { $0 + $1.value * $1.endDate.timeIntervalSince($1.startDate) / 3600 }, 0.5, accuracy: 1e-9)
763+
let before = DateInterval(start: window.start, end: first)
764+
let earlierBasal = try await LoopHistoryProvider.basalHistory(in: before, from: store)
765+
let earlierTargets = try await loopDataManager.glucoseTargetBands(in: before, settingsStore: store)
766+
XCTAssertTrue(earlierBasal.isEmpty)
767+
XCTAssertTrue(earlierTargets.isEmpty)
768+
}
769+
770+
private func reportingSettingsStore(_ settings: [StoredSettings]) async throws -> SettingsStore {
771+
let store = SettingsStore(store: PersistenceController.mock(), expireAfter: .days(3650))
772+
for record in settings {
773+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
774+
store.storeSettings(record) { error in
775+
if let error { continuation.resume(throwing: error) }
776+
else { continuation.resume() }
777+
}
778+
}
779+
}
780+
return store
781+
}
782+
728783
func testFetchDataWithHighInsulinNeedsPresetMitigation() async throws {
729784
var input = try await loopDataManager.fetchData(for: now)
730785
XCTAssertEqual(input.targetMgdl.count, 1)

0 commit comments

Comments
 (0)