From 663a6cf518ff6057f23261ea45e40912e5cc6ba5 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 3 Nov 2026 12:10:23 +0100 Subject: [PATCH] feat(csaxs): csaxs.aerotech.plot() title shows the plotted scan number The history plot uses the given scan number; the live plot the last scan at start and then the number of each followed aerotech_cont_grid scan. The title is set in the redraw thread, only when the scan changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ --- .../plugins/cSAXS/aerotech.py | 29 +++++++++++++++---- .../test_cSAXS_aerotech.py | 23 +++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py index 22882e59..cfeee28f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py @@ -53,8 +53,9 @@ class cSAXSAerotech: waveform, curves = self._setup_plot() if scan_number is not None: _draw(curves, self._history_lines(scan_number)) + waveform.title = _title(scan_number) return waveform - self._live = _LivePositions(self, curves) + self._live = _LivePositions(self, waveform, curves) self._live.start() return waveform @@ -74,7 +75,7 @@ class cSAXSAerotech: if waveform is None: waveform = window.new("Waveform", object_name=self.PLOT, timeout=self.GUI_RPC_TIMEOUT) waveform.clear_all() - waveform.title = "aerotech_cont_grid: exposure centres" + waveform.title = _title(None) waveform.x_label = "X centre (aerox)" waveform.y_label = "Y centre (aeroy)" waveform.lock_aspect_ratio = True @@ -96,6 +97,13 @@ class cSAXSAerotech: return _lines_from_file(scan._msg.file_path) +def _title(scan_number: int | None) -> str: + """Plot title with the number of the plotted scan.""" + if scan_number is None: + return "aerotech_cont_grid: exposure centres" + return f"aerotech_cont_grid scan {scan_number}: exposure centres" + + def _lines_from_file(file_path: str) -> list[tuple[np.ndarray, np.ndarray]]: """Exposure centres per line from an aerotech_cont_grid data file.""" with h5py.File(file_path, "r") as f: @@ -150,12 +158,15 @@ def _markers_only(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray] class _LivePositions: """Follows aerotech_cont_grid scans and redraws the exposure centres per line.""" - def __init__(self, parent: cSAXSAerotech, curves: dict): + def __init__(self, parent: cSAXSAerotech, waveform, curves: dict): self.parent = parent self.connector = parent.client.connector + self.waveform = waveform self.curves = curves self._lock = threading.Lock() self._lines: list[tuple[np.ndarray, np.ndarray]] = [] + self._scan_number: int | None = None + self._shown_scan_number: int | None = None self._scan_id = None self._stream_topic = None self._dirty = threading.Event() @@ -177,7 +188,8 @@ class _LivePositions: def _show_last_scan(self) -> None: try: - self._set_lines(_lines_from_file(self.parent.client.history[-1]._msg.file_path)) + msg = self.parent.client.history[-1]._msg + self._set_lines(_lines_from_file(msg.file_path), scan_number=msg.scan_number) except Exception: # no scan yet, or the last one is not an aerotech_cont_grid pass @@ -191,7 +203,7 @@ class _LivePositions: return self._unfollow() self._scan_id = status.scan_id - self._set_lines([]) + self._set_lines([], scan_number=status.scan_number) self._stream_topic = MessageEndpoints.device_async_signal( scan_id=status.scan_id, device=self.parent.CAPTURE_DEVICE, @@ -216,9 +228,10 @@ class _LivePositions: self._lines.append((x, y)) self._dirty.set() - def _set_lines(self, lines) -> None: + def _set_lines(self, lines, scan_number: int | None = None) -> None: with self._lock: self._lines = list(lines) + self._scan_number = scan_number self._dirty.set() def _redraw_loop(self) -> None: @@ -229,8 +242,12 @@ class _LivePositions: self._dirty.clear() with self._lock: lines = list(self._lines) + scan_number = self._scan_number try: _draw(self.curves, lines) + if scan_number != self._shown_scan_number: + self.waveform.title = _title(scan_number) + self._shown_scan_number = scan_number except Exception as exc: # window or plot closed, GUI restarted # stop following instead of failing on every line of every scan logger.warning( diff --git a/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py b/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py index 49e8a053..21e5b169 100644 --- a/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py +++ b/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py @@ -76,6 +76,7 @@ def test_plot_history_draws_forward_and_backward_curves(): "Waveform", object_name="aerotech_positions", timeout=20 ) assert waveform.lock_aspect_ratio is True + assert waveform.title == "aerotech_cont_grid scan 8494: exposure centres" for curve in (forward, backward): x, y = curve.set_data.call_args.args # markers only: a NaN after every point, X finite and increasing @@ -132,9 +133,31 @@ def test_live_follows_an_aerotech_scan_line_by_line(): x, _ = backward.set_data.call_args.args assert len(x) == 2 * 5 + assert waveform.title == "aerotech_cont_grid scan 1: exposure centres" client.connector.unregister.assert_called() +def test_live_title_shows_the_last_scan(): + client = _client() + waveform = _waveform(client) + forward, backward = mock.MagicMock(), mock.MagicMock() + waveform.plot.side_effect = [forward, backward] + client.history.__getitem__.return_value._msg = SimpleNamespace( + file_path="scan.h5", scan_number=4897 + ) + tool = cSAXSAerotech(client) + with mock.patch( + "csaxs_bec.bec_ipython_client.plugins.cSAXS.aerotech._lines_from_file", + return_value=_snake(), + ): + tool.plot() + deadline = time.time() + 3 + while time.time() < deadline and "4897" not in str(waveform.title): + time.sleep(0.05) + tool.stop_plot() + assert waveform.title == "aerotech_cont_grid scan 4897: exposure centres" + + def test_live_ignores_other_scans(): client = _client() client.history.__getitem__.side_effect = IndexError