Open domarm-comat opened 2 days ago
@ytxloveyou thanks for reporting an issue. I briefly tried to reproduce your issue without success using one of the pglive examples like such:
import signal
from threading import Thread
import pglive.examples_pyqt6 as examples
import pyqtgraph as pg # type: ignore
from pglive.kwargs import Crosshair
from pglive.sources.data_connector import DataConnector
from pglive.sources.live_axis_range import LiveAxisRange
from pglive.sources.live_plot import LiveLinePlot
from pglive.sources.live_plot_widget import LivePlotWidget
"""
We are plotting two signals in one plot with different plot_rate in this example.
Since LiveAxisRange is calculating range from plot_rate, it might result in unwanted results.
You can use ignore_auto_range flag for DataConnector.
If it's set to True, this DataConnector is not causing change of range.
You can in fact use it to all LiveAxisRanges and implement your own custom Range calculation.
"""
layout = pg.LayoutWidget()
layout.layout.setSpacing(0)
'''
We want to display two signals in different frequencies.
Pglive is calculating view in respect to all plots. This might lead to unwanted results with multiple plots in one view.
So to makes things looks nice, we use only 100Hz plot for view calculation and ignore slow 1Hz plot.
Like that we can even save some resources and speed up overall plotting performance.
'''
kwargs = {Crosshair.ENABLED: True,
Crosshair.LINE_PEN: pg.mkPen(color="red", width=1),
Crosshair.TEXT_KWARGS: {"color": "green"}}
widget = LivePlotWidget(title="Two signals plotting at different rates.",
x_range_controller=LiveAxisRange(roll_on_tick=100, offset_left=30),
y_range_controller=LiveAxisRange(fixed_range=[-1, 1]), **kwargs)
widget.x_range_controller.crop_left_offset_to_data = True
plot = LiveLinePlot(pen="red")
widget.addItem(plot)
plot2 = LiveLinePlot(pen="yellow")
widget.addItem(plot2)
layout.addWidget(widget, row=0, col=0)
data_connector = DataConnector(plot, max_points=6000, plot_rate=100)
data_connector2 = DataConnector(plot2, max_points=6000, plot_rate=1, ignore_auto_range=True)
layout.show()
Thread(target=examples.sin_wave_generator, args=(data_connector,)).start()
Thread(target=examples.cos_wave_generator, args=(data_connector2,)).start()
signal.signal(signal.SIGINT, lambda sig, frame: examples.stop())
examples.app.exec()
examples.stop()
Could you please post some example code which reproduces your issue?
Thanks for the quick feedback ! Actually we do have the use cases that show multiple lines in one widget, and they have different unit and rate , in which we might need different Y axis , so i just reuse @Benjamin-Griffiths's code mentions in issues with crosshair function added.. and i am not sure whether it is the best solution, would like to see what you could have done for this... Not sure multiple view boxes are necessary in this use cases...By using that we might have performances issues or what ever need to be solved..
mes_action = self.plotItem.vb.menu.addAction("Mesbar") mes_action.triggered.connect(self.toggle_MesBar) def toggle_MesBar(self): self.IsMesRequired = not self.IsMesRequired if self.IsMesRequired: self.add_crosshair(crosshair_pen=pg.mkPen(color="grey", width=1), crosshair_text_kwargs={"color": "green"}) else: self.remove_crosshair()
and list the whole code here:
import random import sys from datetime import datetime from typing import Union
from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QPen from PyQt6.QtWidgets import ( QApplication, QCheckBox, QDoubleSpinBox, QGraphicsGridLayout, QGridLayout, QPushButton, QWidget, )
import pyqtgraph as pg from pglive.sources.data_connector import DataConnector from pglive.sources.live_axis import Axis, LiveAxis from pglive.sources.live_plot import LiveLinePlot from pglive.sources.live_plot_widget import LiveAxis, LivePlotWidget, ViewBox
class LiveChart(LivePlotWidget):
def __init__(self, window: float = 10.0, fps: int = 30, roll_enabled: bool = False):
super().__init__()
# reimplement LivePlotWidget get_x_range method
self.IsMesRequired = None
mes_action = self.plotItem.vb.menu.addAction("Mesbar")
mes_action.triggered.connect(self.toggle_MesBar)
def get_x_range(data_connector, tick: int):
now = datetime.now().astimezone().timestamp()
if self._roll_enabled:
return [now - self._window, now]
else:
x_range_start = self.final_x_range[0]
if now > x_range_start + self._window:
return [now, now + self._window]
else:
return [x_range_start, x_range_start + self._window]
# use reimplemented function for setting x range based on time and not ticks
self.x_range_controller.get_x_range = get_x_range
self._window: float = window
self._fps: int = fps
self._roll_enabled: bool = roll_enabled
self.plt = self.plotItem
self.plt.vb.sigResized.connect(self.update_views)
self.plt_lay: QGraphicsGridLayout = self.plt.layout
self.plt.hideAxis("left")
self.plt.hideAxis("right")
self.plt.hideAxis("top")
self.plt_lay.removeItem(self.plt_lay.itemAt(2, 0)) # remove left axis
# add layout in place of original left axis for all vertical axes
self.ax_lay = pg.GraphicsLayout()
self.plt_lay.addItem(self.ax_lay, 2, 0)
# time axis
self.time_axis = LiveAxis(
"bottom", text="Datetime", **{Axis.TICK_FORMAT: Axis.TIME}
)
self.plt.setAxisItems({"bottom": self.time_axis})
self.axes: dict[str, LiveAxis] = {}
self.view_boxes: dict[str, ViewBox] = {}
self.series: dict[str, LiveLinePlot] = {}
self.connectors: dict[str, DataConnector] = {}
# dummy series and axis for auto scaling time axis range
pen = QPen()
pen.setStyle(Qt.PenStyle.NoPen)
self._auto_range_series = LiveLinePlot(pen=pen)
self.addItem(self._auto_range_series)
self._auto_range_connector = DataConnector(
self._auto_range_series,
max_points=2,
ignore_auto_range=False,
)
self._auto_range_timer = QTimer()
self._auto_range_timer.setTimerType(Qt.TimerType.PreciseTimer)
self._auto_range_timer.setInterval(int(1000 / self._fps))
self._auto_range_timer.timeout.connect(
lambda: self._auto_range_connector.cb_append_data_point(
0, datetime.now().astimezone().timestamp()
)
)
self.set_window(self._window)
self.set_roll_enabled(self._roll_enabled)
self._auto_range_timer.start()
def add_axis(self, name: str) -> None:
"""
Adds axis to the chart.
:param name: name of the axis
"""
# axis with name already exists
if name in self.axes:
return
vb = ViewBox()
self.view_boxes[name] = vb
ax = LiveAxis("left")
self.axes[name] = ax
self.ax_lay.addItem(ax)
self.plt.scene().addItem(vb)
ax.linkToView(vb)
vb.setXLink(self.plt)
ax.setZValue(-10000)
ax.setLabel(name)
def add_series(self, name: str, axis_name: str, color: Union[str, QPen]) -> None:
"""
Adds line series to the chart.
:param name: name of the series
:param axis_name: name of the vertical axis
"""
# remove existing series with same name
if name in self.series:
self.remove_series(name)
series = LiveLinePlot(pen=color)
self.series[name] = series
self.addItem(series)
connector = DataConnector(
series, max_points=6000, plot_rate=self._fps, ignore_auto_range=True
)
self.connectors[name] = connector
vb = self.view_boxes[axis_name]
vb.addItem(series)
def set_window(self, window: Union[int, float]) -> None:
"""
Sets the scrolling time window shown on the chart in seconds.
:param window: time window in seconds
"""
self._window = float(window)
def set_roll_enabled(self, enabled: bool) -> None:
""" """
self._roll_enabled = enabled
def autoscale(self) -> None:
""" """
self.auto_btn_clicked()
def add_value(self, name: str, value: float, timestamp: datetime) -> None:
"""
Adds a new value to the chart.
:param name: name of the series to add the value to
:param value: value to add
:param timestamp: datetime object
"""
if name not in self.connectors:
return
connector = self.connectors[name]
connector.cb_append_data_point(value, timestamp.timestamp())
def pause(self) -> None:
""" """
self._auto_range_connector.pause()
for c in self.connectors.values():
c.pause()
def resume(self) -> None:
""" """
self._auto_range_connector.resume()
for c in self.connectors.values():
c.resume()
def update_views(self):
# view has resized; update auxiliary views to match
for vb in self.view_boxes.values():
# view has resized; update auxiliary views to match
vb.setGeometry(self.plt.vb.sceneBoundingRect())
# need to re-update linked axes since this was called
# incorrectly while views had different shapes.
# (probably this should be handled in ViewBox.resizeEvent)
vb.linkedViewChanged(self.plt.vb, vb.XAxis)
def toggle_MesBar(self):
self.IsMesRequired = not self.IsMesRequired
if self.IsMesRequired:
self.add_crosshair(crosshair_pen=pg.mkPen(color="grey", width=1), crosshair_text_kwargs={"color": "green"})
else:
self.remove_crosshair()
if name == "main": app = QApplication(sys.argv)
chart = LiveChart()
widget = QWidget()
layout = QGridLayout(widget)
# resume/pause
resume_btn = QPushButton("Resume")
resume_btn.clicked.connect(chart.resume)
layout.addWidget(resume_btn, 0, 0)
pause_btn = QPushButton("Pause")
pause_btn.clicked.connect(chart.pause)
layout.addWidget(pause_btn, 0, layout.columnCount())
# time window
window_spinbox = QDoubleSpinBox()
window_spinbox.setDecimals(1)
window_spinbox.setRange(0.0, 60.0)
window_spinbox.setValue(chart._window)
window_spinbox.valueChanged.connect(chart.set_window)
layout.addWidget(window_spinbox, 0, layout.columnCount())
# roll enabled
roll_toggle = QCheckBox("Roll")
roll_toggle.stateChanged.connect(
lambda state: chart.set_roll_enabled(state == Qt.CheckState.Checked.value)
)
layout.addWidget(roll_toggle, 0, layout.columnCount())
# autoscale
autoscale_btn = QPushButton("Autoscale")
autoscale_btn.clicked.connect(chart.autoscale)
layout.addWidget(autoscale_btn, 0, layout.columnCount())
layout.addWidget(chart, 1, 0, 1, layout.columnCount())
# add axes
chart.add_axis("Speed (RPM)")
chart.add_axis("Torque (Nm)")
# add series
chart.add_series("Speed Sensor 1", "Speed (RPM)", "red")
chart.add_series("Speed Sensor 2", "Speed (RPM)", "green")
chart.add_series("Torque Sensor 1", "Torque (Nm)", "blue")
chart.add_series("Torque Sensor 2", "Torque (Nm)", "yellow")
# speed measurements coming in at 100Hz
timer_1 = QTimer()
timer_1.setInterval(10)
timer_1.timeout.connect(
lambda: chart.add_value(
"Speed Sensor 1", random.randint(200, 250), datetime.now().astimezone()
)
)
timer_1.timeout.connect(
lambda: chart.add_value(
"Speed Sensor 2", random.randint(250, 300), datetime.now().astimezone()
)
)
# torque measurements coming in at 10Hz
timer_2 = QTimer()
timer_2.setInterval(100)
timer_2.timeout.connect(
lambda: chart.add_value(
"Torque Sensor 1", random.randint(1, 5), datetime.now().astimezone()
)
)
timer_2.timeout.connect(
lambda: chart.add_value(
"Torque Sensor 2", random.randint(2, 4), datetime.now().astimezone()
)
)
timer_1.start()
timer_2.start()
widget.show()
sys.exit(app.exec())
So, the problem is, that you are actually hiding first left axis and pglive is using that axis as viewbox to get Y value. Then you're adding two additional axis and viewboxes.
Easy fix is to just override y_format
function, which is called to format Y value. There you can override value and use one of two new viewboxes.
class LiveChart(LivePlotWidget):
def y_format(self, value: Union[int, float]) -> str:
"""Y tick format"""
try:
value = self.view_boxes['Speed (RPM)'].mapSceneToView(self.mouse_position).y()
# Comment out for Torque value
# value = self.view_boxes['Torque (Nm)'].mapSceneToView(self.mouse_position).y()
# Get crosshair Y str format from left tick axis format
return self.getPlotItem().axes[self.crosshair_y_axis]["item"].tickStrings((value,), 0, 1)[0]
except Exception:
return str(round(value, 4))
If you need more complicated solution, showing both values, you have to override _update_crosshair_position
method.
So instead of y_format
you add _update_crosshair_position
like this:
class LiveChart(LivePlotWidget):
def _update_crosshair_position(self) -> None:
"""Update position of crosshair based on mouse position"""
if self.mouse_position:
mouse_point = self.plotItem.vb.mapSceneToView(self.mouse_position)
# Move crosshair to mouse pointer position
self.vLine.setPos(mouse_point.x())
self.hLine.setPos(mouse_point.y())
self.x_value_label.setText(f"X = {self.x_format(mouse_point.x())}")
speed_value = self.view_boxes['Speed (RPM)'].mapSceneToView(self.mouse_position).y()
torque_value = self.view_boxes['Torque (Nm)'].mapSceneToView(self.mouse_position).y()
self.y_value_label.setText(f"S = {self.y_format(speed_value)}, T = {self.y_format(torque_value)}")
x_pos = self.mouse_position.x() + 5
y_pos = self.mouse_position.y() - self.x_value_label.boundingRect().height()
# Resolve position of crosshair text to be in view of the plot
text_height = self.x_value_label.boundingRect().height()
if self.mouse_position.x() + self.x_value_label.boundingRect().width() > self.plotItem.width():
x_pos = self.mouse_position.x() - 5 - self.x_value_label.boundingRect().width()
elif self.mouse_position.x() + self.y_value_label.boundingRect().width() > self.plotItem.width():
x_pos = self.mouse_position.x() - 5 - self.x_value_label.boundingRect().width()
if self.mouse_position.y() - self.x_value_label.boundingRect().height() - 2 * text_height - 5 < 0:
y_pos += text_height * 2 + 5
# Move X marker to position
x_marker_point = self.plotItem.vb.mapSceneToView(
QtCore.QPointF(x_pos, y_pos - self.x_value_label.boundingRect().height()))
self.x_value_label.setPos(x_marker_point.x(), x_marker_point.y())
# Move Y marker to position
y_marker_point = self.plotItem.vb.mapSceneToView(QtCore.QPointF(x_pos, y_pos))
self.y_value_label.setPos(y_marker_point.x(), y_marker_point.y())
# Emit crosshair moved signal
self.sig_crosshair_moved.emit(mouse_point)
I will think about updating pglive to make this somehow easier for the user in the future.
Let me try it! Thanks for `feedback```
But if disable the ignore_auto_range, the crosshair value is still not accurate,
Maybe the reason is that the code did not use the origin viewbox but created new ones for multiple plot in the same widget. How could i fix it in this case
Originally posted by @ytxloveyou in https://github.com/domarm-comat/pglive/issues/38#issuecomment-2463583522