mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-07 08:52:38 +02:00
feat(plots): user-adjustable data update rate on all plot widgets
This commit is contained in:
+120
-2
@@ -2084,6 +2084,26 @@ class Heatmap(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@update_rate.setter
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -2792,6 +2812,26 @@ class Image(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@update_rate.setter
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -3965,6 +4005,26 @@ class MotorMap(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@update_rate.setter
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -4437,6 +4497,26 @@ class MultiWaveform(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@update_rate.setter
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -5719,6 +5799,26 @@ class ScatterWaveform(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@update_rate.setter
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -6054,8 +6154,6 @@ class ViewBase(RPCBase):
|
||||
|
||||
|
||||
class Waveform(RPCBase):
|
||||
"""Widget for plotting waveforms."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.waveform"
|
||||
|
||||
@rpc_call
|
||||
@@ -6370,6 +6468,26 @@ class Waveform(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@update_rate.setter
|
||||
@rpc_call
|
||||
def update_rate(self) -> "float":
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
|
||||
@@ -104,6 +104,16 @@ class QtDataSubscription(QObject):
|
||||
|
||||
# --- public --------------------------------------------------------------
|
||||
|
||||
def set_min_emit_interval(self, seconds: float) -> None:
|
||||
"""
|
||||
Change the backend coalescing interval of the live subscription.
|
||||
|
||||
Args:
|
||||
seconds (float): New interval in seconds; 0 disables coalescing.
|
||||
"""
|
||||
if self._subscription is not None:
|
||||
self._subscription.set_min_emit_interval(seconds)
|
||||
|
||||
@property
|
||||
def scan_id(self) -> str | None:
|
||||
"""The currently bound scan id."""
|
||||
|
||||
@@ -207,6 +207,10 @@ class Heatmap(ImageBase):
|
||||
Heatmap widget for visualizing 2d grid data with color mapping for the z-axis.
|
||||
"""
|
||||
|
||||
#: 5 Hz: grid recomputation is comparatively expensive and scan points
|
||||
#: arrive slowly; a faster rate only burns paint time.
|
||||
DEFAULT_UPDATE_RATE = 5.0
|
||||
|
||||
USER_ACCESS = [
|
||||
*PlotBase.USER_ACCESS,
|
||||
# ImageView Specific Settings
|
||||
@@ -764,7 +768,11 @@ class Heatmap(ImageBase):
|
||||
|
||||
try:
|
||||
self._data_bridge = QtDataSubscription(
|
||||
self.client, sources=sources, scan=scan, parent=self, min_emit_interval=0.2
|
||||
self.client,
|
||||
sources=sources,
|
||||
scan=scan,
|
||||
parent=self,
|
||||
min_emit_interval=self.update_interval_s,
|
||||
)
|
||||
self._data_bridge.updated.connect(self._on_data_update)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -452,7 +452,7 @@ class Image(ImageBase):
|
||||
sources=[(self._config.device, entry)],
|
||||
scan=scan,
|
||||
parent=self,
|
||||
min_emit_interval=0.04,
|
||||
min_emit_interval=self.update_interval_s,
|
||||
max_points=max_points,
|
||||
)
|
||||
self._data_bridge.updated.connect(self._on_data_update)
|
||||
|
||||
@@ -672,7 +672,7 @@ class MotorMap(PlotBase):
|
||||
sources=[(device_x, device_x), (device_y, device_y)],
|
||||
scan=None,
|
||||
parent=self,
|
||||
min_emit_interval=0.04,
|
||||
min_emit_interval=self.update_interval_s,
|
||||
max_points=self.config.max_points,
|
||||
)
|
||||
self._data_bridge.updated.connect(self._on_data_update)
|
||||
|
||||
@@ -835,7 +835,7 @@ class MultiWaveform(PlotBase):
|
||||
sources=[(device, entry)],
|
||||
scan=scan,
|
||||
parent=self,
|
||||
min_emit_interval=0.04,
|
||||
min_emit_interval=self.update_interval_s,
|
||||
max_points=max_points,
|
||||
)
|
||||
self._data_bridge.updated.connect(self._on_data_update)
|
||||
|
||||
@@ -15,6 +15,7 @@ from bec_widgets.utils.entry_validator import EntryValidator
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.fps_counter import FPSCounter
|
||||
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
||||
from bec_widgets.utils.qt_data_subscription import QtDataSubscription
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
|
||||
@@ -65,6 +66,9 @@ class UIMode(Enum):
|
||||
class PlotBase(BECWidget, QWidget):
|
||||
PLUGIN = False
|
||||
RPC = False
|
||||
#: Default data-update rate in Hz; widgets override it to match their
|
||||
#: render cost (see the ``update_rate`` property).
|
||||
DEFAULT_UPDATE_RATE: float = 25.0
|
||||
BASE_USER_ACCESS = [
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
@@ -106,6 +110,8 @@ class PlotBase(BECWidget, QWidget):
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"update_rate",
|
||||
"update_rate.setter",
|
||||
"screenshot",
|
||||
]
|
||||
USER_ACCESS = [*BECWidget.USER_ACCESS, *BASE_USER_ACCESS]
|
||||
@@ -175,6 +181,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
self._y_label_suffix = ""
|
||||
self._y_axis_units = ""
|
||||
self._minimal_crosshair_precision = 3
|
||||
self._update_rate = min(100.0, max(1.0, float(self.DEFAULT_UPDATE_RATE)))
|
||||
|
||||
# Plot Indicator Items
|
||||
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
|
||||
@@ -1035,6 +1042,45 @@ class PlotBase(BECWidget, QWidget):
|
||||
) # 9 is the default font size of the legend, so we always scale it against 9
|
||||
self.plot_item.legend.setScale(scale)
|
||||
|
||||
@SafeProperty(float, doc="Data update rate in Hz (clamped to 1-100).")
|
||||
def update_rate(self) -> float:
|
||||
"""
|
||||
Rate at which data subscriptions deliver updates to the widget, in Hz.
|
||||
|
||||
Clamped to 1-100 Hz. Each widget class defines its own default via
|
||||
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
|
||||
"""
|
||||
return self._update_rate
|
||||
|
||||
@update_rate.setter
|
||||
def update_rate(self, value: float):
|
||||
"""
|
||||
Set the data update rate and apply it to all active subscriptions.
|
||||
|
||||
Args:
|
||||
value(float): Update rate in Hz; clamped to 1-100.
|
||||
"""
|
||||
try:
|
||||
rate = float(value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(f"Invalid update_rate {value!r}; keeping {self._update_rate} Hz.")
|
||||
return
|
||||
rate = min(100.0, max(1.0, rate))
|
||||
self._update_rate = rate
|
||||
self._apply_update_rate()
|
||||
self.property_changed.emit("update_rate", rate)
|
||||
|
||||
@property
|
||||
def update_interval_s(self) -> float:
|
||||
"""The subscription coalescing interval in seconds for :attr:`update_rate`."""
|
||||
return 1.0 / self._update_rate
|
||||
|
||||
def _apply_update_rate(self):
|
||||
"""Propagate the current update rate to every active data bridge."""
|
||||
interval = self.update_interval_s
|
||||
for bridge in self.findChildren(QtDataSubscription):
|
||||
bridge.set_min_emit_interval(interval)
|
||||
|
||||
################################################################################
|
||||
# FPS Counter
|
||||
################################################################################
|
||||
|
||||
@@ -345,7 +345,11 @@ class ScatterWaveform(PlotBase):
|
||||
return
|
||||
try:
|
||||
self._data_bridge = QtDataSubscription(
|
||||
self.client, sources=sources, scan=scan, parent=self, min_emit_interval=0.04
|
||||
self.client,
|
||||
sources=sources,
|
||||
scan=scan,
|
||||
parent=self,
|
||||
min_emit_interval=self.update_interval_s,
|
||||
)
|
||||
self._data_bridge.updated.connect(self._on_data_update)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -76,6 +76,9 @@ class WaveformConfig(ConnectionConfig):
|
||||
|
||||
|
||||
class Waveform(PlotBase):
|
||||
#: 15 Hz: above typical device message rates while leaving paint headroom
|
||||
#: for multi-million-point async curves (benchmarked).
|
||||
DEFAULT_UPDATE_RATE = 15.0
|
||||
"""
|
||||
Widget for plotting waveforms.
|
||||
"""
|
||||
@@ -1748,10 +1751,12 @@ class Waveform(PlotBase):
|
||||
if not sources:
|
||||
return
|
||||
try:
|
||||
# 15 Hz render coalescing: above typical device message rates while
|
||||
# leaving paint headroom for multi-million-point curves.
|
||||
self._data_bridge = QtDataSubscription(
|
||||
self.client, sources=sources, scan=scan, parent=self, min_emit_interval=0.0667
|
||||
self.client,
|
||||
sources=sources,
|
||||
scan=scan,
|
||||
parent=self,
|
||||
min_emit_interval=self.update_interval_s,
|
||||
)
|
||||
self._data_bridge.updated.connect(self._on_data_update)
|
||||
except Exception as exc:
|
||||
@@ -1795,7 +1800,7 @@ class Waveform(PlotBase):
|
||||
sources=list(dict.fromkeys(sources)),
|
||||
scan=scan_id,
|
||||
parent=self,
|
||||
min_emit_interval=0.0667,
|
||||
min_emit_interval=self.update_interval_s,
|
||||
)
|
||||
bridge.updated.connect(self._on_data_update)
|
||||
self._history_bridges[scan_id] = bridge
|
||||
|
||||
@@ -538,3 +538,53 @@ def test_limits_accept_fractional_values(qtbot, mocked_client):
|
||||
pb.y_limits = (-1.75, 3.5)
|
||||
assert (pb.x_limits.x(), pb.x_limits.y()) == (0.5, 9.25)
|
||||
assert (pb.y_limits.x(), pb.y_limits.y()) == (-1.75, 3.5)
|
||||
|
||||
|
||||
def test_update_rate_default_and_clamp(qtbot, mocked_client):
|
||||
"""update_rate defaults to the class rate and clamps to 1-100 Hz."""
|
||||
pb = create_widget(qtbot, PlotBase, client=mocked_client)
|
||||
assert pb.update_rate == 25.0
|
||||
assert pb.update_interval_s == 1.0 / 25.0
|
||||
|
||||
pb.update_rate = 0.2
|
||||
assert pb.update_rate == 1.0
|
||||
pb.update_rate = 500
|
||||
assert pb.update_rate == 100.0
|
||||
pb.update_rate = 10
|
||||
assert pb.update_rate == 10.0
|
||||
pb.update_rate = "not-a-number"
|
||||
assert pb.update_rate == 10.0
|
||||
|
||||
|
||||
def test_update_rate_propagates_to_bridges(qtbot, mocked_client):
|
||||
"""Setting update_rate reconfigures every child data bridge in place."""
|
||||
from unittest import mock
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
from bec_widgets.utils.qt_data_subscription import QtDataSubscription
|
||||
|
||||
class _StubBridge(QtDataSubscription):
|
||||
# pylint: disable=super-init-not-called
|
||||
def __init__(self, parent):
|
||||
QObject.__init__(self, parent)
|
||||
self._closed = False
|
||||
self._subscription = mock.MagicMock()
|
||||
|
||||
pb = create_widget(qtbot, PlotBase, client=mocked_client)
|
||||
bridge = _StubBridge(pb)
|
||||
with qtbot.waitSignal(pb.property_changed, timeout=500) as signal:
|
||||
pb.update_rate = 5
|
||||
assert signal.args == ["update_rate", 5.0]
|
||||
bridge._subscription.set_min_emit_interval.assert_called_once_with(0.2)
|
||||
|
||||
|
||||
def test_update_rate_widget_defaults():
|
||||
"""Per-widget defaults preserve the benchmarked rates."""
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
assert Waveform.DEFAULT_UPDATE_RATE == 15.0
|
||||
assert Heatmap.DEFAULT_UPDATE_RATE == 5.0
|
||||
assert Image.DEFAULT_UPDATE_RATE == 25.0
|
||||
|
||||
@@ -87,6 +87,7 @@ def _fake_bridge_factory(monkeypatch, gated_bytes: int | None = None):
|
||||
self.closed = False
|
||||
self.updated = MagicMock()
|
||||
self.size_limit_bytes = size_limit_bytes
|
||||
self.min_emit_interval = min_emit_interval
|
||||
self.estimated_bytes = gated_bytes
|
||||
self.size_gated = (
|
||||
gated_bytes is not None
|
||||
@@ -105,7 +106,13 @@ def _fake_bridge_factory(monkeypatch, gated_bytes: int | None = None):
|
||||
def factory(
|
||||
client, sources, scan="live", parent=None, min_emit_interval=0.1, size_limit_bytes=None
|
||||
):
|
||||
bridge = _FakeBridge(client, sources, scan=scan, size_limit_bytes=size_limit_bytes)
|
||||
bridge = _FakeBridge(
|
||||
client,
|
||||
sources,
|
||||
scan=scan,
|
||||
min_emit_interval=min_emit_interval,
|
||||
size_limit_bytes=size_limit_bytes,
|
||||
)
|
||||
created.append(bridge)
|
||||
return bridge
|
||||
|
||||
@@ -245,6 +252,16 @@ def test_plot_single_arg_input_2d(qtbot, mocked_client):
|
||||
np.testing.assert_array_equal(y_data, data[:, 1])
|
||||
|
||||
|
||||
def test_update_rate_reaches_bridge(qtbot, mocked_client, monkeypatch):
|
||||
"""The widget's update_rate defines the bridge coalescing interval."""
|
||||
created = _fake_bridge_factory(monkeypatch)
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
assert wf.update_rate == 15.0 # Waveform default (benchmarked)
|
||||
wf.plot(arg1="bpm4i")
|
||||
assert created, "no data bridge was created"
|
||||
assert created[-1].min_emit_interval == pytest.approx(1.0 / 15.0)
|
||||
|
||||
|
||||
def test_plot_single_arg_input_sync(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user