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
5 changes: 5 additions & 0 deletions LoopFollow/Charts/BGChartModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 30 additions & 2 deletions LoopFollow/Charts/BGChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -889,18 +894,26 @@ 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)) }

var value: Double?
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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions LoopFollow/Controllers/Nightscout/Treatments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -191,6 +194,8 @@ extension MainViewController {
processNSOverrides(entries: temporaryOverride)
}

processNSUnreliableGlucose(entries: unreliable)

if temporaryTarget.count == 0, tempTargetGraphData.count > 0 {
clearOldTempTarget()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
4 changes: 4 additions & 0 deletions LoopFollow/Storage/Observable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class Observable {
var minAgoText = ObservableValue<String>(default: "?? min ago")
var bgText = ObservableValue<String>(default: "BG")
var bg = ObservableValue<Int?>(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<Int?>(default: nil)
var unreliableBGDate = ObservableValue<TimeInterval?>(default: nil)
var bgStale = ObservableValue<Bool>(default: true)
var bgTextColor = ObservableValue<Color>(default: .primary)
var directionText = ObservableValue<String>(default: "-")
Expand Down
18 changes: 18 additions & 0 deletions LoopFollow/ViewControllers/BGDisplayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?

Expand Down Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions LoopFollow/ViewControllers/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
7 changes: 6 additions & 1 deletion LoopFollow/Watch/WatchStateBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
Expand Down Expand Up @@ -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
)
}

Expand Down
12 changes: 12 additions & 0 deletions LoopFollowWatch/ChartPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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("—")
}
Expand Down
31 changes: 30 additions & 1 deletion LoopFollowWatch/GlucosePage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion LoopFollowWatch/StandaloneFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
21 changes: 20 additions & 1 deletion LoopFollowWatch/WatchChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions WatchShared/WatchState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down