From cab8844d0dd3525344a9e599b474a86891dcd9e2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 12 Aug 2026 16:03:13 +0200 Subject: [PATCH] fix(scatter_waveform): trim live x/y/z buffers to common length before plotting --- .../scatter_waveform/scatter_waveform.py | 10 ++- tests/unit_tests/test_scatter_waveform.py | 65 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 731674e6..54640730 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -1,5 +1,6 @@ from __future__ import annotations +import numpy as np import pyqtgraph as pg from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints @@ -369,7 +370,14 @@ class ScatterWaveform(PlotBase): y_data = data.get(device_y, {}).get(signal_y, {}).read().get("value", None) z_data = data.get(device_z, {}).get(signal_z, {}).read().get("value", None) - self._main_curve.set_data(x=x_data, y=y_data, z=z_data) + if x_data is None or y_data is None or z_data is None: + return + + x_data, y_data, z_data = (np.atleast_1d(arr) for arr in (x_data, y_data, z_data)) + min_len = min(len(x_data), len(y_data), len(z_data)) + if min_len == 0: + return + self._main_curve.set_data(x=x_data[:min_len], y=y_data[:min_len], z=z_data[:min_len]) def _fetch_scan_data_and_access(self): """ diff --git a/tests/unit_tests/test_scatter_waveform.py b/tests/unit_tests/test_scatter_waveform.py index 6c3dc87d..04852368 100644 --- a/tests/unit_tests/test_scatter_waveform.py +++ b/tests/unit_tests/test_scatter_waveform.py @@ -10,7 +10,7 @@ from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterW from bec_widgets.widgets.plots.scatter_waveform.settings.scatter_curve_setting import ( ScatterCurveSettings, ) -from tests.unit_tests.client_mocks import create_dummy_scan_item, mocked_client +from tests.unit_tests.client_mocks import DummyData, create_dummy_scan_item, mocked_client from .conftest import create_widget @@ -574,6 +574,69 @@ def test_z_gradient_uses_shared_brush_pool(qtbot, mocked_client): assert brushes_first[-1].color().getRgb()[:3] == hi.getRgb()[:3] +def test_scatter_waveform_live_update_unequal_lengths(qtbot, mocked_client, monkeypatch): + """Device buffers fill independently during a live scan, so one of them can + be a point ahead when the update slot fires; the data must be trimmed to the + common length instead of handing pyqtgraph mismatched arrays.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + dummy_scan = create_dummy_scan_item() + # samx is one point ahead of samy and bpm4i + dummy_scan.live_data["samx"]["samx"].val = [10, 20, 30, 40] + monkeypatch.setattr(swf.queue.scan_storage, "find_scan_by_ID", lambda scan_id: dummy_scan) + + swf.plot("samx", "samy", "bpm4i", label="live_curve") + swf.scan_id = "dummy" + swf.scan_item = dummy_scan + + swf.update_sync_curves() + + x_data, y_data = swf.main_curve.getData() + np.testing.assert_array_equal(x_data, [10, 20, 30]) + np.testing.assert_array_equal(y_data, [5, 10, 15]) + + +def test_scatter_waveform_live_update_incomplete_data(qtbot, mocked_client, monkeypatch): + """Missing or empty device buffers skip the update instead of erroring.""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + dummy_scan = create_dummy_scan_item() + dummy_scan.live_data["samy"] = {} # y signal not delivered yet + monkeypatch.setattr(swf.queue.scan_storage, "find_scan_by_ID", lambda scan_id: dummy_scan) + + swf.plot("samx", "samy", "bpm4i", label="live_curve") + swf.scan_id = "dummy" + swf.scan_item = dummy_scan + + swf.update_sync_curves() + assert swf.main_curve.getData() == (None, None) + + dummy_scan.live_data["samy"] = {"samy": DummyData(val=[], timestamps=[])} + swf.update_sync_curves() + assert swf.main_curve.getData() == (None, None) + + +def test_scatter_waveform_live_update_scalar_readback(qtbot, mocked_client, monkeypatch): + """The first live update can deliver bare scalar readbacks instead of buffers; + they must be plotted as a single point, not raise TypeError on len().""" + swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) + + dummy_scan = create_dummy_scan_item() + for device in ("samx", "samy", "bpm4i"): + dummy_scan.live_data[device][device] = DummyData(val=5.0, timestamps=100) + monkeypatch.setattr(swf.queue.scan_storage, "find_scan_by_ID", lambda scan_id: dummy_scan) + + swf.plot("samx", "samy", "bpm4i", label="live_curve") + swf.scan_id = "dummy" + swf.scan_item = dummy_scan + + swf.update_sync_curves() + + x_data, y_data = swf.main_curve.getData() + np.testing.assert_array_equal(x_data, [5.0]) + np.testing.assert_array_equal(y_data, [5.0]) + + def test_z_gradient_flat_and_empty_z(qtbot, mocked_client): """Degenerate z inputs keep returning None (no gradient).""" swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)