From 6cb29f8cdb89feea25e7e402f50736d43ad51c33 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 11 Aug 2026 20:43:54 +0200 Subject: [PATCH] feat(plots): user-adjustable data update rate on all plot widgets --- bec_widgets/cli/client.py | 122 +++++++++++++++++- bec_widgets/utils/qt_data_subscription.py | 10 ++ bec_widgets/widgets/plots/heatmap/heatmap.py | 10 +- bec_widgets/widgets/plots/image/image.py | 2 +- .../widgets/plots/motor_map/motor_map.py | 2 +- .../plots/multi_waveform/multi_waveform.py | 2 +- bec_widgets/widgets/plots/plot_base.py | 46 +++++++ .../scatter_waveform/scatter_waveform.py | 6 +- .../widgets/plots/waveform/waveform.py | 13 +- tests/unit_tests/test_plot_base_next_gen.py | 50 +++++++ tests/unit_tests/test_waveform.py | 19 ++- 11 files changed, 270 insertions(+), 12 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 108c291a..caf73412 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -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): diff --git a/bec_widgets/utils/qt_data_subscription.py b/bec_widgets/utils/qt_data_subscription.py index 5c0ee5f0..c3e0ffb3 100644 --- a/bec_widgets/utils/qt_data_subscription.py +++ b/bec_widgets/utils/qt_data_subscription.py @@ -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.""" diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index 8404e3bb..109dd150 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -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: diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index 4affc4c5..15e21897 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -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) diff --git a/bec_widgets/widgets/plots/motor_map/motor_map.py b/bec_widgets/widgets/plots/motor_map/motor_map.py index c346b08f..db16aa4b 100644 --- a/bec_widgets/widgets/plots/motor_map/motor_map.py +++ b/bec_widgets/widgets/plots/motor_map/motor_map.py @@ -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) diff --git a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py index ce848bc0..d68443cc 100644 --- a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py +++ b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py @@ -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) diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index b2c494b1..6630f1ba 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -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 ################################################################################ diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index f9e3dec7..619ab36c 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -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: diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index 73f44907..6a7a1bc5 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -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 diff --git a/tests/unit_tests/test_plot_base_next_gen.py b/tests/unit_tests/test_plot_base_next_gen.py index cadd5083..343cd141 100644 --- a/tests/unit_tests/test_plot_base_next_gen.py +++ b/tests/unit_tests/test_plot_base_next_gen.py @@ -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 diff --git a/tests/unit_tests/test_waveform.py b/tests/unit_tests/test_waveform.py index 7ded46a5..14ff9fa2 100644 --- a/tests/unit_tests/test_waveform.py +++ b/tests/unit_tests/test_waveform.py @@ -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)