feat(plots): user-adjustable data update rate on all plot widgets

This commit is contained in:
2026-08-14 12:01:24 +02:00
parent 9e5b6d02b9
commit 6cb29f8cdb
11 changed files with 270 additions and 12 deletions
+120 -2
View File
@@ -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):
+10
View File
@@ -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."""
+9 -1
View File
@@ -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:
+1 -1
View File
@@ -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)
+46
View File
@@ -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
+18 -1
View File
@@ -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)