Skip to content
Merged
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
7 changes: 6 additions & 1 deletion LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ extension MainViewController {
pumpID: lastPumpRecord["pumpID"] as? String,
manufacturer: lastPumpRecord["manufacturer"] as? String,
model: lastPumpRecord["model"] as? String,
displayOverride: lastPumpRecord["reservoir_display_override"] as? String,
cache: Storage.shared.pumpReservoirCache.value,
now: Date()
date: Date(timeIntervalSince1970: lastPumpTime)
)
Storage.shared.pumpReservoirCache.value = reservoir.cache

Expand All @@ -133,6 +134,10 @@ extension MainViewController {
latestPumpVolume = 50.0
infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0)
Storage.shared.lastPumpReservoirU.value = nil
case let .status(status):
latestPumpVolume = nil
infoManager.updateInfoData(type: .pump, value: status)
Storage.shared.lastPumpReservoirU.value = nil
case .unknown:
// The row stays cleared, which the info table renders as an em dash.
latestPumpVolume = nil
Expand Down
116 changes: 86 additions & 30 deletions LoopFollow/Controllers/Nightscout/PumpReservoir.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ struct PumpReservoirCache: Codable, Equatable {
/// When this pump first appeared in a device status record LoopFollow fetched.
let pumpSince: Date
var reading: Reading?
/// Whether a volume in a record is this pump's own. Right after a pod change Loop
/// still uploads the previous pod's final volume.
var isSettled: Bool

init(pumpID: String, pumpSince: Date, reading: Reading? = nil, isSettled: Bool = true) {
self.pumpID = pumpID
self.pumpSince = pumpSince
self.reading = reading
self.isSettled = isSettled
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
pumpID = try container.decode(String.self, forKey: .pumpID)
pumpSince = try container.decode(Date.self, forKey: .pumpSince)
reading = try container.decodeIfPresent(Reading.self, forKey: .reading)
isSettled = try container.decodeIfPresent(Bool.self, forKey: .isSettled) ?? true
}
}

/// What a device status record says about the reservoir.
Expand All @@ -22,6 +40,8 @@ enum PumpReservoirState: Equatable {
case units(Double)
/// A pump that reports a volume only once it drops below 50U, and is above it.
case aboveReportingLimit
/// The status the uploader shows in place of the volume, such as "No Pod".
case status(String)
/// No volume to show.
case unknown
}
Expand All @@ -31,8 +51,9 @@ enum PumpReservoirResolver {
/// 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.
/// Loop uploads its last reservoir reading until it is 15 minutes old, whichever pod it
/// came from, so the previous pod's final volume can appear for up to this long after a
/// pod change. A new pod reports no volume of its own until it drops below 50U.
static let pumpSettleTime: TimeInterval = 15 * 60

struct Resolution: Equatable {
Expand All @@ -41,59 +62,94 @@ enum PumpReservoirResolver {
let cache: PumpReservoirCache?
}

/// - Parameters:
/// - displayOverride: The status Loop shows in place of the reservoir, such as
/// "Finish Setup" or "No Pod", in the uploader's language.
/// - date: When the record was made, from the pump clock it carries.
static func resolve(
reservoir: Double?,
pumpID: String?,
manufacturer: String?,
model: String?,
displayOverride: String? = nil,
cache storedCache: PumpReservoirCache?,
date: Date
) -> Resolution {
let resolution = resolveVolume(
reservoir: reservoir,
pumpID: pumpID,
manufacturer: manufacturer,
model: model,
cache: storedCache,
date: date
)
// The pump keeps being tracked while its status replaces the volume.
if let status = displayOverride?.trimmingCharacters(in: .whitespacesAndNewlines), !status.isEmpty {
return Resolution(state: .status(status), cache: resolution.cache)
}
return resolution
}

private static func resolveVolume(
reservoir: Double?,
pumpID: String?,
manufacturer: String?,
model: String?,
cache storedCache: PumpReservoirCache?,
now: Date
date: Date
) -> Resolution {
let withoutReading: PumpReservoirState = reportsVolumeOnlyWhenLow(manufacturer: manufacturer, model: model)
? .aboveReportingLimit
: .unknown
let onlyWhenLow = reportsVolumeOnlyWhenLow(manufacturer: manufacturer, model: model)
let withoutReading: PumpReservoirState = onlyWhenLow ? .aboveReportingLimit : .unknown

let pumpID = pumpID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

// Loop reports an Omnipod as "Unknown" while no pod is paired, so there is no volume
// to show and one in the record is the previous pod's. Medtrum is always "Unknown"
// on Loop.
if pumpID == "Unknown", onlyWhenLow {
return Resolution(state: .unknown, cache: storedCache)
}

// 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)
// came from, so that a pod change discards it. The stored pump is kept across
// records that name none, so the next one reads as a change.
guard !pumpID.isEmpty, pumpID != "Unknown" else {
guard let reservoir else { return Resolution(state: withoutReading, cache: storedCache) }
return Resolution(state: .units(reservoir), cache: storedCache)
}

var cache = storedCache?.pumpID == pumpID
? storedCache!
: PumpReservoirCache(pumpID: pumpID, pumpSince: now, reading: nil)
var cache: PumpReservoirCache
if let storedCache, storedCache.pumpID == pumpID {
cache = storedCache
} else {
// With no pump stored there is no change to have seen, so the pump is taken
// as one already in use.
cache = PumpReservoirCache(pumpID: pumpID, pumpSince: date, isSettled: storedCache == nil || !onlyWhenLow)
}

if !cache.isSettled, reservoir == nil || date.timeIntervalSince(cache.pumpSince) >= pumpSettleTime {
cache.isSettled = true
}

if let reservoir {
cache.reading = PumpReservoirCache.Reading(units: reservoir, date: now)
guard cache.isSettled else { return Resolution(state: withoutReading, cache: cache) }
cache.reading = PumpReservoirCache.Reading(units: reservoir, date: date)
return Resolution(state: .units(reservoir), cache: cache)
}

if let reading = cache.reading {
let age = now.timeIntervalSince(reading.date)
let age = date.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
// The pump has reported a volume of its own, so it is below its reporting limit
// and the only thing missing is a fresh number.
return Resolution(state: .unknown, cache: cache)
}

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
Expand Down
117 changes: 104 additions & 13 deletions Tests/PumpReservoirTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct PumpReservoirTests {
pumpID: String? = "17CB71F7",
manufacturer: String? = "Insulet",
model: String? = "Omnipod DASH",
displayOverride: String? = nil,
cache: PumpReservoirCache? = nil,
at date: Date? = nil
) -> PumpReservoirResolver.Resolution {
Expand All @@ -22,8 +23,9 @@ struct PumpReservoirTests {
pumpID: pumpID,
manufacturer: manufacturer,
model: model,
displayOverride: displayOverride,
cache: cache,
now: date ?? now
date: date ?? now
)
}

Expand Down Expand Up @@ -70,17 +72,59 @@ struct PumpReservoirTests {
#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() {
@Test("a volume reported right after a pod change is the previous pod's and is not shown")
func podChangeCarryoverIsIgnored() {
// 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 previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 25.6, date: now.addingTimeInterval(-5 * 60)))
var result = resolve(reservoir: 25.5, cache: previousPod)
#expect(result.state == .aboveReportingLimit)
#expect(result.cache?.pumpID == pump)
#expect(result.cache?.reading == nil)

result = resolve(reservoir: 25.5, cache: result.cache, at: now.addingTimeInterval(10 * 60))
#expect(result.state == .aboveReportingLimit)
#expect(result.cache?.reading == nil)

result = resolve(cache: result.cache, at: now.addingTimeInterval(15 * 60))
#expect(result.state == .aboveReportingLimit)
#expect(result.cache?.isSettled == true)
}

@Test("a new pod's volumes count once it has uploaded a record without one")
func podChangeSettlesOnMissingVolume() {
let changed = resolve(reservoir: 25.5, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60)))
let gap = resolve(cache: changed.cache, at: now.addingTimeInterval(5 * 60))
let reported = resolve(reservoir: 48.5, cache: gap.cache, at: now.addingTimeInterval(10 * 60))
#expect(reported.state == .units(48.5))
#expect(reported.cache?.reading?.units == 48.5)
}

@Test("a new pod's volumes count 15 minutes after it appeared")
func podChangeSettlesAfterTime() {
let changed = resolve(reservoir: 25.5, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60)))
#expect(resolve(reservoir: 25.5, cache: changed.cache, at: now.addingTimeInterval(14 * 60)).state == .aboveReportingLimit)
#expect(resolve(reservoir: 48.5, cache: changed.cache, at: now.addingTimeInterval(15 * 60)).state == .units(48.5))
}

@Test("a pump change is seen across records that name no pump")
func podChangeAcrossUnknown() {
let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 43.3, date: now.addingTimeInterval(-10 * 60)))
let pairing = resolve(pumpID: "Unknown", cache: previousPod, at: now.addingTimeInterval(-5 * 60))
#expect(pairing.cache == previousPod)
#expect(resolve(reservoir: 42.9, cache: pairing.cache).state == .aboveReportingLimit)
}

@Test("a pump seen with nothing stored has its volume taken as its own")
func firstSeenPumpIsSettled() {
let result = resolve(reservoir: 25.5)
#expect(result.state == .units(25.5))
#expect(result.cache?.isSettled == true)
}

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 pump that reports its volume in every record is used right after a change")
func otherPumpChangeIsSettled() {
let previous = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60))
#expect(resolve(reservoir: 180, manufacturer: "Medtronic", model: "723", cache: previous).state == .units(180))
}

@Test("a pod change drops the previous pod's reading")
Expand Down Expand Up @@ -119,10 +163,49 @@ struct PumpReservoirTests {
#expect(withoutVolume.cache == nil)
}

@Test("a pump with no pod paired is not an identity to cache against")
@Test("an Omnipod with no pod paired shows no volume and keeps the stored pump")
func unknownPumpID() {
let result = resolve(pumpID: "Unknown", cache: cache(units: 9.9, readingAge: 5 * 60))
#expect(result.state == .aboveReportingLimit)
let stored = cache(units: 9.9, readingAge: 5 * 60)
let result = resolve(pumpID: "Unknown", cache: stored)
#expect(result.state == .unknown)
#expect(result.cache == stored)
}

@Test("a volume uploaded while no pod is paired is the previous pod's")
func unknownPumpIDWithVolume() {
let stored = cache(units: 12.6, readingAge: 5 * 60)
let result = resolve(reservoir: 12.5, pumpID: "Unknown", cache: stored)
#expect(result.state == .unknown)
#expect(result.cache == stored)
}

@Test("a status Loop shows in place of the reservoir is shown instead of a volume")
func displayOverride() {
let stored = cache(units: 9.9, readingAge: 5 * 60)
let result = resolve(displayOverride: " Signal Loss ", cache: stored)
#expect(result.state == .status("Signal Loss"))
#expect(result.cache == stored)
}

@Test("a new pod finishing setup shows its status and is still seen as a change")
func displayOverrideDuringSetup() {
let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 21.0, date: now.addingTimeInterval(-5 * 60)))
let setup = resolve(reservoir: 21.0, displayOverride: "Finish Setup", cache: previousPod)
#expect(setup.state == .status("Finish Setup"))
#expect(setup.cache?.pumpID == pump)
#expect(setup.cache?.reading == nil)
#expect(resolve(reservoir: 21.0, cache: setup.cache, at: now.addingTimeInterval(5 * 60)).state == .aboveReportingLimit)
}

@Test("an empty status shows the volume")
func emptyDisplayOverride() {
#expect(resolve(reservoir: 21.0, displayOverride: " ", cache: cache(units: 21.0, readingAge: 5 * 60)).state == .units(21.0))
}

@Test("a pump Loop names Unknown that reports its volume in every record shows it")
func unknownPumpIDOtherPump() {
let result = resolve(reservoir: 185.6, pumpID: "Unknown", manufacturer: "Medtrum", model: "MD8201")
#expect(result.state == .units(185.6))
#expect(result.cache == nil)
}

Expand All @@ -148,4 +231,12 @@ struct PumpReservoirTests {
let decoded = try JSONDecoder().decode(PumpReservoirCache.self, from: JSONEncoder().encode(original))
#expect(decoded == original)
}

@Test("a cache stored without the settled flag decodes as settled")
func cacheDecodesWithoutSettledFlag() throws {
let json = #"{"pumpID":"17CB71F7","pumpSince":0}"#
let decoded = try JSONDecoder().decode(PumpReservoirCache.self, from: Data(json.utf8))
#expect(decoded.isSettled)
#expect(decoded.reading == nil)
}
}