From f9dfca5715daf79ed079a75778166c239d44689c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 7 Sep 2026 14:53:15 +0200 Subject: [PATCH] Carry the pump reservoir value across records that omit it Loop uploads pump.reservoir in only some device status records while an Omnipod is below 50U. The last exact reading is kept per pump and reused for 30 minutes when the field is missing, and the pump row shows an em dash once it is older than that. A missing reading reads as 50+U for pumps that report a volume only when it is low, and for records that name no pump at all, which is how Trio and iAPS upload. Pumps that name themselves and report a volume in every record get the em dash instead. A reading that arrives within 15 minutes of a pump first appearing can still be the previous pod's, so it is not taken as evidence that the pod is below its reporting limit. latestPumpVolume is optional so an unknown volume cannot reach the reservoir alarm as 50. --- .../Controllers/Nightscout/DeviceStatus.swift | 25 ++- .../Nightscout/PumpReservoir.swift | 104 ++++++++++++ LoopFollow/Storage/Storage.swift | 1 + .../ViewControllers/MainViewController.swift | 2 +- Tests/PumpReservoirTests.swift | 151 ++++++++++++++++++ 5 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 LoopFollow/Controllers/Nightscout/PumpReservoir.swift create mode 100644 Tests/PumpReservoirTests.swift diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 89126e6b1..4cd3a65a1 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -112,16 +112,31 @@ extension MainViewController { Storage.shared.lastLoopTime.value = lastPumpTime } - if let reservoirData = lastPumpRecord["reservoir"] as? Double { - latestPumpVolume = reservoirData - infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U", numericValue: reservoirData) - Storage.shared.lastPumpReservoirU.value = reservoirData - } else { + let reservoir = PumpReservoirResolver.resolve( + reservoir: lastPumpRecord["reservoir"] as? Double, + pumpID: lastPumpRecord["pumpID"] as? String, + manufacturer: lastPumpRecord["manufacturer"] as? String, + model: lastPumpRecord["model"] as? String, + cache: Storage.shared.pumpReservoirCache.value, + now: Date() + ) + Storage.shared.pumpReservoirCache.value = reservoir.cache + + switch reservoir.state { + case let .units(units): + latestPumpVolume = units + infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", units) + "U", numericValue: units) + Storage.shared.lastPumpReservoirU.value = units + case .aboveReportingLimit: // Pumps that only report "50+" get treated as exactly 50, both // for the volume alarm and for the info row's coloring. latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0) Storage.shared.lastPumpReservoirU.value = nil + case .unknown: + // The row stays cleared, which the info table renders as an em dash. + latestPumpVolume = nil + Storage.shared.lastPumpReservoirU.value = nil } } diff --git a/LoopFollow/Controllers/Nightscout/PumpReservoir.swift b/LoopFollow/Controllers/Nightscout/PumpReservoir.swift new file mode 100644 index 000000000..ad843a296 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/PumpReservoir.swift @@ -0,0 +1,104 @@ +// LoopFollow +// PumpReservoir.swift + +import Foundation + +/// What is known about one pump's reservoir, carried between device status records. +struct PumpReservoirCache: Codable, Equatable { + struct Reading: Codable, Equatable { + let units: Double + let date: Date + } + + let pumpID: String + /// When this pump first appeared in a device status record LoopFollow fetched. + let pumpSince: Date + var reading: Reading? +} + +/// What a device status record says about the reservoir. +enum PumpReservoirState: Equatable { + /// An exact volume, from the record itself or from a recent reading for the same pump. + case units(Double) + /// A pump that reports a volume only once it drops below 50U, and is above it. + case aboveReportingLimit + /// No volume to show. + case unknown +} + +enum PumpReservoirResolver { + /// Omnipod reports the reservoir in only some of the device status records it uploads + /// while the pod is below 50U. A reading carries across those gaps for this long. + static let maxReadingAge: TimeInterval = 30 * 60 + + /// A volume reported within this long of a pump first appearing can still be the + /// previous pod's final reading, so it says nothing about the pump now on. + static let pumpSettleTime: TimeInterval = 15 * 60 + + struct Resolution: Equatable { + let state: PumpReservoirState + /// What to keep for the next record, `nil` to store nothing. + let cache: PumpReservoirCache? + } + + static func resolve( + reservoir: Double?, + pumpID: String?, + manufacturer: String?, + model: String?, + cache storedCache: PumpReservoirCache?, + now: Date + ) -> Resolution { + let withoutReading: PumpReservoirState = reportsVolumeOnlyWhenLow(manufacturer: manufacturer, model: model) + ? .aboveReportingLimit + : .unknown + + // A reading is carried between records only when the uploader names the pump it + // came from, so that a pod change discards it. + guard let pumpID = identifiedPump(pumpID) else { + guard let reservoir else { return Resolution(state: withoutReading, cache: nil) } + return Resolution(state: .units(reservoir), cache: nil) + } + + var cache = storedCache?.pumpID == pumpID + ? storedCache! + : PumpReservoirCache(pumpID: pumpID, pumpSince: now, reading: nil) + + if let reservoir { + cache.reading = PumpReservoirCache.Reading(units: reservoir, date: now) + return Resolution(state: .units(reservoir), cache: cache) + } + + if let reading = cache.reading { + let age = now.timeIntervalSince(reading.date) + if age >= 0, age <= maxReadingAge { + return Resolution(state: .units(reading.units), cache: cache) + } + if reading.date.timeIntervalSince(cache.pumpSince) >= pumpSettleTime { + // The pump has reported a volume long enough after coming online for that + // to be its own, so it is below its reporting limit and the only thing + // missing is a fresh number. A pump first seen mid-pod has no such gap, + // so its first reading is treated as a pod change's and dropped below. + return Resolution(state: .unknown, cache: cache) + } + cache.reading = nil + } + + return Resolution(state: withoutReading, cache: cache) + } + + private static func identifiedPump(_ pumpID: String?) -> String? { + let trimmed = pumpID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // Loop reports "Unknown" while no pod is paired. + guard !trimmed.isEmpty, trimmed != "Unknown" else { return nil } + return trimmed + } + + private static func reportsVolumeOnlyWhenLow(manufacturer: String?, model: String?) -> Bool { + let pump = [manufacturer, model].compactMap { $0 }.joined(separator: " ").lowercased() + // An uploader that names no pump cannot be told apart from one that reports its + // volume only when low, so it gets the same reading. + guard !pump.isEmpty else { return true } + return pump.contains("insulet") || pump.contains("omnipod") || pump.contains("dash") + } +} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4876924e2..8064dbf00 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -99,6 +99,7 @@ class Storage { // Live Activity extended InfoType data var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var pumpReservoirCache = StorageValue(key: "pumpReservoirCache", defaultValue: nil) var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 9c96252d5..06c5e1f76 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -103,7 +103,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var latestLoopStatusString = "" var latestCOB: CarbMetric? var latestBasal = "" - var latestPumpVolume: Double = 50.0 + var latestPumpVolume: Double? var latestIOB: InsulinMetric? var lastOverrideStartTime: TimeInterval = 0 var lastOverrideEndTime: TimeInterval = 0 diff --git a/Tests/PumpReservoirTests.swift b/Tests/PumpReservoirTests.swift new file mode 100644 index 000000000..625e43802 --- /dev/null +++ b/Tests/PumpReservoirTests.swift @@ -0,0 +1,151 @@ +// LoopFollow +// PumpReservoirTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct PumpReservoirTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let pump = "17CB71F7" + + private func resolve( + reservoir: Double? = nil, + pumpID: String? = "17CB71F7", + manufacturer: String? = "Insulet", + model: String? = "Omnipod DASH", + cache: PumpReservoirCache? = nil, + at date: Date? = nil + ) -> PumpReservoirResolver.Resolution { + PumpReservoirResolver.resolve( + reservoir: reservoir, + pumpID: pumpID, + manufacturer: manufacturer, + model: model, + cache: cache, + now: date ?? now + ) + } + + /// A pump that came online `settledFor` ago and reported `units` `readingAge` ago. + private func cache(units: Double, readingAge: TimeInterval, settledFor: TimeInterval = 60 * 60) -> PumpReservoirCache { + PumpReservoirCache( + pumpID: pump, + pumpSince: now.addingTimeInterval(-settledFor), + reading: .init(units: units, date: now.addingTimeInterval(-readingAge)) + ) + } + + @Test("a reported volume is used and kept for the pump it came from") + func reportedVolume() { + let result = resolve(reservoir: 12.5) + #expect(result.state == .units(12.5)) + #expect(result.cache?.pumpID == pump) + #expect(result.cache?.reading == .init(units: 12.5, date: now)) + } + + @Test("zero is a volume, not a missing reading") + func zeroVolume() { + #expect(resolve(reservoir: 0).state == .units(0)) + } + + @Test("a record without a volume reuses a recent reading from the same pump") + func carriesRecentReading() { + let result = resolve(cache: cache(units: 9.9, readingAge: 25 * 60)) + #expect(result.state == .units(9.9)) + #expect(result.cache?.reading?.units == 9.9) + } + + @Test("a reading older than 30 minutes is not shown, and does not become 50+") + func staleReadingIsUnknown() { + let result = resolve(cache: cache(units: 9.9, readingAge: 31 * 60)) + #expect(result.state == .unknown) + // Kept, so the next record still reads as unknown. + #expect(result.cache?.reading?.units == 9.9) + #expect(resolve(cache: result.cache, at: now.addingTimeInterval(5 * 60)).state == .unknown) + } + + @Test("a reading dated in the future is treated as stale") + func futureReadingIsUnknown() { + #expect(resolve(cache: cache(units: 9.9, readingAge: -60 * 60)).state == .unknown) + } + + @Test("a volume reported right after a pod change is shown but not trusted once stale") + func podChangeCarryoverIsDiscarded() { + // Loop's first records for a new pod still carry the previous pod's final volume. + let carryover = resolve(reservoir: 9.9, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: nil)) + #expect(carryover.state == .units(9.9)) + #expect(carryover.cache?.pumpID == pump) + + let laterOnTheSamePod = resolve(cache: carryover.cache, at: now.addingTimeInterval(31 * 60)) + #expect(laterOnTheSamePod.state == .aboveReportingLimit) + #expect(laterOnTheSamePod.cache?.pumpID == pump) + #expect(laterOnTheSamePod.cache?.reading == nil) + } + + @Test("a pod change drops the previous pod's reading") + func podChangeDropsReading() { + let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 9.9, date: now.addingTimeInterval(-5 * 60))) + let result = resolve(cache: previousPod) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache?.reading == nil) + #expect(result.cache?.pumpSince == now) + } + + @Test("an Omnipod that has never reported a volume reads as 50+") + func omnipodWithoutReading() { + #expect(resolve().state == .aboveReportingLimit) + #expect(resolve(manufacturer: "Insulet", model: "Dash").state == .aboveReportingLimit) + #expect(resolve(manufacturer: nil, model: "Omnipod").state == .aboveReportingLimit) + } + + @Test("a pump that reports its volume gives no number when the field is missing") + func otherPumpWithoutReading() { + let result = resolve(manufacturer: "Medtronic", model: "723") + #expect(result.state == .unknown) + #expect(result.cache?.pumpID == pump) + #expect(result.cache?.reading == nil) + } + + @Test("an uploader that names no pump resolves from the record alone") + func unidentifiedPump() { + // Trio and iAPS name no pump, so there is nothing to tie a reading to. + let withVolume = resolve(reservoir: 18, pumpID: nil, manufacturer: nil, model: nil) + #expect(withVolume.state == .units(18)) + #expect(withVolume.cache == nil) + + let withoutVolume = resolve(pumpID: nil, manufacturer: nil, model: nil) + #expect(withoutVolume.state == .aboveReportingLimit) + #expect(withoutVolume.cache == nil) + } + + @Test("a pump with no pod paired is not an identity to cache against") + func unknownPumpID() { + let result = resolve(pumpID: "Unknown", cache: cache(units: 9.9, readingAge: 5 * 60)) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache == nil) + } + + @Test("a settled pump stays unknown for as long as it reports nothing") + func settledUnknownPersists() { + var result = resolve(cache: cache(units: 9.9, readingAge: 31 * 60)) + #expect(result.state == .unknown) + result = resolve(cache: result.cache, at: now.addingTimeInterval(3 * 60 * 60)) + #expect(result.state == .unknown) + } + + @Test("a settled pump recovers as soon as it reports again") + func settledUnknownRecovers() { + let stale = resolve(cache: cache(units: 9.9, readingAge: 31 * 60)) + let reported = resolve(reservoir: 8.4, cache: stale.cache, at: now.addingTimeInterval(5 * 60)) + #expect(reported.state == .units(8.4)) + #expect(reported.cache?.reading?.units == 8.4) + } + + @Test("the cache survives a round trip through storage") + func cacheRoundTrips() throws { + let original = cache(units: 12.5, readingAge: 5 * 60) + let decoded = try JSONDecoder().decode(PumpReservoirCache.self, from: JSONEncoder().encode(original)) + #expect(decoded == original) + } +}