fix(LamNI): show cached composite on manual channel switch, align toggle grid, archive smear runs
CI for csaxs_bec / test (push) Successful in 1m45s
CI for csaxs_bec / test (pull_request) Successful in 3m12s

Three follow-ups from real-hardware testing of the smear aid:

1. Switching the "Smear preview channel" toggle off then back on showed
   the live camera feed, not the previous composite. Root cause:
   Image.image()'s connect_slot subscription only delivers stream
   entries published *after* connecting (bec_lib's default
   register(from_start=False, newest_only=False) bookmarks the
   then-current last entry and only forwards newer ones) -- so nothing
   re-renders on reconnect until the next sweep pushes a new frame.
   Added a permanent, independent subscription (newest_only=True) that
   keeps the last smear_preview payload cached regardless of which
   channel is currently displayed; set_live_view_signal() now force-
   renders that cache via main_image.set_data() when switching back to
   "smear_preview", instead of waiting for a push that may never come.
   Naturally "resets" once a new sweep starts pushing fresh composites.

2. The new switch row wasn't column-aligned with the existing
   shutter/camera-running row (each used an independent QHBoxLayout
   sized by its own labels' widths). Merged both rows into one
   QGridLayout with right-aligned label columns, so all four toggles
   line up regardless of label text length. Shutter now pairs with
   Smear integrating, Camera running with Smear preview channel.

3. find_rotation_center_smear_experimental() now archives each run to
   a timestamped HDF5 file (~/data/raw/logs/xrayeye_smear_calibration/),
   mirroring align()'s existing _save_alignment_data() pattern: the FZP
   reference, one composite image + one clicked centre per iteration,
   and the final result -- collecting data towards eventually
   automating center detection from the composite offline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-24 15:44:10 +02:00
co-authored by Claude Sonnet 5
parent 7c2311a1f0
commit 9299d813ad
3 changed files with 140 additions and 31 deletions
@@ -813,6 +813,8 @@ class XrayEyeAlign:
self.gui.set_dap_params_forwarding(False)
self._reset_init_values()
self.alignment_images = []
self.smear_composites = []
self.smear_clicks = []
self.movement_buttons_enabled(False, False)
self.gui.enable_submit_button(False)
dev.omny_xray_gui.mvx.set(0)
@@ -847,7 +849,7 @@ class XrayEyeAlign:
self.send_message(
"Watch the sample rotate and locate the stationary rotation centre..."
)
self._smear_sweep(sweep_deg=sweep_deg, keep_shutter_open=keep_shutter_open)
composite = self._smear_sweep(sweep_deg=sweep_deg, keep_shutter_open=keep_shutter_open)
self.movement_buttons_enabled(True, True)
center_x, center_y = self._collect_click(
1,
@@ -855,6 +857,8 @@ class XrayEyeAlign:
label=f"rotation centre, iter {iteration}",
)
self.gui.set_live_view_signal("image")
self.smear_composites.append(composite)
self.smear_clicks.append((center_x, center_y))
delta_x, delta_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y)
cumulative_shift_x += delta_x
cumulative_shift_y += delta_y
@@ -922,8 +926,45 @@ class XrayEyeAlign:
else:
print("Shutter left open.")
timestamp = time.strftime("%Y%m%d_%H%M%S")
file_h5 = (
f"~/data/raw/logs/xrayeye_smear_calibration/xrayeye_smear_calibration_{timestamp}.h5"
)
self._save_smear_calibration_data(
file_h5,
fzp_xy=(fzp_x, fzp_y),
new_center=(new_lsamx, new_lsamy),
sweep_deg=sweep_deg,
)
return (new_lsamx, new_lsamy)
def _save_smear_calibration_data(
self,
file_path: str,
fzp_xy: tuple[float, float],
new_center: tuple[float, float],
sweep_deg: float,
):
"""Archive the raw record of a smear calibration run: one composite
image and one clicked centre position per iteration, plus the FZP
reference and final result -- so the data can be collected across
runs for offline analysis (e.g. towards automating center detection
from the composite instead of relying on an operator click).
"""
expanded = os.path.expanduser(file_path)
os.makedirs(os.path.dirname(expanded), exist_ok=True)
with h5py.File(expanded, "w") as f:
f.create_dataset("fzp_reference_mm", data=np.array(fzp_xy))
f.create_dataset("smear_composites", data=np.array(self.smear_composites))
ds = f.create_dataset(
"clicked_centers_mm", data=np.array(self.smear_clicks, dtype=float)
)
ds.attrs["columns"] = ["center_x_mm", "center_y_mm"]
f.create_dataset("new_center_mm", data=np.array(new_center))
f.attrs["sweep_deg"] = sweep_deg
print(f"[rotation-center][smear] saved calibration record to {expanded}")
# --- end EXPERIMENTAL: rotation-center smear mode ---------------------
def _ensure_at_configured_center(self, tol: float = 0.003):
@@ -272,6 +272,7 @@ class XRayEye(BECWidget, QWidget):
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self._live_view_signal = CAMERA[1]
self._last_smear_composite = None
self._connected_motor = None
self._dap_params_forwarding_connected = False
self._queue_busy = False
@@ -295,6 +296,20 @@ class XRayEye(BECWidget, QWidget):
self.bec_dispatcher.connect_slot(
self.on_queue_status_update, MessageEndpoints.scan_queue_status()
)
# Separate, permanent subscription (independent of whichever channel
# self.image is currently displaying) so the last smear composite is
# always cached -- newest_only=True delivers the current retained
# value immediately, then every future one. The "main" monitor
# subscription (image()/connect_slot with no from_start/newest_only)
# only delivers messages published *after* it connects, so switching
# back to "smear_preview" via set_live_view_signal would otherwise
# show nothing new until the next sweep, i.e. whatever was already
# on screen (whichever channel was displayed just before switching).
self.bec_dispatcher.connect_slot(
self._on_smear_preview_cached_update,
MessageEndpoints.device_preview(CAMERA[0], "smear_preview"),
newest_only=True,
)
self.connect_motors()
self.resize(800, 600)
@@ -344,45 +359,49 @@ class XRayEye(BECWidget, QWidget):
header_row.addWidget(self.live_preview_toggle, 0, Qt.AlignmentFlag.AlignVCenter)
self.control_panel_layout.addLayout(header_row)
self.switch_row_widget = QWidget(parent=self)
switch_row = QHBoxLayout(self.switch_row_widget)
switch_row.setContentsMargins(0, 0, 0, 0)
switch_row.setSpacing(8)
switch_row.addStretch()
self.camera_running_label = QLabel("Camera running", parent=self)
self.camera_running_toggle = ToggleSwitch(parent=self)
# self.camera_running_toggle.checked = False
self.camera_running_toggle.enabled.connect(self.camera_running_enabled)
# Shutter/camera-running (row 0) and the smear aid's own status
# switches (row 1) share one grid so the toggle columns actually
# line up regardless of each row's label text width -- two
# independent QHBoxLayouts (the previous approach) each size
# themselves from their own labels' widths, so corresponding
# switches in different rows land at different x-positions.
self.switch_grid_widget = QWidget(parent=self)
switch_grid = QGridLayout(self.switch_grid_widget)
switch_grid.setContentsMargins(0, 0, 0, 0)
switch_grid.setHorizontalSpacing(8)
switch_grid.setVerticalSpacing(4)
switch_grid.setColumnStretch(0, 1) # pushes both rows to the right
self.shutter_label = QLabel("Shutter open", parent=self)
self.shutter_toggle = ToggleSwitch(parent=self)
# self.shutter_toggle.checked = False
self.shutter_toggle.enabled.connect(self.opening_shutter)
switch_row.addWidget(self.shutter_label, 0, Qt.AlignmentFlag.AlignVCenter)
switch_row.addWidget(self.shutter_toggle, 0, Qt.AlignmentFlag.AlignVCenter)
switch_row.addWidget(self.camera_running_label, 0, Qt.AlignmentFlag.AlignVCenter)
switch_row.addWidget(self.camera_running_toggle, 0, Qt.AlignmentFlag.AlignVCenter)
self.control_panel_layout.addWidget(self.switch_row_widget)
self.camera_running_label = QLabel("Camera running", parent=self)
self.camera_running_toggle = ToggleSwitch(parent=self)
# self.camera_running_toggle.checked = False
self.camera_running_toggle.enabled.connect(self.camera_running_enabled)
# Smear rotation-center calibration aid: which channel is displayed,
# and whether it's currently accumulating a composite.
self.smear_row_widget = QWidget(parent=self)
smear_row = QHBoxLayout(self.smear_row_widget)
smear_row.setContentsMargins(0, 0, 0, 0)
smear_row.setSpacing(8)
smear_row.addStretch()
self.smear_active_label = QLabel("Smear integrating", parent=self)
self.smear_active_toggle = ToggleSwitch(parent=self)
self.smear_active_toggle.checked = False
self.smear_active_toggle.setEnabled(False) # read-only status, not operator-togglable
self.smear_preview_label = QLabel("Smear preview channel", parent=self)
self.smear_preview_toggle = ToggleSwitch(parent=self)
self.smear_preview_toggle.checked = False
self.smear_preview_toggle.enabled.connect(self.toggle_smear_preview_channel)
smear_row.addWidget(self.smear_active_label, 0, Qt.AlignmentFlag.AlignVCenter)
smear_row.addWidget(self.smear_active_toggle, 0, Qt.AlignmentFlag.AlignVCenter)
smear_row.addWidget(self.smear_preview_label, 0, Qt.AlignmentFlag.AlignVCenter)
smear_row.addWidget(self.smear_preview_toggle, 0, Qt.AlignmentFlag.AlignVCenter)
self.control_panel_layout.addWidget(self.smear_row_widget)
self.smear_active_label = QLabel("Smear integrating", parent=self)
self.smear_active_toggle = ToggleSwitch(parent=self)
self.smear_active_toggle.checked = False
self.smear_active_toggle.setEnabled(False) # read-only status, not operator-togglable
_right_vcenter = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
switch_grid.addWidget(self.shutter_label, 0, 1, _right_vcenter)
switch_grid.addWidget(self.shutter_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter)
switch_grid.addWidget(self.camera_running_label, 0, 3, _right_vcenter)
switch_grid.addWidget(self.camera_running_toggle, 0, 4, Qt.AlignmentFlag.AlignVCenter)
switch_grid.addWidget(self.smear_active_label, 1, 1, _right_vcenter)
switch_grid.addWidget(self.smear_active_toggle, 1, 2, Qt.AlignmentFlag.AlignVCenter)
switch_grid.addWidget(self.smear_preview_label, 1, 3, _right_vcenter)
switch_grid.addWidget(self.smear_preview_toggle, 1, 4, Qt.AlignmentFlag.AlignVCenter)
self.control_panel_layout.addWidget(self.switch_grid_widget)
# separator
self.control_panel_layout.addWidget(self._create_separator())
@@ -798,6 +817,16 @@ class XRayEye(BECWidget, QWidget):
self.live_preview_toggle.checked = enabled
self.live_preview_toggle.blockSignals(False)
@SafeSlot(dict, dict)
def _on_smear_preview_cached_update(self, msg: dict, metadata: dict):
"""Keep the last-known smear composite cached regardless of which
channel self.image is currently displaying -- see the permanent
subscription set up in __init__ and the note in
set_live_view_signal() about why this is needed."""
data = self.image._get_payload_data(msg) # pylint: disable=protected-access
if data is not None:
self._last_smear_composite = data
def set_live_view_signal(self, signal: str = CAMERA[1]) -> None:
"""Switch which camera signal the live Image view displays.
@@ -814,6 +843,14 @@ class XRayEye(BECWidget, QWidget):
self.image.image(device=CAMERA[0], signal=signal)
if hasattr(self.image, "_autorange_on_next_update"):
self.image._autorange_on_next_update = False
if signal == "smear_preview" and self._last_smear_composite is not None:
# image()/connect_slot only delivers messages published
# *after* connecting (see _on_smear_preview_cached_update's
# docstring) -- force an immediate repaint with the last
# known composite instead of waiting for a new sweep that
# may never come, so switching back shows the previous
# result, not whatever the live channel last rendered.
self.image.main_image.set_data(self._last_smear_composite)
# Keep the toggle's visual state in sync regardless of whether this
# call actually changed anything, and regardless of who called it
# (the toggle itself, or the ipython client's automation).
@@ -496,8 +496,39 @@ def test_find_rotation_center_smear_experimental_switches_live_view_signal(bec_c
with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report):
with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"):
with mock.patch(f"{XRAY_EYE_ALIGN}.input", side_effect=["y", "y"]):
align.find_rotation_center_smear_experimental()
with mock.patch.object(align, "_save_smear_calibration_data"):
align.find_rotation_center_smear_experimental()
calls = [c.args[0] for c in align.gui.set_live_view_signal.call_args_list]
assert calls == ["smear_preview", "image"]
dev_mock.cam_xeye.live_mode_enabled.put.assert_not_called()
dev_mock.cam_xeye.live_mode_enabled.put.assert_not_called()
def test_find_rotation_center_smear_experimental_saves_composites_and_clicks(bec_client_mock):
"""Composites and clicked centres from every iteration must be archived
for offline analysis -- confirms the data reaching _save_smear_calibration_data,
without touching the real filesystem."""
client = bec_client_mock
align, dev_mock = _make_calibration_align(client)
dev_mock.omny_xray_gui.xval_x_1.get.return_value = 100.0
dev_mock.omny_xray_gui.yval_y_1.get.return_value = 0.0
frame = np.zeros((2, 2), dtype=np.uint8)
dev_mock.cam_xeye.get_last_image.return_value = frame
report = _report_with_status_sequence("RUNNING", "COMPLETED")
with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock):
with mock.patch(f"{XRAY_EYE_ALIGN}.umv"):
with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report):
with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"):
with mock.patch(f"{XRAY_EYE_ALIGN}.input", side_effect=["y", "y"]):
with mock.patch.object(align, "_save_smear_calibration_data") as save_mock:
align.find_rotation_center_smear_experimental(sweep_deg=45.0)
assert len(align.smear_composites) == 1
assert np.array_equal(align.smear_composites[0], frame)
assert align.smear_clicks == [(0.1, 0.0)]
save_mock.assert_called_once()
_, kwargs = save_mock.call_args
assert kwargs["fzp_xy"] == (0.0, 0.0)
assert kwargs["sweep_deg"] == 45.0