diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 525fcfcb2..f84052aba 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -116,6 +116,8 @@ final class BGChartModel: ObservableObject { } @Published var bg: [BGPoint] = [] + /// G7 readings the sensor marked unreliable: hollow orange dots. + @Published var unreliable: [BGPoint] = [] /// Scrub timeline on the BG cadence; rebuilt together with `bg`. private(set) var scrubSlots = BGChartScrubSlots(readingDates: []) @Published var bgRuns: [BGRun] = [] @@ -451,6 +453,9 @@ final class BGChartModel: ObservableObject { scrubSlots = BGChartScrubSlots(readingDates: vc.bgData.map { Date(timeIntervalSince1970: $0.date) }) bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) } bgRuns = Self.makeRuns(bg) + unreliable = vc.unreliableGraphData.map { + BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: .orange) + } // Yesterday comparison overlay (#665): already +24h shifted, dimmed gray, no dots. if Storage.shared.showYesterdayLine.value { diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index e2949839a..e5c5c47bf 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -819,6 +819,11 @@ private struct MainBGChart: View { "BG\n\(Localizer.toDisplayUnits(String(Int(point.value))))\n\(model.pillTimeString(for: point.date))" } + /// A reading the G7 marked unreliable: its warning in place of "BG". + private func unreliablePillText(for point: BGChartModel.BGPoint) -> String { + "\(model.pillTimeString(for: point.date))\n⚠︎ not reliable\n\(Localizer.toDisplayUnits(String(Int(point.value))))" + } + private func bandPillTexts(at date: Date) -> [String] { var texts: [String] = [] if let band = model.overrides @@ -889,11 +894,17 @@ private struct MainBGChart: View { } treatments.sort { $0.date < $1.date } let readings = model.bg.filter { slot.contains($0.date) } + // readings the G7 marked unreliable: listed after the reliable ones, and on a scrub shown in the pill + // where no reliable reading is, since the BG display has nothing for that time + let unreliable = model.unreliable.filter { slot.contains($0.date) } let bandTexts = bandPillTexts(at: mark) // Scrubbing shows no pill: the BG display and info table show the - // values at the scrubbed time. A tap lists what happened there. - let texts = purpose == .tap ? treatments.map(\.pillText) + readings.map(bgPillText) + bandTexts : [] + // values at the scrubbed time. A tap lists what happened there, readings the G7 marked unreliable after the + // reliable ones; a scrub over a block with no reliable reading shows the unreliable one. + let texts = purpose == .tap + ? treatments.map(\.pillText) + readings.map(bgPillText) + unreliable.map(unreliablePillText) + bandTexts + : (readings.isEmpty ? unreliable.map(unreliablePillText) : []) func distanceToMark(_ date: Date) -> TimeInterval { abs(date.timeIntervalSince(mark)) } @@ -901,6 +912,8 @@ private struct MainBGChart: View { let nearestReading = readings.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) if let reading = nearestReading { value = reading.value + } else if let nearest = unreliable.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { + value = nearest.value } else if let nearest = treatments.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { value = nearest.sgv } else if !bandTexts.isEmpty { @@ -938,6 +951,10 @@ private struct MainBGChart: View { for p in model.bg { consider(p.date, p.value, bgPillText(for: p), isReading: true) } + // the hollow dots the G7 marked unreliable + for p in model.unreliable { + consider(p.date, p.value, unreliablePillText(for: p)) + } } if best == nil { let date = interaction.scrollPosition.addingTimeInterval( @@ -1495,6 +1512,17 @@ private struct BGChartCanvas: View, Equatable { .foregroundStyle(pt.color) } } + // readings the G7 marked unreliable, also with dots off: hollow, so they never read as real ones + ForEach(windowed(model.unreliable) { $0.date }) { pt in + PointMark( + x: .value("time", pt.date), + y: .value("bg", pt.value) + ) + // the size of the normal dots, only hollow + .symbol(.circle.strokeBorder(lineWidth: 1.5)) + .symbolSize(isSmall ? 14 : 30) + .foregroundStyle(pt.color) + } } @ChartContentBuilder diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 969039ab6..f62a89fc6 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -76,6 +76,7 @@ extension MainViewController { var carbs: [[String: AnyObject]] = [] var temporaryOverride: [[String: AnyObject]] = [] var temporaryTarget: [[String: AnyObject]] = [] + var unreliable: [[String: AnyObject]] = [] var note: [[String: AnyObject]] = [] var bgCheck: [[String: AnyObject]] = [] var suspendPump: [[String: AnyObject]] = [] @@ -107,6 +108,8 @@ extension MainViewController { carbs.append(entry) case "Temporary Override", "Exercise": temporaryOverride.append(entry) + case "Unreliable Glucose": + unreliable.append(entry) case "Temporary Target": temporaryTarget.append(entry) case "Note": @@ -191,6 +194,8 @@ extension MainViewController { processNSOverrides(entries: temporaryOverride) } + processNSUnreliableGlucose(entries: unreliable) + if temporaryTarget.count == 0, tempTargetGraphData.count > 0 { clearOldTempTarget() } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/UnreliableGlucose.swift b/LoopFollow/Controllers/Nightscout/Treatments/UnreliableGlucose.swift new file mode 100644 index 000000000..ea8cffcff --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/Treatments/UnreliableGlucose.swift @@ -0,0 +1,29 @@ +// LoopFollow +// UnreliableGlucose.swift + +import Foundation + +extension MainViewController { + /// Trio ("Show Unreliable G7 Readings") uploads each Dexcom G7 reading the sensor marks unreliable as an + /// "Unreliable Glucose" treatment, never as an entry. Drawn as hollow orange dots, and the newest shown in orange + /// with a warning sign under the BG while it is newer than the last reliable reading. Never alarmed on, counted in + /// the statistics or used as BG. + func processNSUnreliableGlucose(entries: [[String: AnyObject]]) { + var readings: [ShareGlucoseData] = [] + for entry in entries { + guard let dateString = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let date = NightscoutUtils.parseDate(dateString)?.timeIntervalSince1970 + else { continue } + let value = (entry["glucose"] as? NSNumber)?.doubleValue ?? Double(entry["glucose"] as? String ?? "") + guard var mgdl = value, mgdl > 0 else { continue } + if let units = entry["units"] as? String, units.lowercased().hasPrefix("mmol") { mgdl *= 18.0182 } + readings.append(ShareGlucoseData(sgv: Int(mgdl.rounded()), date: date, direction: nil)) + } + readings.sort { $0.date < $1.date } + guard readings.map(\.date) != unreliableGraphData.map(\.date) else { return } + unreliableGraphData = readings + Observable.shared.unreliableBG.value = readings.last?.sgv + Observable.shared.unreliableBGDate.value = readings.last?.date + chartModel.rebuild() + } +} diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 229ee6c65..6f5cf3826 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -23,6 +23,10 @@ class Observable { var minAgoText = ObservableValue(default: "?? min ago") var bgText = ObservableValue(default: "BG") var bg = ObservableValue(default: nil) + /// The newest G7 reading the sensor marked unreliable, from Trio's "Unreliable Glucose" treatments (mg/dL and + /// epoch seconds): shown greyed with a warning, never alarmed on or counted. + var unreliableBG = ObservableValue(default: nil) + var unreliableBGDate = ObservableValue(default: nil) var bgStale = ObservableValue(default: true) var bgTextColor = ObservableValue(default: .primary) var directionText = ObservableValue(default: "-") diff --git a/LoopFollow/ViewControllers/BGDisplayView.swift b/LoopFollow/ViewControllers/BGDisplayView.swift index 036a92e56..65c2ce74e 100644 --- a/LoopFollow/ViewControllers/BGDisplayView.swift +++ b/LoopFollow/ViewControllers/BGDisplayView.swift @@ -18,6 +18,17 @@ struct BGDisplayView: View { @ObservedObject var isNotLooping = Observable.shared.isNotLooping @ObservedObject var cgmSensorState = Observable.shared.cgmSensorState @ObservedObject var retro = RetroSelection.shared + @ObservedObject var unreliableBG = Observable.shared.unreliableBG + @ObservedObject var unreliableBGDate = Observable.shared.unreliableBGDate + + /// The newest reading the G7 marked unreliable, while fresh and newer than the last reliable one. + private var unreliableText: String? { + _ = minAgoText.value + guard let mgdl = unreliableBG.value, let date = unreliableBGDate.value else { return nil } + let age = Date().timeIntervalSince1970 - date + guard age < 12 * 60, date > (Storage.shared.lastBgReadingTimeSeconds.value ?? 0) else { return nil } + return "\(Localizer.toDisplayUnits(String(mgdl))) not reliable · \(Int(age / 60)) min ago" + } var onRefresh: (() -> Void)? @@ -65,6 +76,13 @@ struct BGDisplayView: View { Text(minAgoText.value) .font(.system(size: 17)) + if let unreliableText { + Label(unreliableText, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.orange) + .frame(maxWidth: .infinity) + } + if let state = cgmSensorState.value { Text("CGM: \(state.summary)") .font(.system(size: 17, weight: .semibold)) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 501736488..bd1533e2e 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -86,6 +86,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var statsBasalData: [basalGraphStruct] = [] var overrideGraphData: [DataStructs.overrideStruct] = [] var tempTargetGraphData: [DataStructs.tempTargetStruct] = [] + /// G7 readings the sensor marked unreliable (Trio's "Unreliable Glucose" treatments), oldest first: hollow dots. + var unreliableGraphData: [ShareGlucoseData] = [] var predictionData: [ShareGlucoseData] = [] var ztPredictionData: [ShareGlucoseData] = [] var iobPredictionData: [ShareGlucoseData] = [] diff --git a/LoopFollow/Watch/WatchStateBuilder.swift b/LoopFollow/Watch/WatchStateBuilder.swift index 12a379c99..118cfced0 100644 --- a/LoopFollow/Watch/WatchStateBuilder.swift +++ b/LoopFollow/Watch/WatchStateBuilder.swift @@ -27,6 +27,10 @@ enum WatchStateBuilder { .filter { $0.date >= cutoff } .map { WatchReading(t: $0.date, v: $0.value) } + let unreliable = (vc?.unreliableGraphData ?? []) + .filter { $0.date >= cutoff && $0.sgv > 0 } + .map { WatchReading(t: $0.date, v: Double($0.sgv)) } + let overrides = (vc?.overrideGraphData ?? []) .filter { $0.endDate >= cutoff } .map { WatchOverride(start: $0.date, end: $0.endDate, name: $0.reason.isEmpty ? "Override" : $0.reason, percent: overridePercent($0)) } @@ -67,7 +71,8 @@ enum WatchStateBuilder { stats: vc.map { stats($0.statsDisplayModel) }, device: Storage.shared.device.value, alarm: currentAlarm(), - remote: remoteConfig() + remote: remoteConfig(), + unreliable: unreliable ) } diff --git a/LoopFollowWatch/ChartPage.swift b/LoopFollowWatch/ChartPage.swift index d8f17d7f9..31f653d92 100644 --- a/LoopFollowWatch/ChartPage.swift +++ b/LoopFollowWatch/ChartPage.swift @@ -79,6 +79,11 @@ struct ChartScrubView: View { let marker = markerDate(now: now) let nearest = state.readings.min { abs($0.date.timeIntervalSince(marker)) < abs($1.date.timeIntervalSince(marker)) } let reading = nearest.flatMap { abs($0.date.timeIntervalSince(marker)) <= 450 ? $0 : nil } + // without a reliable reading there, one the G7 marked unreliable, hollow as on the chart + let nearestUnsure = (state.unreliable ?? []).min { + abs($0.date.timeIntervalSince(marker)) < abs($1.date.timeIntervalSince(marker)) + } + let unsure = reading == nil ? nearestUnsure.flatMap { abs($0.date.timeIntervalSince(marker)) <= 450 ? $0 : nil } : nil let carbs = total(of: state.carbs, near: marker) let insulin = total(of: state.boluses, near: marker) return HStack(spacing: 8) { @@ -90,6 +95,13 @@ struct ChartScrubView: View { .fill(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) .frame(width: 7, height: 7) Text(WatchFormat.glucose(reading.v, unit: state.snapshot?.unit ?? .mgdl)) + } else if let unsure { + Circle() + .strokeBorder(Color.orange, lineWidth: 1) + .frame(width: 7, height: 7) + Text(WatchFormat.glucose(unsure.v, unit: state.snapshot?.unit ?? .mgdl)) + .foregroundStyle(.secondary) + .accessibilityLabel("\(WatchFormat.glucose(unsure.v, unit: state.snapshot?.unit ?? .mgdl)), not reliable") } else { Text("—") } diff --git a/LoopFollowWatch/GlucosePage.swift b/LoopFollowWatch/GlucosePage.swift index d07086dd2..744b37fff 100644 --- a/LoopFollowWatch/GlucosePage.swift +++ b/LoopFollowWatch/GlucosePage.swift @@ -19,6 +19,7 @@ struct GlucosePage: View { AlarmBanner(alarm: alarm) } glucose(now: context.date) + unreliableLine(now: context.date) if !isLuminanceReduced { details overrideLine(now: context.date) @@ -72,6 +73,25 @@ struct GlucosePage: View { } } + /// The newest reading the G7 marked unreliable, while fresh and newer than the last reliable one, as on the phone. + @ViewBuilder + private func unreliableLine(now: Date) -> some View { + if let unsure = state.unreliable?.last, + now.timeIntervalSince(unsure.date) < WatchFormat.staleAfter, + (state.snapshot?.updatedAt ?? .distantPast) < unsure.date + { + Label { + Text("\(WatchFormat.glucose(unsure.v, unit: state.snapshot?.unit ?? .mgdl)) not reliable · \(WatchFormat.minutesAgo(unsure.date, now: now))") + } icon: { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + } + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } + /// Age of the reading, with a marker while the watch fetches on its own or cannot reach the phone. @ViewBuilder private func age(now: Date) -> some View { @@ -199,7 +219,8 @@ struct Sparkline: View { var body: some View { let start = now.addingTimeInterval(-2 * 3600) let readings = state.readings.filter { $0.date >= start } - let values = readings.map(\.v) + let unreliable = (state.unreliable ?? []).filter { $0.date >= start } + let values = (readings + unreliable).map(\.v) let bottom = min(values.min() ?? state.lowMgdl, state.lowMgdl) - 10 let top = max(values.max() ?? state.highMgdl, state.highMgdl) + 10 Chart { @@ -214,6 +235,14 @@ struct Sparkline: View { .symbolSize(12) .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) } + ForEach(unreliable, id: \.t) { reading in + PointMark(x: .value("Time", reading.date), y: .value("BG", reading.v)) + .symbol { + Circle() + .strokeBorder(Color.orange, lineWidth: 1) + .frame(width: WatchChartView.diameter(12), height: WatchChartView.diameter(12)) + } + } } .chartXScale(domain: start ... now) .chartYScale(domain: bottom ... top) diff --git a/LoopFollowWatch/StandaloneFetcher.swift b/LoopFollowWatch/StandaloneFetcher.swift index 0617371c0..b2a17567a 100644 --- a/LoopFollowWatch/StandaloneFetcher.swift +++ b/LoopFollowWatch/StandaloneFetcher.swift @@ -150,10 +150,26 @@ enum StandaloneFetcher { stats: base?.stats, device: base?.device ?? "", alarm: nil, - remote: remote + remote: remote, + unreliable: unreliableReadings(treatments, cutoff: cutoff) ) } + /// Trio's "Unreliable Glucose" treatments, as the phone reads them (Untethered.swift), in mg/dL. + private static func unreliableReadings(_ treatments: [[String: Any]], cutoff: TimeInterval) -> [WatchReading] { + treatments.compactMap { entry -> WatchReading? in + guard entry["eventType"] as? String == "Unreliable Glucose", + let at = DeviceStatusSummary.date(entry["timestamp"] ?? entry["created_at"]), + at.timeIntervalSince1970 >= cutoff + else { return nil } + let value = (entry["glucose"] as? NSNumber)?.doubleValue ?? Double(entry["glucose"] as? String ?? "") + guard var mgdl = value, mgdl > 0 else { return nil } + if let units = entry["units"] as? String, units.lowercased().hasPrefix("mmol") { mgdl *= 18.0182 } + return WatchReading(t: at.timeIntervalSince1970, v: mgdl.rounded()) + } + .sorted { $0.t < $1.t } + } + private static func reading(_ treatment: [String: Any], key: String, cutoff: TimeInterval) -> WatchReading? { guard let value = treatment[key] as? Double, value > 0, let at = DeviceStatusSummary.date(treatment["timestamp"] ?? treatment["created_at"]), diff --git a/LoopFollowWatch/WatchChartView.swift b/LoopFollowWatch/WatchChartView.swift index 71cc3225f..963327b5f 100644 --- a/LoopFollowWatch/WatchChartView.swift +++ b/LoopFollowWatch/WatchChartView.swift @@ -21,12 +21,13 @@ struct WatchChartView: View { var body: some View { let window = visibleWindow let readings = state.readings.filter { window.contains($0.date) } + let unreliable = (state.unreliable ?? []).filter { window.contains($0.date) } let predictions = state.predictions.map { line in (line.kind, line.points.filter { window.contains($0.date) }) } let cone = state.cone.filter { window.contains($0.date) } let overrides = (state.overrides ?? []).filter { $0.endDate > window.lowerBound && $0.startDate < window.upperBound } - let top = yTop(readings: readings, predictions: predictions.flatMap(\.1) + cone.map { WatchReading(t: $0.t, v: $0.high) }) + let top = yTop(readings: readings + unreliable, predictions: predictions.flatMap(\.1) + cone.map { WatchReading(t: $0.t, v: $0.high) }) Chart { RuleMark(y: .value("High", state.highMgdl)) @@ -86,6 +87,19 @@ struct WatchChartView: View { .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) } + // readings the G7 marked unreliable: hollow, so they never read as real ones + ForEach(unreliable, id: \.t) { reading in + PointMark( + x: .value("Time", reading.date), + y: .value("BG", min(reading.v, top)) + ) + .symbol { + Circle() + .strokeBorder(Color.orange, lineWidth: 1) + .frame(width: Self.diameter(spanHours <= 3 ? 14 : 6), height: Self.diameter(spanHours <= 3 ? 14 : 6)) + } + } + ForEach(state.carbs.filter { window.contains($0.date) }, id: \.t) { carb in PointMark( x: .value("Time", carb.date), @@ -187,6 +201,11 @@ struct WatchChartView: View { return min(nearest?.v ?? 100, top - 30) } + /// The width of a filled dot of this symbol size (an area), for a hollow one to match. + static func diameter(_ symbolSize: CGFloat) -> CGFloat { + max(3, (symbolSize / .pi).squareRoot() * 2) + } + static func color(for kind: WatchPredictionLine.Kind) -> Color { switch kind { case .loop: .purple diff --git a/WatchShared/WatchState.swift b/WatchShared/WatchState.swift index 91c73c69c..2f6ad324b 100644 --- a/WatchShared/WatchState.swift +++ b/WatchShared/WatchState.swift @@ -41,6 +41,9 @@ struct WatchState: Codable, Equatable { var alarm: WatchAlarm? /// Present when remote commands from the watch are enabled on the phone. var remote: WatchRemoteConfig? + /// G7 readings the sensor marked unreliable (Trio's "Unreliable Glucose" treatments), mg/dL, oldest first: + /// hollow orange dots, never used as BG; nil from a phone that doesn't send them. + var unreliable: [WatchReading]? = nil func encoded() -> Data? { try? JSONEncoder().encode(self)