Skip to content

Commit 762e4ae

Browse files
authored
Add cone of uncertainty for Trio/OpenAPS predictions (#605)
Replace individual prediction lines (ZT, IOB, COB, UAM) with a filled cone that shows the min/max envelope across all prediction arrays, matching Trio's native forecast visualization. - ConeChartDataEntry and ConeOfUncertaintyRenderer draw the filled band via a custom CGContext polygon in the Charts library - PredictionDisplayType setting (.cone / .lines) lets Trio users switch between styles; Loop users are locked to lines - Reactive Combine subscription auto-sets the display type when the device changes (Loop -> lines, Trio -> cone) while preserving manual user preference across Nightscout refreshes - Cone dataset added at index 18 in both main and small BG graphs
1 parent aa240b7 commit 762e4ae

8 files changed

Lines changed: 271 additions & 36 deletions

File tree

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; };
100100
DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */; };
101101
DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; };
102+
DD1D52C22E4C100000000002 /* PredictionDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */; };
102103
DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; };
103104
DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
104105
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; };
@@ -548,6 +549,7 @@
548549
DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = "<group>"; };
549550
DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuViewController.swift; sourceTree = "<group>"; };
550551
DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = "<group>"; };
552+
DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionDisplayType.swift; sourceTree = "<group>"; };
551553
DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = "<group>"; };
552554
DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
553555
DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = "<group>"; };
@@ -1667,6 +1669,7 @@
16671669
656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */,
16681670
DD4A407D2E6AFEE6007B318B /* AuthService.swift */,
16691671
DD1D52BF2E4C100000000001 /* AppearanceMode.swift */,
1672+
DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */,
16701673
DD1D52B82E1EB5DC00432050 /* TabPosition.swift */,
16711674
DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */,
16721675
DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */,
@@ -2345,6 +2348,7 @@
23452348
DD7F4C112DD51ED900D449E9 /* TempTargetStartAlarmEditor.swift in Sources */,
23462349
DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */,
23472350
DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */,
2351+
DD1D52C22E4C100000000002 /* PredictionDisplayType.swift in Sources */,
23482352
DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */,
23492353
654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */,
23502354
6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */,

LoopFollow/Controllers/Graphs.swift

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ enum GraphDataIndex: Int {
2626
case uamPrediction = 15
2727
case smb = 16
2828
case tempTarget = 17
29+
case predictionCone = 18
2930
}
3031

3132
extension GraphDataIndex {
@@ -49,15 +50,17 @@ extension GraphDataIndex {
4950
case .uamPrediction: return "UAM Prediction"
5051
case .smb: return "SMB"
5152
case .tempTarget: return "Temp Target"
53+
case .predictionCone: return "Prediction Cone"
5254
}
5355
}
5456
}
5557

5658
class CompositeRenderer: LineChartRenderer {
5759
let tempTargetRenderer: TempTargetRenderer
5860
let triangleRenderer: TriangleRenderer
61+
let coneRenderer: ConeOfUncertaintyRenderer
5962

60-
init(dataProvider: LineChartDataProvider?, animator: Animator?, viewPortHandler: ViewPortHandler?, tempTargetDataSetIndex: Int, smbDataSetIndex: Int) {
63+
init(dataProvider: LineChartDataProvider?, animator: Animator?, viewPortHandler: ViewPortHandler?, tempTargetDataSetIndex: Int, smbDataSetIndex: Int, coneDataSetIndex: Int) {
6164
tempTargetRenderer = TempTargetRenderer(
6265
dataProvider: dataProvider,
6366
animator: animator,
@@ -70,11 +73,18 @@ class CompositeRenderer: LineChartRenderer {
7073
viewPortHandler: viewPortHandler,
7174
smbDataSetIndex: smbDataSetIndex
7275
)
76+
coneRenderer = ConeOfUncertaintyRenderer(
77+
dataProvider: dataProvider,
78+
animator: animator,
79+
viewPortHandler: viewPortHandler,
80+
coneDataSetIndex: coneDataSetIndex
81+
)
7382
super.init(dataProvider: dataProvider!, animator: animator!, viewPortHandler: viewPortHandler!)
7483
}
7584

7685
override func drawExtras(context: CGContext) {
7786
super.drawExtras(context: context)
87+
coneRenderer.drawExtras(context: context)
7888
tempTargetRenderer.drawExtras(context: context)
7989
triangleRenderer.drawExtras(context: context)
8090
}
@@ -204,13 +214,15 @@ extension MainViewController {
204214
func updateChartRenderers() {
205215
let tempTargetDataIndex = GraphDataIndex.tempTarget.rawValue
206216
let smbDataIndex = GraphDataIndex.smb.rawValue
217+
let coneDataIndex = GraphDataIndex.predictionCone.rawValue
207218

208219
let compositeRenderer = CompositeRenderer(
209220
dataProvider: BGChart,
210221
animator: BGChart.chartAnimator,
211222
viewPortHandler: BGChart.viewPortHandler,
212223
tempTargetDataSetIndex: tempTargetDataIndex,
213-
smbDataSetIndex: smbDataIndex
224+
smbDataSetIndex: smbDataIndex,
225+
coneDataSetIndex: coneDataIndex
214226
)
215227
BGChart.renderer = compositeRenderer
216228

@@ -595,7 +607,16 @@ extension MainViewController {
595607
data.append(COBlinePrediction) // Dataset 14
596608
data.append(UAMlinePrediction) // Dataset 15
597609
data.append(lineSmb) // Dataset 16
598-
data.append(lineTempTarget)
610+
data.append(lineTempTarget) // Dataset 17
611+
612+
// Dataset 18: Prediction Cone (rendered via ConeOfUncertaintyRenderer)
613+
let lineCone = LineChartDataSet(entries: [ChartDataEntry](), label: "")
614+
lineCone.lineWidth = 0
615+
lineCone.drawCirclesEnabled = false
616+
lineCone.drawValuesEnabled = false
617+
lineCone.highlightEnabled = false
618+
lineCone.axisDependency = YAxis.AxisDependency.right
619+
data.append(lineCone)
599620

600621
data.setValueFont(UIFont.systemFont(ofSize: 12))
601622

@@ -783,6 +804,9 @@ extension MainViewController {
783804
BGChart.data?.dataSets[dataIndex].notifyDataSetChanged()
784805
BGChart.data?.notifyDataChanged()
785806
BGChart.notifyDataSetChanged()
807+
808+
// Re-render prediction display in case display type changed
809+
updateOpenAPSPredictionDisplay()
786810
}
787811

788812
func updateBGGraph() {
@@ -1605,7 +1629,16 @@ extension MainViewController {
16051629
data.append(COBlinePrediction) // Dataset 14
16061630
data.append(UAMlinePrediction) // Dataset 15
16071631
data.append(lineSmb) // Dataset 16
1608-
data.append(lineTempTarget)
1632+
data.append(lineTempTarget) // Dataset 17
1633+
1634+
// Dataset 18: Prediction Cone placeholder (not rendered on small chart)
1635+
let lineConeSmall = LineChartDataSet(entries: [ChartDataEntry](), label: "")
1636+
lineConeSmall.lineWidth = 0
1637+
lineConeSmall.drawCirclesEnabled = false
1638+
lineConeSmall.drawValuesEnabled = false
1639+
lineConeSmall.highlightEnabled = false
1640+
lineConeSmall.axisDependency = YAxis.AxisDependency.right
1641+
data.append(lineConeSmall)
16091642

16101643
BGChartFull.highlightPerDragEnabled = true
16111644
BGChartFull.leftAxis.enabled = false
@@ -1902,6 +1935,104 @@ extension MainViewController {
19021935
}
19031936
}
19041937

1938+
func updateConeGraph(coneData: [ConeChartDataEntry]) {
1939+
let dataIndex = GraphDataIndex.predictionCone.rawValue
1940+
let mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet
1941+
mainChart.clear()
1942+
for entry in coneData {
1943+
mainChart.addEntry(entry)
1944+
}
1945+
BGChart.rightAxis.axisMaximum = Double(calculateMaxBgGraphValue())
1946+
updateChartRenderers()
1947+
}
1948+
1949+
func clearConeGraph() {
1950+
let dataIndex = GraphDataIndex.predictionCone.rawValue
1951+
guard let lineData = BGChart.lineData, lineData.dataSets.count > dataIndex else { return }
1952+
lineData.dataSets[dataIndex].clear()
1953+
BGChart.data?.dataSets[dataIndex].notifyDataSetChanged()
1954+
BGChart.data?.notifyDataChanged()
1955+
BGChart.notifyDataSetChanged()
1956+
}
1957+
1958+
func updateOpenAPSPredictionDisplay() {
1959+
guard let predBGs = openAPSPredBGs else { return }
1960+
1961+
// Cone is only for OpenAPS-based systems; Loop always uses lines
1962+
let displayType: PredictionDisplayType = Storage.shared.device.value == "Loop" ? .lines : Storage.shared.predictionDisplayType.value
1963+
let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
1964+
let predictionStart = openAPSPredUpdatedTime ?? Date().timeIntervalSince1970
1965+
1966+
let predictionTypes: [(type: String, colorName: String, dataIndex: Int)] = [
1967+
("ZT", "ZT", GraphDataIndex.ztPrediction.rawValue),
1968+
("IOB", "Insulin", GraphDataIndex.iobPrediction.rawValue),
1969+
("COB", "LoopYellow", GraphDataIndex.cobPrediction.rawValue),
1970+
("UAM", "UAM", GraphDataIndex.uamPrediction.rawValue),
1971+
]
1972+
1973+
topPredictionBG = Storage.shared.minBGScale.value
1974+
1975+
if displayType == .cone {
1976+
var allArrays = [[Double]]()
1977+
for (type, _, _) in predictionTypes {
1978+
if let arr = predBGs[type], !arr.isEmpty {
1979+
allArrays.append(arr)
1980+
}
1981+
}
1982+
1983+
var coneData = [ConeChartDataEntry]()
1984+
if !allArrays.isEmpty {
1985+
let maxLength = min(allArrays.map { $0.count }.max()!, toLoad + 1)
1986+
var t = predictionStart
1987+
for i in 0 ..< maxLength {
1988+
var valuesAtIndex = [Double]()
1989+
for arr in allArrays where i < arr.count {
1990+
valuesAtIndex.append(arr[i])
1991+
}
1992+
if !valuesAtIndex.isEmpty {
1993+
var yMin = max(valuesAtIndex.min()!, Double(globalVariables.minDisplayGlucose))
1994+
var yMax = min(valuesAtIndex.max()!, Double(globalVariables.maxDisplayGlucose))
1995+
// Ensure minimum ±1 mg/dL range so the cone is visible when predictions agree
1996+
if yMin == yMax {
1997+
yMin -= 1
1998+
yMax += 1
1999+
}
2000+
coneData.append(ConeChartDataEntry(x: t, yMin: yMin, yMax: yMax))
2001+
if yMax > topPredictionBG - 20 { topPredictionBG = yMax + 20 }
2002+
}
2003+
t += 300
2004+
}
2005+
}
2006+
2007+
updateConeGraph(coneData: coneData)
2008+
2009+
// Clear individual prediction lines
2010+
for (_, _, dataIndex) in predictionTypes {
2011+
updatePredictionGraphGeneric(dataIndex: dataIndex, predictionData: [], chartLabel: "", color: .clear)
2012+
}
2013+
2014+
} else {
2015+
clearConeGraph()
2016+
2017+
for (type, colorName, dataIndex) in predictionTypes {
2018+
var predictionData = [ShareGlucoseData]()
2019+
if let graphdata = predBGs[type] {
2020+
var t = predictionStart
2021+
for i in 0 ... toLoad {
2022+
if i < graphdata.count {
2023+
let v = graphdata[i]
2024+
let clamped = min(max(Int(round(v)), globalVariables.minDisplayGlucose), globalVariables.maxDisplayGlucose)
2025+
predictionData.append(ShareGlucoseData(sgv: clamped, date: t, direction: "flat"))
2026+
t += 300
2027+
}
2028+
}
2029+
}
2030+
let color = UIColor(named: colorName) ?? UIColor.systemPurple
2031+
updatePredictionGraphGeneric(dataIndex: dataIndex, predictionData: predictionData, chartLabel: type, color: color)
2032+
}
2033+
}
2034+
}
2035+
19052036
func updatePredictionGraphGeneric(
19062037
dataIndex: Int,
19072038
predictionData: [ShareGlucoseData],

LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -176,46 +176,26 @@ extension MainViewController {
176176
let predictioncolor = UIColor.systemGray
177177
PredictionLabel.textColor = predictioncolor
178178
topPredictionBG = Storage.shared.minBGScale.value
179-
if let predbgdata = predBGsData {
180-
let predictionTypes: [(type: String, colorName: String, dataIndex: Int)] = [
181-
("ZT", "ZT", 12),
182-
("IOB", "Insulin", 13),
183-
("COB", "LoopYellow", 14),
184-
("UAM", "UAM", 15),
185-
]
186179

180+
if let predbgdata = predBGsData {
181+
let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
182+
var rawPredBGs = [String: [Double]]()
187183
var minPredBG = Double.infinity
188184
var maxPredBG = -Double.infinity
189185

190-
for (type, colorName, dataIndex) in predictionTypes {
191-
var predictionData = [ShareGlucoseData]()
192-
if let graphdata = predbgdata[type] as? [Double] {
193-
var predictionTime = updatedTime ?? Date().timeIntervalSince1970
194-
let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
195-
196-
for i in 0 ... toLoad {
197-
if i < graphdata.count {
198-
let predictionValue = graphdata[i]
199-
minPredBG = min(minPredBG, predictionValue)
200-
maxPredBG = max(maxPredBG, predictionValue)
201-
202-
let clampedValue = min(max(Int(round(predictionValue)), globalVariables.minDisplayGlucose), globalVariables.maxDisplayGlucose)
203-
let prediction = ShareGlucoseData(sgv: clampedValue, date: predictionTime, direction: "flat")
204-
predictionData.append(prediction)
205-
predictionTime += 300
206-
}
186+
for type in ["ZT", "IOB", "COB", "UAM"] {
187+
if let arr = predbgdata[type] as? [Double], !arr.isEmpty {
188+
rawPredBGs[type] = arr
189+
for i in 0 ... min(toLoad, arr.count - 1) {
190+
minPredBG = min(minPredBG, arr[i])
191+
maxPredBG = max(maxPredBG, arr[i])
207192
}
208193
}
209-
210-
let color = UIColor(named: colorName) ?? UIColor.systemPurple
211-
updatePredictionGraphGeneric(
212-
dataIndex: dataIndex,
213-
predictionData: predictionData,
214-
chartLabel: type,
215-
color: color
216-
)
217194
}
218195

196+
openAPSPredBGs = rawPredBGs.isEmpty ? nil : rawPredBGs
197+
openAPSPredUpdatedTime = updatedTime
198+
219199
if minPredBG != Double.infinity, maxPredBG != -Double.infinity {
220200
let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))"
221201
infoManager.updateInfoData(type: .minMax, value: value)
@@ -224,6 +204,11 @@ extension MainViewController {
224204
} else {
225205
infoManager.updateInfoData(type: .minMax, value: "N/A")
226206
}
207+
208+
updateOpenAPSPredictionDisplay()
209+
} else {
210+
openAPSPredBGs = nil
211+
openAPSPredUpdatedTime = nil
227212
}
228213

229214
if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {

LoopFollow/Helpers/Chart.swift

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,76 @@ class PillMarker: MarkerImage {
141141
return "\(formattedString)"
142142
}
143143
}
144+
145+
// MARK: - Cone of Uncertainty
146+
147+
class ConeChartDataEntry: ChartDataEntry {
148+
var yMin: Double = 0.0
149+
var yMax: Double = 0.0
150+
151+
required init() {
152+
super.init()
153+
}
154+
155+
init(x: Double, yMin: Double, yMax: Double) {
156+
self.yMin = yMin
157+
self.yMax = yMax
158+
super.init(x: x, y: yMax)
159+
}
160+
161+
override func copy(with _: NSZone? = nil) -> Any {
162+
let copy = ConeChartDataEntry(x: x, yMin: yMin, yMax: yMax)
163+
copy.data = data
164+
return copy
165+
}
166+
}
167+
168+
class ConeOfUncertaintyRenderer: LineChartRenderer {
169+
let coneDataSetIndex: Int
170+
171+
init(dataProvider: LineChartDataProvider?, animator: Animator?, viewPortHandler: ViewPortHandler?, coneDataSetIndex: Int) {
172+
self.coneDataSetIndex = coneDataSetIndex
173+
super.init(dataProvider: dataProvider!, animator: animator!, viewPortHandler: viewPortHandler!)
174+
}
175+
176+
override func drawExtras(context: CGContext) {
177+
super.drawExtras(context: context)
178+
179+
guard let dataProvider = dataProvider,
180+
dataProvider.lineData?.dataSets.count ?? 0 > coneDataSetIndex,
181+
let lineDataSet = dataProvider.lineData?.dataSets[coneDataSetIndex] as? LineChartDataSet,
182+
lineDataSet.entryCount > 1 else { return }
183+
184+
let trans = dataProvider.getTransformer(forAxis: lineDataSet.axisDependency)
185+
let phaseY = animator.phaseY
186+
187+
var upperPoints = [CGPoint]()
188+
var lowerPoints = [CGPoint]()
189+
190+
for i in 0 ..< lineDataSet.entryCount {
191+
guard let entry = lineDataSet.entryForIndex(i) as? ConeChartDataEntry else { continue }
192+
upperPoints.append(trans.pixelForValues(x: entry.x, y: entry.yMax * phaseY))
193+
lowerPoints.append(trans.pixelForValues(x: entry.x, y: entry.yMin * phaseY))
194+
}
195+
196+
guard upperPoints.count > 1 else { return }
197+
198+
context.saveGState()
199+
200+
let path = CGMutablePath()
201+
path.move(to: upperPoints[0])
202+
for i in 1 ..< upperPoints.count {
203+
path.addLine(to: upperPoints[i])
204+
}
205+
for i in stride(from: lowerPoints.count - 1, through: 0, by: -1) {
206+
path.addLine(to: lowerPoints[i])
207+
}
208+
path.closeSubpath()
209+
210+
context.addPath(path)
211+
context.setFillColor(UIColor.systemBlue.withAlphaComponent(0.4).cgColor)
212+
context.fillPath()
213+
214+
context.restoreGState()
215+
}
216+
}

0 commit comments

Comments
 (0)