From 114e60338f82c19fdc1f8ba13d0d6779a6853a4b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 21 May 2026 16:00:28 -0500 Subject: [PATCH 1/3] [LOOP-5925] Preserve scheduled basal rate in insulin delivery cache A mutable .basal dose loses its scheduledBasalRate when it round-trips through the PumpEvent Core Data entity, which has no column for it. CachedInsulinDeliveryObject then stores only the delivered total (quantized to 0.05 U increments) and, on read-back with a nil rate, falls back to unit == .units, so the displayed rate becomes delivered_total / elapsed and drifts (e.g. 0.999/1.001 U/hr for a 1.0 U/hr basal) until the dose finalizes. For a .basal the rate is intrinsically the dose's value, so preserve it as scheduledBasalRate in create(from:)/update(from:) when not already set. Display-only (basal net-insulin is always 0); no Core Data migration. --- ...dInsulinDeliveryObject+CoreDataClass.swift | 6 ++-- .../CachedInsulinDeliveryObjectTests.swift | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/LoopKit/InsulinKit/CachedInsulinDeliveryObject+CoreDataClass.swift b/LoopKit/InsulinKit/CachedInsulinDeliveryObject+CoreDataClass.swift index 1b064519c..fc7955367 100644 --- a/LoopKit/InsulinKit/CachedInsulinDeliveryObject+CoreDataClass.swift +++ b/LoopKit/InsulinKit/CachedInsulinDeliveryObject+CoreDataClass.swift @@ -276,7 +276,8 @@ extension CachedInsulinDeliveryObject { self.endDate = entry.endDate self.syncIdentifier = entry.syncIdentifier self.deliveredUnits = entry.unitsInDeliverableIncrements - self.scheduledBasalRate = entry.scheduledBasalRate + // A `.basal` dose's rate is its `value`; preserve it as the rate field so the cache reports the exact rate instead of one derived from a quantized delivered total. + self.scheduledBasalRate = entry.scheduledBasalRate ?? (entry.type == .basal ? LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: entry.unitsPerHour) : nil) self.programmedTempBasalRate = (entry.type == .tempBasal) ? LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: entry.unitsPerHour) : nil self.programmedUnits = (entry.type == .bolus) ? entry.programmedUnits : nil self.reason = (entry.type == .bolus) ? .bolus : .basal @@ -302,7 +303,8 @@ extension CachedInsulinDeliveryObject { self.endDate = entry.endDate self.syncIdentifier = entry.syncIdentifier self.deliveredUnits = entry.unitsInDeliverableIncrements - self.scheduledBasalRate = entry.scheduledBasalRate + // A `.basal` dose's rate is its `value`; preserve it as the rate field so the cache reports the exact rate instead of one derived from a quantized delivered total. + self.scheduledBasalRate = entry.scheduledBasalRate ?? (entry.type == .basal ? LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: entry.unitsPerHour) : nil) self.programmedTempBasalRate = (entry.type == .tempBasal) ? LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: entry.unitsPerHour) : nil self.programmedUnits = (entry.type == .bolus) ? entry.programmedUnits : nil self.reason = (entry.type == .bolus) ? .bolus : .basal diff --git a/LoopKitTests/Persistence/CachedInsulinDeliveryObjectTests.swift b/LoopKitTests/Persistence/CachedInsulinDeliveryObjectTests.swift index 865ef1828..953786624 100644 --- a/LoopKitTests/Persistence/CachedInsulinDeliveryObjectTests.swift +++ b/LoopKitTests/Persistence/CachedInsulinDeliveryObjectTests.swift @@ -346,6 +346,34 @@ class CachedInsulinDeliveryObjectOperationsTests: PersistenceControllerTestCase } } + func testCreateFromScheduledBasalEntryPreservesRate() { + // A scheduled `.basal` reported without a scheduledBasalRate (e.g. after a PumpEvent + // round-trip, which doesn't persist it) must keep its exact rate. Without the fix the + // cache stores only the quantized delivered total and the read-back rate drifts. + let start = dateFormatter.date(from: "2020-01-02T03:04:05Z")! + let entry = DoseEntry(type: .basal, + startDate: start, + endDate: start.addingTimeInterval(180.2), // 3 min 0.2 s → quantized total would drift + value: 1.0, + unit: .unitsPerHour, + decisionId: nil, + deliveredUnits: nil, + syncIdentifier: "scheduled-basal", + scheduledBasalRate: nil, + isMutable: true) + cacheStore.managedObjectContext.performAndWait { + let object = CachedInsulinDeliveryObject(context: cacheStore.managedObjectContext) + object.create(from: entry, by: "Test Providence Identifier", at: start) + + XCTAssertEqual(object.scheduledBasalRate, LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 1.0)) + + let dose = object.dose + XCTAssertNotNil(dose) + XCTAssertEqual(dose!.unit, .unitsPerHour) + XCTAssertEqual(dose!.unitsPerHour, 1.0, accuracy: 1e-9) + } + } + func testCreateAndUpdateFromEntry() { let createEntry = DoseEntry(type: .tempBasal, startDate: dateFormatter.date(from: "2020-02-03T04:05:06Z")!, From 95ef01ad9f08ea95467d5847d4bc21c2b2d02565 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 21 May 2026 17:06:09 -0500 Subject: [PATCH 2/3] Fix tests --- LoopKitHostedTests/InsulinDeliveryStoreTests.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/LoopKitHostedTests/InsulinDeliveryStoreTests.swift b/LoopKitHostedTests/InsulinDeliveryStoreTests.swift index 0a268e4b4..1bcfa5298 100644 --- a/LoopKitHostedTests/InsulinDeliveryStoreTests.swift +++ b/LoopKitHostedTests/InsulinDeliveryStoreTests.swift @@ -20,7 +20,7 @@ class InsulinDeliveryStoreTestsBase: PersistenceControllerTestCase { decisionId: nil, deliveredUnits: 0.015, syncIdentifier: "4B14522E-A7B5-4E73-B76B-5043CD7176B0", - scheduledBasalRate: nil) + scheduledBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 1.8)) internal let entry2 = DoseEntry(type: .tempBasal, startDate: Date(timeIntervalSinceNow: -.minutes(2)), endDate: Date(timeIntervalSinceNow: -.minutes(1.5)), @@ -202,8 +202,8 @@ class InsulinDeliveryStoreTests: InsulinDeliveryStoreTestsBase { XCTAssertEqual(entries[0].type, self.entry1.type) XCTAssertEqual(entries[0].startDate, self.entry1.startDate) XCTAssertEqual(entries[0].endDate, self.entry1.endDate) - XCTAssertEqual(entries[0].value, 0.015) - XCTAssertEqual(entries[0].unit, .units) + XCTAssertEqual(entries[0].value, 1.8) + XCTAssertEqual(entries[0].unit, .unitsPerHour) XCTAssertEqual(entries[0].deliveredUnits, 0.015) XCTAssertEqual(entries[0].description, self.entry1.description) XCTAssertEqual(entries[0].syncIdentifier, self.entry1.syncIdentifier) @@ -282,8 +282,8 @@ class InsulinDeliveryStoreTests: InsulinDeliveryStoreTestsBase { XCTAssertEqual(entries[0].type, self.entry1.type) XCTAssertEqual(entries[0].startDate, self.entry1.startDate) XCTAssertEqual(entries[0].endDate, self.entry1.endDate) - XCTAssertEqual(entries[0].value, 0.015) - XCTAssertEqual(entries[0].unit, .units) + XCTAssertEqual(entries[0].value, 1.8) + XCTAssertEqual(entries[0].unit, .unitsPerHour) XCTAssertEqual(entries[0].deliveredUnits, 0.015) XCTAssertEqual(entries[0].description, self.entry1.description) XCTAssertEqual(entries[0].syncIdentifier, self.entry1.syncIdentifier) @@ -314,8 +314,8 @@ class InsulinDeliveryStoreTests: InsulinDeliveryStoreTestsBase { XCTAssertEqual(entries[0].type, self.entry1.type) XCTAssertEqual(entries[0].startDate, self.entry1.startDate) XCTAssertEqual(entries[0].endDate, self.entry1.endDate) - XCTAssertEqual(entries[0].value, 0.015) - XCTAssertEqual(entries[0].unit, .units) + XCTAssertEqual(entries[0].value, 1.8) + XCTAssertEqual(entries[0].unit, .unitsPerHour) XCTAssertEqual(entries[0].deliveredUnits, 0.015) XCTAssertEqual(entries[0].description, self.entry1.description) XCTAssertEqual(entries[0].syncIdentifier, self.entry1.syncIdentifier) From 6584485a10a70226dcbb36058d6d758d1985ea7a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 2 Jun 2026 10:02:13 -0500 Subject: [PATCH 3/3] Improve comments --- .../CachedInsulinDeliveryObject+CoreDataClass.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopKit/InsulinKit/CachedInsulinDeliveryObject+CoreDataClass.swift b/LoopKit/InsulinKit/CachedInsulinDeliveryObject+CoreDataClass.swift index fc7955367..4d832c789 100644 --- a/LoopKit/InsulinKit/CachedInsulinDeliveryObject+CoreDataClass.swift +++ b/LoopKit/InsulinKit/CachedInsulinDeliveryObject+CoreDataClass.swift @@ -276,7 +276,7 @@ extension CachedInsulinDeliveryObject { self.endDate = entry.endDate self.syncIdentifier = entry.syncIdentifier self.deliveredUnits = entry.unitsInDeliverableIncrements - // A `.basal` dose's rate is its `value`; preserve it as the rate field so the cache reports the exact rate instead of one derived from a quantized delivered total. + // A `.basal` dose's rate (used via `.unitsPerHour` accessor) is its `value`; preserve it as the rate field so the cache reports the exact rate instead of one derived from a quantized delivered total. self.scheduledBasalRate = entry.scheduledBasalRate ?? (entry.type == .basal ? LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: entry.unitsPerHour) : nil) self.programmedTempBasalRate = (entry.type == .tempBasal) ? LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: entry.unitsPerHour) : nil self.programmedUnits = (entry.type == .bolus) ? entry.programmedUnits : nil @@ -303,7 +303,7 @@ extension CachedInsulinDeliveryObject { self.endDate = entry.endDate self.syncIdentifier = entry.syncIdentifier self.deliveredUnits = entry.unitsInDeliverableIncrements - // A `.basal` dose's rate is its `value`; preserve it as the rate field so the cache reports the exact rate instead of one derived from a quantized delivered total. + // A `.basal` dose's rate is its `value` (used via `.unitsPerHour` accessor); preserve it as the rate field so the cache reports the exact rate instead of one derived from a quantized delivered total. self.scheduledBasalRate = entry.scheduledBasalRate ?? (entry.type == .basal ? LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: entry.unitsPerHour) : nil) self.programmedTempBasalRate = (entry.type == .tempBasal) ? LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: entry.unitsPerHour) : nil self.programmedUnits = (entry.type == .bolus) ? entry.programmedUnits : nil