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
98 changes: 73 additions & 25 deletions LoopFollow/Charts/BGChartHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ enum BGChartHistory {
}

enum PaneKind: Int, CaseIterable {
case iob, cob, sensitivityRatio
case iob, insulinActivity, cob, sensitivityRatio

var title: String {
switch self {
case .iob: return "IOB"
case .insulinActivity: return "Activity"
case .cob: return "COB"
case .sensitivityRatio: return "Sens. Ratio"
}
Expand All @@ -33,18 +34,29 @@ enum BGChartHistory {
var color: Color {
switch self {
case .iob: return Color("Insulin")
case .insulinActivity: return Color(.systemTeal)
case .cob: return Color(.systemOrange)
case .sensitivityRatio: return Color(.systemIndigo)
}
}

/// Values below zero, drawn mirrored above the overlay baseline.
var negativeColor: Color? {
switch self {
case .iob: return Color(.systemPink)
case .insulinActivity: return Color(.systemBrown)
case .cob, .sensitivityRatio: return nil
}
}

var fillsArea: Bool {
self == .iob || self == .cob
self == .iob || self == .insulinActivity || self == .cob
}

var isEnabled: Bool {
switch self {
case .iob: return Storage.shared.showIOBGraph.value
case .insulinActivity: return Storage.shared.showInsulinActivityGraph.value
case .cob: return Storage.shared.showCOBGraph.value
case .sensitivityRatio: return Storage.shared.showSensitivityRatioGraph.value && Storage.shared.device.value != "Loop"
}
Expand All @@ -53,15 +65,27 @@ enum BGChartHistory {
func value(of sample: DeviceStatusHistorySample) -> Double? {
switch self {
case .iob: return sample.iob
case .insulinActivity: return sample.insulinActivity
case .cob: return sample.cob
case .sensitivityRatio: return sample.sensitivityRatio
}
}

/// The value this kind takes from an insulin forecast, for the kinds it covers.
func forecastValue(of point: InsulinForecast.Point) -> Double? {
switch self {
case .iob: return point.iob
case .insulinActivity: return point.activity
case .cob, .sensitivityRatio: return nil
}
}

func format(_ value: Double) -> String {
switch self {
case .iob:
return Localizer.formatToLocalizedString(value, maxFractionDigits: 2) + "U"
case .insulinActivity:
return Localizer.formatToLocalizedString(value, maxFractionDigits: 2) + "U/h"
case .cob:
return Localizer.formatToLocalizedString(value, maxFractionDigits: 0) + "g"
case .sensitivityRatio:
Expand All @@ -73,6 +97,8 @@ enum BGChartHistory {
switch self {
case .iob:
return Localizer.formatToLocalizedString(value, maxFractionDigits: 1) + "U"
case .insulinActivity:
return Localizer.formatToLocalizedString(value, maxFractionDigits: 1) + "U/h"
default:
return format(value)
}
Expand All @@ -87,6 +113,8 @@ enum BGChartHistory {
let ticks: [Double]
/// Bottom of the area fill.
let baseline: Double
/// Insulin forecast ahead of the history, drawn dashed.
var forecast: [Point] = []
var id: Int { kind.rawValue }
}

Expand All @@ -95,34 +123,43 @@ enum BGChartHistory {
struct Overlay {
struct Series: Identifiable {
let kind: PaneKind
/// Negative IOB, drawn mirrored above the baseline.
/// Values below zero, drawn mirrored above the baseline.
let isNegative: Bool
let points: [Point]
var id: String { "\(kind.rawValue)-\(isNegative)" }
var color: Color { isNegative ? BGChartHistory.negativeIOBColor : kind.color }
/// Insulin forecast, drawn dashed.
var isForecast = false
var id: String { "\(kind.rawValue)-\(isNegative)-\(isForecast)" }
var color: Color { isNegative ? kind.negativeColor ?? kind.color : kind.color }
}

let series: [Series]
}

static let negativeIOBColor = Color(.systemPink)

static func overlay(kinds: [PaneKind], samples: [DeviceStatusHistorySample], bandTop: Double) -> Overlay? {
static func overlay(
kinds: [PaneKind],
samples: [DeviceStatusHistorySample],
forecast: InsulinForecast? = nil,
bandTop: Double
) -> Overlay? {
var series: [Overlay.Series] = []

for kind in kinds {
let points = points(from: samples, value: kind.value(of:))
guard let minValue = points.map(\.value).min(),
let maxValue = points.map(\.value).max()
else { continue }
let forecastPoints = forecastPoints(kind: kind, forecast: forecast)
let values = (points + forecastPoints).map(\.value)
guard let minValue = values.min(), let maxValue = values.max() else { continue }

if kind == .iob {
// Magnitude scale: negative IOB is mirrored upward in its own color.
if kind.negativeColor != nil {
// Magnitude scale: negative values are mirrored upward in their own color.
let magnitude = max(abs(minValue), maxValue, 1).rounded(.up)
let (positive, negative) = splitAtZero(points)
series.append(.init(kind: kind, isNegative: false, points: positive.map { scaled($0, abs($0.value) / magnitude * bandTop) }))
if !negative.isEmpty {
series.append(.init(kind: kind, isNegative: true, points: negative.map { scaled($0, abs($0.value) / magnitude * bandTop) }))
for (curve, isForecast) in [(points, false), (forecastPoints, true)] where !curve.isEmpty {
let (positive, negative) = splitAtZero(curve)
if !positive.isEmpty {
series.append(.init(kind: kind, isNegative: false, points: positive.map { scaled($0, abs($0.value) / magnitude * bandTop) }, isForecast: isForecast))
}
if !negative.isEmpty {
series.append(.init(kind: kind, isNegative: true, points: negative.map { scaled($0, abs($0.value) / magnitude * bandTop) }, isForecast: isForecast))
}
}
continue
}
Expand All @@ -137,11 +174,13 @@ enum BGChartHistory {
// Same range as the ratio graph, so a flat curve sits mid-band.
lower = min(minValue, 0.9)
upper = max(maxValue, 1.1)
case .iob:
case .iob, .insulinActivity:
continue
}
let span = max(upper - lower, .ulpOfOne)
series.append(.init(kind: kind, isNegative: false, points: points.map { scaled($0, ($0.value - lower) / span * bandTop) }))
for (curve, isForecast) in [(points, false), (forecastPoints, true)] where !curve.isEmpty {
series.append(.init(kind: kind, isNegative: false, points: curve.map { scaled($0, ($0.value - lower) / span * bandTop) }, isForecast: isForecast))
}
}

guard !series.isEmpty else { return nil }
Expand Down Expand Up @@ -223,17 +262,25 @@ enum BGChartHistory {
return points
}

static func pane(kind: PaneKind, samples: [DeviceStatusHistorySample]) -> Pane? {
/// The forecast's values for `kind` as one unbroken curve.
static func forecastPoints(kind: PaneKind, forecast: InsulinForecast?) -> [Point] {
guard let forecast else { return [] }
return forecast.points.compactMap { point in
kind.forecastValue(of: point).map { Point(date: Date(timeIntervalSince1970: point.date), value: $0, segment: 0) }
}
}

static func pane(kind: PaneKind, samples: [DeviceStatusHistorySample], forecast: InsulinForecast? = nil) -> Pane? {
let points = points(from: samples, value: kind.value(of:))
guard let minValue = points.map(\.value).min(),
let maxValue = points.map(\.value).max()
else { return nil }
let forecastPoints = forecastPoints(kind: kind, forecast: forecast)
let values = (points + forecastPoints).map(\.value)
guard let minValue = values.min(), let maxValue = values.max() else { return nil }

let lower: Double
let upper: Double
var ticks: [Double]
switch kind {
case .iob:
case .iob, .insulinActivity:
lower = min(0, minValue)
upper = max(1, maxValue.rounded(.up))
ticks = [0, upper]
Expand All @@ -254,7 +301,8 @@ enum BGChartHistory {
points: points,
yDomain: (lower - pad) ... (upper + pad),
ticks: ticks,
baseline: kind == .iob || kind == .cob ? 0 : lower - pad
baseline: kind.fillsArea ? 0 : lower - pad,
forecast: forecastPoints
)
}

Expand Down
10 changes: 10 additions & 0 deletions LoopFollow/Charts/BGChartHistoryPanes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ struct BGChartHistoryPaneCanvas: View, Equatable {
.lineStyle(StrokeStyle(lineWidth: 1.5))
.interpolationMethod(.linear)
}
ForEach(chartWindowedLine(pane.forecast, windowStart: windowStart, windowEnd: windowEnd) { $0.date }) { pt in
LineMark(
x: .value("time", pt.date),
y: .value(pane.kind.title, pt.value),
series: .value("series", "forecast")
)
.foregroundStyle(color)
.lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
.interpolationMethod(.linear)
}
}
.chartXScale(domain: windowStart ... windowEnd)
.chartYScale(domain: pane.yDomain)
Expand Down
19 changes: 16 additions & 3 deletions LoopFollow/Charts/BGChartModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -662,16 +662,29 @@ final class BGChartModel: ObservableObject {
let enabledKinds = BGChartHistory.PaneKind.allCases.filter(\.isEnabled)
let showSmoothed = Storage.shared.showSmoothedBG.value && Storage.shared.device.value != "Loop"
let startTime = start.timeIntervalSince1970
let samples = enabledKinds.isEmpty && !showSmoothed
var samples = enabledKinds.isEmpty && !showSmoothed
? []
: DeviceStatusHistory.shared.state.samples.filter { $0.date >= startTime }
// Loop's activity is computed from treatments, not read from devicestatus.
if enabledKinds.contains(.insulinActivity), let vc = MainViewController.shared, !vc.loopInsulinActivity.isEmpty {
samples += vc.loopInsulinActivity.filter { $0.date >= startTime }.map {
var sample = DeviceStatusHistorySample(date: $0.date)
sample.insulinActivity = $0.value
return sample
}
samples.sort { $0.date < $1.date }
}

// Same reach as the BG forecast lines.
let forecastEnd = now.timeIntervalSince1970 + Storage.shared.predictionToLoad.value * 3600
let forecast = MainViewController.shared?.insulinForecast?.clipped(to: forecastEnd)

if Storage.shared.historyCurvePlacement.value == .mainGraph {
historyPanes = []
// The band stays below the low line so it does not cover in-range readings.
historyOverlay = BGChartHistory.overlay(kinds: enabledKinds, samples: samples, bandTop: min(maxBG * 0.22, lowLine * 0.9))
historyOverlay = BGChartHistory.overlay(kinds: enabledKinds, samples: samples, forecast: forecast, bandTop: min(maxBG * 0.22, lowLine * 0.9))
} else {
historyPanes = enabledKinds.compactMap { BGChartHistory.pane(kind: $0, samples: samples) }
historyPanes = enabledKinds.compactMap { BGChartHistory.pane(kind: $0, samples: samples, forecast: forecast) }
historyOverlay = nil
}
smoothedBG = showSmoothed ? BGChartHistory.points(from: samples) { $0.smoothedBG } : []
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Charts/BGChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1508,7 +1508,7 @@ private struct BGChartCanvas: View, Equatable {
series: .value("series", "overlay-\(series.id)-\(pt.segment)")
)
.foregroundStyle(series.color)
.lineStyle(StrokeStyle(lineWidth: 1.5))
.lineStyle(StrokeStyle(lineWidth: 1.5, dash: series.isForecast ? [4, 3] : []))
}
}
}
Expand Down
7 changes: 4 additions & 3 deletions LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import Combine
import Foundation

/// History of the values the looping app reports in Nightscout devicestatus
/// records (IOB, COB, smoothed BG, sensitivity ratio, forecasts and the info
/// table values), shared by the history graphs and the scrubbed-time displays.
/// records (IOB, insulin activity, COB, smoothed BG, sensitivity ratio,
/// forecasts and the info table values), shared by the history graphs and the scrubbed-time displays.
///
/// Devicestatus records never change once written, so the extracted fields are
/// cached on disk and only missing spans are fetched: the Show Days Back window
Expand All @@ -28,7 +28,7 @@ final class DeviceStatusHistory {
}

private struct CacheFile: Codable {
static let currentVersion = 2
static let currentVersion = 3
let version: Int
let url: String
let state: DeviceStatusHistoryState
Expand Down Expand Up @@ -82,6 +82,7 @@ final class DeviceStatusHistory {
Publishers.MergeMany(
s.showIOBGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(),
s.showCOBGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(),
s.showInsulinActivityGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(),
s.showSensitivityRatioGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(),
s.showSmoothedBG.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(),
s.historyCurvePlacement.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ struct DeviceStatusHistorySample: Codable, Equatable {
/// Time of the loop cycle that produced the record.
let date: TimeInterval
var iob: Double?
/// OpenAPS/Trio `iob.activity` in U/h: the insulin acting at that moment,
/// net of the scheduled basal.
var insulinActivity: Double?
var cob: Double?
/// Glucose the OpenAPS/Trio algorithm ran on (`suggested.bg`); smoothed in Trio.
var smoothedBG: Double?
Expand Down Expand Up @@ -89,7 +92,8 @@ enum DeviceStatusHistoryParser {
guard let date = determinationDate ?? createdAt else { return nil }

var sample = DeviceStatusHistorySample(date: date.timeIntervalSince1970)
sample.iob = openAPSIOB(openAPS["iob"])
sample.iob = openAPSIOB(openAPS["iob"], key: "iob")
sample.insulinActivity = openAPSIOB(openAPS["iob"], key: "activity").map { $0 * 60 }
sample.cob = number(determination["COB"]) ?? reasonValue("COB", in: determination["reason"] as? String)
sample.smoothedBG = number(determination["bg"]).flatMap { $0 > 0 ? $0 : nil }
sample.isf = number(determination["ISF"]).flatMap { $0 > 0 ? $0 : nil }
Expand Down Expand Up @@ -124,9 +128,9 @@ enum DeviceStatusHistoryParser {

/// `openaps.iob` is a dictionary on Trio/AAPS and an array of projections on
/// oref0 rigs, where the first element is the current value.
private static func openAPSIOB(_ value: Any?) -> Double? {
if let dict = value as? [String: Any] { return number(dict["iob"]) }
if let array = value as? [[String: Any]] { return array.first.flatMap { number($0["iob"]) } }
private static func openAPSIOB(_ value: Any?, key: String) -> Double? {
if let dict = value as? [String: Any] { return number(dict[key]) }
if let array = value as? [[String: Any]] { return array.first.flatMap { number($0[key]) } }
return nil
}

Expand Down
6 changes: 6 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ extension MainViewController {
latestIOB = iobMetric
Observable.shared.iobText.value = iobMetric.formattedValue()
}
insulinForecast = InsulinForecast.parseOpenAPS(lastLoopRecord)
if let activity = (lastLoopRecord["iob"] as? [String: AnyObject])?["activity"] as? Double {
infoManager.updateInsulinActivity(activity * 60)
} else {
infoManager.clearInfoData(type: .insulinActivity)
}

// COB
if let cobMetric = CarbMetric(from: enactedOrSuggested, key: "COB") {
Expand Down
47 changes: 47 additions & 0 deletions LoopFollow/Controllers/Nightscout/InsulinForecast.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// LoopFollow
// InsulinForecast.swift

import Foundation

/// Where IOB and insulin activity are heading for the insulin already
/// delivered and the running temp basal until it ends, with no further
/// dosing. OpenAPS/Trio upload it as `openaps.iobForecast`; for Loop it is
/// computed from treatments (see `LoopInsulinActivity`).
struct InsulinForecast: Equatable {
struct Point: Equatable {
let date: TimeInterval
let iob: Double
/// U/h.
let activity: Double
}

let points: [Point]

static let step: TimeInterval = 5 * 60

/// Reads `openaps.iobForecast` (`iob` in U and `activity` in U/min, every
/// five minutes from `openaps.iob.time`).
static func parseOpenAPS(_ openAPS: [String: Any]) -> InsulinForecast? {
guard let forecast = openAPS["iobForecast"] as? [String: Any],
let iob = forecast["iob"] as? [Any],
let activity = forecast["activity"] as? [Any],
let timeString = (openAPS["iob"] as? [String: Any])?["time"] as? String,
let start = DeviceStatusHistoryParser.parseDate(timeString)?.timeIntervalSince1970
else { return nil }

let points = zip(iob, activity).enumerated().compactMap { index, pair -> Point? in
guard let iob = (pair.0 as? NSNumber)?.doubleValue,
let activity = (pair.1 as? NSNumber)?.doubleValue,
iob.isFinite, activity.isFinite
else { return nil }
return Point(date: start + Double(index) * step, iob: iob, activity: activity * 60)
}
return points.count > 1 ? InsulinForecast(points: points) : nil
}

/// The points up to `end`.
func clipped(to end: TimeInterval) -> InsulinForecast? {
let kept = points.filter { $0.date <= end }
return kept.count > 1 ? InsulinForecast(points: kept) : nil
}
}
Loading