fix(csaxs): aerotech plot shows markers only and reuses its plot
CI for csaxs_bec / test (push) Successful in 3m40s

- the column-wise connecting lines suggested a vertical fast axis; a NaN after
  every point now breaks the curve, so only the exposure markers are drawn (X
  stays finite and sorted for clip-to-view)
- a second plot() call added a second Waveform to the window (new() with the
  same object_name does not reuse it); the existing one is now looked up with
  widget_map() and cleared

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8zoDzDfsCTY2o5bT3J7Wv
This commit is contained in:
x01dcandClaude Opus 5.5 committed 2026-10-29 15:16:44 +01:00
1 parent bff159095c
commit 8c19ed0807
3 files changed
+34 -17

No files matched your search

@@ -40,8 +40,7 @@ class cSAXSAerotech:
Without scan_number the plot shows the last scan and then follows the running
and the next aerotech_cont_grid scans, line by line; with scan_number it shows
that scan. Calling it again reuses the window. The points are sent as plain
data, so the plot shows every exposure (no downsampling); the thin lines
connect the points of each cell column.
data, so the plot shows every exposure (no downsampling), as markers.
Examples
--------
@@ -70,7 +69,10 @@ class cSAXSAerotech:
window = self.gui.windows[self.WINDOW]
else:
window = self.gui.new(self.WINDOW, timeout=self.GUI_RPC_TIMEOUT)
waveform = window.new("Waveform", object_name=self.PLOT, timeout=self.GUI_RPC_TIMEOUT)
# reuse the plot of an earlier call (new() would add a second one)
waveform = window.widget_map().get(self.PLOT)
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.x_label = "X centre (aerox)"
@@ -82,7 +84,6 @@ class cSAXSAerotech:
curve.set_color(color)
curve.set_symbol("o")
curve.set_symbol_size(4)
curve.set_pen_width(1)
curves[direction] = curve
return waveform, curves
@@ -108,11 +109,10 @@ def _lines_from_file(file_path: str) -> list[tuple[np.ndarray, np.ndarray]]:
def _column_order(lines: list[tuple[np.ndarray, np.ndarray]], backward: bool):
"""
Points of lines of one direction, ordered by cell column, the columns alternately
up and down. X then increases along the curve (up to the jitter within a column),
which the curve's clip-to-view (bisection on X) needs, and the connecting line runs
along the columns instead of zigzagging. Lines are in measurement order, so a
backward line is reversed to cell order.
Points of lines of one direction, ordered by cell column. X then increases along
the curve (up to the jitter within a column), which the curve's clip-to-view
(bisection on X) needs. Lines are in measurement order, so a backward line is
reversed to cell order.
"""
cells_x = np.array([line[0][::-1] if backward else line[0] for line in lines])
cells_y = np.array([line[1][::-1] if backward else line[1] for line in lines])
@@ -133,7 +133,18 @@ def _draw(curves: dict, lines: list[tuple[np.ndarray, np.ndarray]]) -> None:
curves[direction].set_data([np.nan], [np.nan])
continue
x, y = _column_order(selected, backward=first == 1)
curves[direction].set_data(x, y)
curves[direction].set_data(*_markers_only(x, y))
def _markers_only(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""
A NaN after every point: the curve draws no connecting lines (a non-finite value is a
gap), only the markers. X stays finite and sorted for the clip-to-view.
"""
x2 = np.repeat(x, 2)
y2 = np.full(2 * len(y), np.nan)
y2[0::2] = y
return x2, y2
class _LivePositions:
@@ -91,8 +91,8 @@ csaxs.aerotech.plot(scan_number=8494) # a finished scan
csaxs.aerotech.stop_plot() # stop following (the plot stays)
```
Every exposure is shown. The thin lines connect the points of each cell column; they
only guide the eye.
Every exposure is shown as a marker. Forward and backward lines alternate, so the two
colours alternate in Y by one line spacing.
### Examples
@@ -28,6 +28,7 @@ def _snake(n_lines=4, n_points=5, step=0.01):
def _client(windows=None):
gui = mock.MagicMock()
gui.windows = windows if windows is not None else {}
gui.new.return_value.widget_map.return_value = {}
client = mock.MagicMock(gui=gui)
client.connector.get.return_value = None
return client
@@ -77,18 +78,23 @@ def test_plot_history_draws_forward_and_backward_curves():
assert waveform.lock_aspect_ratio is True
for curve in (forward, backward):
x, y = curve.set_data.call_args.args
assert len(x) == 10 and np.all(np.diff(x) >= 0)
# markers only: a NaN after every point, X finite and increasing
assert len(x) == 20 and np.all(np.diff(x) >= 0) and np.isfinite(x).all()
assert np.isfinite(y[0::2]).all() and np.isnan(y[1::2]).all()
client.connector.register.assert_not_called() # history: no live subscription
def test_plot_reuses_open_window():
def test_plot_reuses_open_window_and_plot():
window = mock.MagicMock()
existing = mock.MagicMock()
window.widget_map.return_value = {"aerotech_positions": existing}
client = _client(windows={"aerotech": window})
tool = cSAXSAerotech(client)
with mock.patch.object(tool, "_history_lines", return_value=_snake()):
tool.plot(scan_number=1)
assert tool.plot(scan_number=1) is existing
client.gui.new.assert_not_called()
window.new.assert_called_once()
window.new.assert_not_called()
existing.clear_all.assert_called_once_with()
def test_plot_without_gui_raises():
@@ -125,7 +131,7 @@ def test_live_follows_an_aerotech_scan_line_by_line():
tool.stop_plot()
x, _ = backward.set_data.call_args.args
assert len(x) == 5
assert len(x) == 2 * 5
client.connector.unregister.assert_called()