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
4 changes: 4 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; };
DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */; };
DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; };
DD1D52C22E4C100000000002 /* PredictionDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */; };
DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; };
DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; };
Expand Down Expand Up @@ -548,6 +549,7 @@
DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = "<group>"; };
DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuViewController.swift; sourceTree = "<group>"; };
DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = "<group>"; };
DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionDisplayType.swift; sourceTree = "<group>"; };
DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = "<group>"; };
DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1667,6 +1669,7 @@
656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */,
DD4A407D2E6AFEE6007B318B /* AuthService.swift */,
DD1D52BF2E4C100000000001 /* AppearanceMode.swift */,
DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */,
DD1D52B82E1EB5DC00432050 /* TabPosition.swift */,
DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */,
DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */,
Expand Down Expand Up @@ -2345,6 +2348,7 @@
DD7F4C112DD51ED900D449E9 /* TempTargetStartAlarmEditor.swift in Sources */,
DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */,
DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */,
DD1D52C22E4C100000000002 /* PredictionDisplayType.swift in Sources */,
DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */,
654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */,
6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */,
Expand Down
139 changes: 135 additions & 4 deletions LoopFollow/Controllers/Graphs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ enum GraphDataIndex: Int {
case uamPrediction = 15
case smb = 16
case tempTarget = 17
case predictionCone = 18
}

extension GraphDataIndex {
Expand All @@ -49,15 +50,17 @@ extension GraphDataIndex {
case .uamPrediction: return "UAM Prediction"
case .smb: return "SMB"
case .tempTarget: return "Temp Target"
case .predictionCone: return "Prediction Cone"
}
}
}

class CompositeRenderer: LineChartRenderer {
let tempTargetRenderer: TempTargetRenderer
let triangleRenderer: TriangleRenderer
let coneRenderer: ConeOfUncertaintyRenderer

init(dataProvider: LineChartDataProvider?, animator: Animator?, viewPortHandler: ViewPortHandler?, tempTargetDataSetIndex: Int, smbDataSetIndex: Int) {
init(dataProvider: LineChartDataProvider?, animator: Animator?, viewPortHandler: ViewPortHandler?, tempTargetDataSetIndex: Int, smbDataSetIndex: Int, coneDataSetIndex: Int) {
tempTargetRenderer = TempTargetRenderer(
dataProvider: dataProvider,
animator: animator,
Expand All @@ -70,11 +73,18 @@ class CompositeRenderer: LineChartRenderer {
viewPortHandler: viewPortHandler,
smbDataSetIndex: smbDataSetIndex
)
coneRenderer = ConeOfUncertaintyRenderer(
dataProvider: dataProvider,
animator: animator,
viewPortHandler: viewPortHandler,
coneDataSetIndex: coneDataSetIndex
)
super.init(dataProvider: dataProvider!, animator: animator!, viewPortHandler: viewPortHandler!)
}

override func drawExtras(context: CGContext) {
super.drawExtras(context: context)
coneRenderer.drawExtras(context: context)
tempTargetRenderer.drawExtras(context: context)
triangleRenderer.drawExtras(context: context)
}
Expand Down Expand Up @@ -204,13 +214,15 @@ extension MainViewController {
func updateChartRenderers() {
let tempTargetDataIndex = GraphDataIndex.tempTarget.rawValue
let smbDataIndex = GraphDataIndex.smb.rawValue
let coneDataIndex = GraphDataIndex.predictionCone.rawValue

let compositeRenderer = CompositeRenderer(
dataProvider: BGChart,
animator: BGChart.chartAnimator,
viewPortHandler: BGChart.viewPortHandler,
tempTargetDataSetIndex: tempTargetDataIndex,
smbDataSetIndex: smbDataIndex
smbDataSetIndex: smbDataIndex,
coneDataSetIndex: coneDataIndex
)
BGChart.renderer = compositeRenderer

Expand Down Expand Up @@ -595,7 +607,16 @@ extension MainViewController {
data.append(COBlinePrediction) // Dataset 14
data.append(UAMlinePrediction) // Dataset 15
data.append(lineSmb) // Dataset 16
data.append(lineTempTarget)
data.append(lineTempTarget) // Dataset 17

// Dataset 18: Prediction Cone (rendered via ConeOfUncertaintyRenderer)
let lineCone = LineChartDataSet(entries: [ChartDataEntry](), label: "")
lineCone.lineWidth = 0
lineCone.drawCirclesEnabled = false
lineCone.drawValuesEnabled = false
lineCone.highlightEnabled = false
lineCone.axisDependency = YAxis.AxisDependency.right
data.append(lineCone)

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

Expand Down Expand Up @@ -783,6 +804,9 @@ extension MainViewController {
BGChart.data?.dataSets[dataIndex].notifyDataSetChanged()
BGChart.data?.notifyDataChanged()
BGChart.notifyDataSetChanged()

// Re-render prediction display in case display type changed
updateOpenAPSPredictionDisplay()
}

func updateBGGraph() {
Expand Down Expand Up @@ -1601,7 +1625,16 @@ extension MainViewController {
data.append(COBlinePrediction) // Dataset 14
data.append(UAMlinePrediction) // Dataset 15
data.append(lineSmb) // Dataset 16
data.append(lineTempTarget)
data.append(lineTempTarget) // Dataset 17

// Dataset 18: Prediction Cone placeholder (not rendered on small chart)
let lineConeSmall = LineChartDataSet(entries: [ChartDataEntry](), label: "")
lineConeSmall.lineWidth = 0
lineConeSmall.drawCirclesEnabled = false
lineConeSmall.drawValuesEnabled = false
lineConeSmall.highlightEnabled = false
lineConeSmall.axisDependency = YAxis.AxisDependency.right
data.append(lineConeSmall)

BGChartFull.highlightPerDragEnabled = true
BGChartFull.leftAxis.enabled = false
Expand Down Expand Up @@ -1898,6 +1931,104 @@ extension MainViewController {
}
}

func updateConeGraph(coneData: [ConeChartDataEntry]) {
let dataIndex = GraphDataIndex.predictionCone.rawValue
let mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet
mainChart.clear()
for entry in coneData {
mainChart.addEntry(entry)
}
BGChart.rightAxis.axisMaximum = Double(calculateMaxBgGraphValue())
updateChartRenderers()
}

func clearConeGraph() {
let dataIndex = GraphDataIndex.predictionCone.rawValue
guard let lineData = BGChart.lineData, lineData.dataSets.count > dataIndex else { return }
lineData.dataSets[dataIndex].clear()
BGChart.data?.dataSets[dataIndex].notifyDataSetChanged()
BGChart.data?.notifyDataChanged()
BGChart.notifyDataSetChanged()
}

func updateOpenAPSPredictionDisplay() {
guard let predBGs = openAPSPredBGs else { return }

// Cone is only for OpenAPS-based systems; Loop always uses lines
let displayType: PredictionDisplayType = Storage.shared.device.value == "Loop" ? .lines : Storage.shared.predictionDisplayType.value
let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
let predictionStart = openAPSPredUpdatedTime ?? Date().timeIntervalSince1970

let predictionTypes: [(type: String, colorName: String, dataIndex: Int)] = [
("ZT", "ZT", GraphDataIndex.ztPrediction.rawValue),
("IOB", "Insulin", GraphDataIndex.iobPrediction.rawValue),
("COB", "LoopYellow", GraphDataIndex.cobPrediction.rawValue),
("UAM", "UAM", GraphDataIndex.uamPrediction.rawValue),
]

topPredictionBG = Storage.shared.minBGScale.value

if displayType == .cone {
var allArrays = [[Double]]()
for (type, _, _) in predictionTypes {
if let arr = predBGs[type], !arr.isEmpty {
allArrays.append(arr)
}
}

var coneData = [ConeChartDataEntry]()
if !allArrays.isEmpty {
let maxLength = min(allArrays.map { $0.count }.max()!, toLoad + 1)
var t = predictionStart
for i in 0 ..< maxLength {
var valuesAtIndex = [Double]()
for arr in allArrays where i < arr.count {
valuesAtIndex.append(arr[i])
}
if !valuesAtIndex.isEmpty {
var yMin = max(valuesAtIndex.min()!, Double(globalVariables.minDisplayGlucose))
var yMax = min(valuesAtIndex.max()!, Double(globalVariables.maxDisplayGlucose))
// Ensure minimum ±1 mg/dL range so the cone is visible when predictions agree
if yMin == yMax {
yMin -= 1
yMax += 1
}
coneData.append(ConeChartDataEntry(x: t, yMin: yMin, yMax: yMax))
if yMax > topPredictionBG - 20 { topPredictionBG = yMax + 20 }
}
t += 300
}
}

updateConeGraph(coneData: coneData)

// Clear individual prediction lines
for (_, _, dataIndex) in predictionTypes {
updatePredictionGraphGeneric(dataIndex: dataIndex, predictionData: [], chartLabel: "", color: .clear)
}

} else {
clearConeGraph()

for (type, colorName, dataIndex) in predictionTypes {
var predictionData = [ShareGlucoseData]()
if let graphdata = predBGs[type] {
var t = predictionStart
for i in 0 ... toLoad {
if i < graphdata.count {
let v = graphdata[i]
let clamped = min(max(Int(round(v)), globalVariables.minDisplayGlucose), globalVariables.maxDisplayGlucose)
predictionData.append(ShareGlucoseData(sgv: clamped, date: t, direction: "flat"))
t += 300
}
}
}
let color = UIColor(named: colorName) ?? UIColor.systemPurple
updatePredictionGraphGeneric(dataIndex: dataIndex, predictionData: predictionData, chartLabel: type, color: color)
}
}
}

func updatePredictionGraphGeneric(
dataIndex: Int,
predictionData: [ShareGlucoseData],
Expand Down
49 changes: 17 additions & 32 deletions LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,46 +176,26 @@ extension MainViewController {
let predictioncolor = UIColor.systemGray
PredictionLabel.textColor = predictioncolor
topPredictionBG = Storage.shared.minBGScale.value
if let predbgdata = predBGsData {
let predictionTypes: [(type: String, colorName: String, dataIndex: Int)] = [
("ZT", "ZT", 12),
("IOB", "Insulin", 13),
("COB", "LoopYellow", 14),
("UAM", "UAM", 15),
]

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

for (type, colorName, dataIndex) in predictionTypes {
var predictionData = [ShareGlucoseData]()
if let graphdata = predbgdata[type] as? [Double] {
var predictionTime = updatedTime ?? Date().timeIntervalSince1970
let toLoad = Int(Storage.shared.predictionToLoad.value * 12)

for i in 0 ... toLoad {
if i < graphdata.count {
let predictionValue = graphdata[i]
minPredBG = min(minPredBG, predictionValue)
maxPredBG = max(maxPredBG, predictionValue)

let clampedValue = min(max(Int(round(predictionValue)), globalVariables.minDisplayGlucose), globalVariables.maxDisplayGlucose)
let prediction = ShareGlucoseData(sgv: clampedValue, date: predictionTime, direction: "flat")
predictionData.append(prediction)
predictionTime += 300
}
for type in ["ZT", "IOB", "COB", "UAM"] {
if let arr = predbgdata[type] as? [Double], !arr.isEmpty {
rawPredBGs[type] = arr
for i in 0 ... min(toLoad, arr.count - 1) {
minPredBG = min(minPredBG, arr[i])
maxPredBG = max(maxPredBG, arr[i])
}
}

let color = UIColor(named: colorName) ?? UIColor.systemPurple
updatePredictionGraphGeneric(
dataIndex: dataIndex,
predictionData: predictionData,
chartLabel: type,
color: color
)
}

openAPSPredBGs = rawPredBGs.isEmpty ? nil : rawPredBGs
openAPSPredUpdatedTime = updatedTime

if minPredBG != Double.infinity, maxPredBG != -Double.infinity {
let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))"
infoManager.updateInfoData(type: .minMax, value: value)
Expand All @@ -224,6 +204,11 @@ extension MainViewController {
} else {
infoManager.updateInfoData(type: .minMax, value: "N/A")
}

updateOpenAPSPredictionDisplay()
} else {
openAPSPredBGs = nil
openAPSPredUpdatedTime = nil
}

if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
Expand Down
73 changes: 73 additions & 0 deletions LoopFollow/Helpers/Chart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,76 @@ class PillMarker: MarkerImage {
return "\(formattedString)"
}
}

// MARK: - Cone of Uncertainty

class ConeChartDataEntry: ChartDataEntry {
var yMin: Double = 0.0
var yMax: Double = 0.0

required init() {
super.init()
}

init(x: Double, yMin: Double, yMax: Double) {
self.yMin = yMin
self.yMax = yMax
super.init(x: x, y: yMax)
}

override func copy(with _: NSZone? = nil) -> Any {
let copy = ConeChartDataEntry(x: x, yMin: yMin, yMax: yMax)
copy.data = data
return copy
}
}

class ConeOfUncertaintyRenderer: LineChartRenderer {
let coneDataSetIndex: Int

init(dataProvider: LineChartDataProvider?, animator: Animator?, viewPortHandler: ViewPortHandler?, coneDataSetIndex: Int) {
self.coneDataSetIndex = coneDataSetIndex
super.init(dataProvider: dataProvider!, animator: animator!, viewPortHandler: viewPortHandler!)
}

override func drawExtras(context: CGContext) {
super.drawExtras(context: context)

guard let dataProvider = dataProvider,
dataProvider.lineData?.dataSets.count ?? 0 > coneDataSetIndex,
let lineDataSet = dataProvider.lineData?.dataSets[coneDataSetIndex] as? LineChartDataSet,
lineDataSet.entryCount > 1 else { return }

let trans = dataProvider.getTransformer(forAxis: lineDataSet.axisDependency)
let phaseY = animator.phaseY

var upperPoints = [CGPoint]()
var lowerPoints = [CGPoint]()

for i in 0 ..< lineDataSet.entryCount {
guard let entry = lineDataSet.entryForIndex(i) as? ConeChartDataEntry else { continue }
upperPoints.append(trans.pixelForValues(x: entry.x, y: entry.yMax * phaseY))
lowerPoints.append(trans.pixelForValues(x: entry.x, y: entry.yMin * phaseY))
}

guard upperPoints.count > 1 else { return }

context.saveGState()

let path = CGMutablePath()
path.move(to: upperPoints[0])
for i in 1 ..< upperPoints.count {
path.addLine(to: upperPoints[i])
}
for i in stride(from: lowerPoints.count - 1, through: 0, by: -1) {
path.addLine(to: lowerPoints[i])
}
path.closeSubpath()

context.addPath(path)
context.setFillColor(UIColor.systemBlue.withAlphaComponent(0.4).cgColor)
context.fillPath()

context.restoreGState()
}
}
Loading