diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 9a11022d6..9833a6a41 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -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 */; }; @@ -548,6 +549,7 @@ DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = ""; }; DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuViewController.swift; sourceTree = ""; }; DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = ""; }; + DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionDisplayType.swift; sourceTree = ""; }; DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = ""; }; DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = ""; }; DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */, diff --git a/LoopFollow/Controllers/Graphs.swift b/LoopFollow/Controllers/Graphs.swift index fe50491c7..37be03cd2 100644 --- a/LoopFollow/Controllers/Graphs.swift +++ b/LoopFollow/Controllers/Graphs.swift @@ -26,6 +26,7 @@ enum GraphDataIndex: Int { case uamPrediction = 15 case smb = 16 case tempTarget = 17 + case predictionCone = 18 } extension GraphDataIndex { @@ -49,6 +50,7 @@ extension GraphDataIndex { case .uamPrediction: return "UAM Prediction" case .smb: return "SMB" case .tempTarget: return "Temp Target" + case .predictionCone: return "Prediction Cone" } } } @@ -56,8 +58,9 @@ extension GraphDataIndex { 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, @@ -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) } @@ -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 @@ -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)) @@ -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() { @@ -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 @@ -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], diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index c1d926fd1..bc4da5ebd 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -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) @@ -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] { diff --git a/LoopFollow/Helpers/Chart.swift b/LoopFollow/Helpers/Chart.swift index 2df0ae0d2..7bab094ea 100644 --- a/LoopFollow/Helpers/Chart.swift +++ b/LoopFollow/Helpers/Chart.swift @@ -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() + } +} diff --git a/LoopFollow/Helpers/PredictionDisplayType.swift b/LoopFollow/Helpers/PredictionDisplayType.swift new file mode 100644 index 000000000..eb79940f8 --- /dev/null +++ b/LoopFollow/Helpers/PredictionDisplayType.swift @@ -0,0 +1,14 @@ +// LoopFollow +// PredictionDisplayType.swift + +enum PredictionDisplayType: String, CaseIterable, Codable { + case cone + case lines + + var displayName: String { + switch self { + case .cone: return "Cone" + case .lines: return "Lines" + } + } +} diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 4ebc1896a..81e2a074e 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -16,6 +16,7 @@ struct GraphSettingsView: View { @ObservedObject private var smallGraphHeight = Storage.shared.smallGraphHeight @ObservedObject private var predictionToLoad = Storage.shared.predictionToLoad + @ObservedObject private var predictionDisplayType = Storage.shared.predictionDisplayType @ObservedObject private var minBasalScale = Storage.shared.minBasalScale @ObservedObject private var minBGScale = Storage.shared.minBGScale @ObservedObject private var lowLine = Storage.shared.lowLine @@ -82,6 +83,15 @@ struct GraphSettingsView: View { value: $predictionToLoad.value, format: { "\($0.localized(maxFractionDigits: 2)) h" } ) + + if Storage.shared.device.value != "Loop" { + Picker("Prediction Style", selection: $predictionDisplayType.value) { + ForEach(PredictionDisplayType.allCases, id: \.self) { type in + Text(type.displayName).tag(type) + } + } + .onChange(of: predictionDisplayType.value) { _ in markDirty() } + } } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 11997da35..5dea5ebb1 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -130,6 +130,7 @@ class Storage { var smallGraphHeight = StorageValue(key: "smallGraphHeight", defaultValue: 40) var predictionToLoad = StorageValue(key: "predictionToLoad", defaultValue: 1.0) + var predictionDisplayType = StorageValue(key: "predictionDisplayType", defaultValue: .cone) var minBasalScale = StorageValue(key: "minBasalScale", defaultValue: 5.0) var minBGScale = StorageValue(key: "minBGScale", defaultValue: 250.0) var lowLine = StorageValue(key: "lowLine", defaultValue: 70.0) @@ -336,6 +337,7 @@ class Storage { smallGraphTreatments.reload() smallGraphHeight.reload() predictionToLoad.reload() + predictionDisplayType.reload() minBasalScale.reload() minBGScale.reload() lowLine.reload() diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ac1f19a24..0785efc01 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -91,6 +91,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele var overrideGraphData: [DataStructs.overrideStruct] = [] var tempTargetGraphData: [DataStructs.tempTargetStruct] = [] var predictionData: [ShareGlucoseData] = [] + var openAPSPredBGs: [String: [Double]]? + var openAPSPredUpdatedTime: TimeInterval? var bgCheckData: [ShareGlucoseData] = [] var suspendGraphData: [DataStructs.timestampOnlyStruct] = [] var resumeGraphData: [DataStructs.timestampOnlyStruct] = [] @@ -422,6 +424,20 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) + Storage.shared.device.$value + .receive(on: DispatchQueue.main) + .map { device -> Bool? in + device.isEmpty ? nil : (device == "Loop") + } + .removeDuplicates() + .dropFirst() + .sink { [weak self] isLoop in + guard let isLoop = isLoop else { return } + Storage.shared.predictionDisplayType.value = isLoop ? .lines : .cone + self?.updateOpenAPSPredictionDisplay() + } + .store(in: &cancellables) + updateQuickActions() // Delay initial tab setup to ensure view hierarchy is ready