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) + } +}