Skip to content
Open
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
25 changes: 20 additions & 5 deletions LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
104 changes: 104 additions & 0 deletions LoopFollow/Controllers/Nightscout/PumpReservoir.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions LoopFollow/Storage/Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class Storage {
// Live Activity extended InfoType data
var lastBasal = StorageValue<String>(key: "lastBasal", defaultValue: "")
var lastPumpReservoirU = StorageValue<Double?>(key: "lastPumpReservoirU", defaultValue: nil)
var pumpReservoirCache = StorageValue<PumpReservoirCache?>(key: "pumpReservoirCache", defaultValue: nil)
var lastAutosens = StorageValue<Double?>(key: "lastAutosens", defaultValue: nil)
var lastTdd = StorageValue<Double?>(key: "lastTdd", defaultValue: nil)
var lastTargetLowMgdl = StorageValue<Double?>(key: "lastTargetLowMgdl", defaultValue: nil)
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/ViewControllers/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 151 additions & 0 deletions Tests/PumpReservoirTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading