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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ
This commit is contained in:
x12saandClaude Opus 5.5 committed 2026-11-03 12:10:23 +01:00
1 parent 12528a8f63
commit 663a6cf518
2 files changed
+46 -6

No files matched your search

@@ -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(
@@ -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