domarm-comat / pglive

Live pyqtgraph plot
MIT License
80 stars 14 forks source link

Crosshair value issue #43

Open domarm-comat opened 2 days ago

domarm-comat commented 2 days ago
          Firstly Thanks for the nice work of pglive,  i  just try this code, but found issues when i use crosshair,  if i enable the ignore_auto_range, the crosshair value it totally not correct, like:

image But if disable the ignore_auto_range, the crosshair value is still not accurate, image

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

domarm-comat commented 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?

ytxloveyou commented 2 days ago

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())
domarm-comat commented 1 day ago

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.

ytxloveyou commented 1 day ago

Let me try it! Thanks for `feedback```