From 59f30613c9020f5351c5b6c75bc350c213d52ddb Mon Sep 17 00:00:00 2001 From: Mirko Holler Date: Tue, 13 Oct 2026 07:14:32 +0200 Subject: [PATCH 1/3] feat(xrayeye): rotatable, angle-lockable elliptical ROI for LamNI rotation-centre calibration Ellipse tool is hidden by default and only enabled by the LamNI client in the extended/smear rotation-centre modes after the FZP click. Adds angle lock toggle/field, rotation-aware geometry, and roi_angle_{step} submit signal. Co-Authored-By: Claude Sonnet 5.5 --- .../plugins/LamNI/x_ray_eye_align.py | 186 +++++++------- csaxs_bec/bec_widgets/widgets/client.py | 38 +++ .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 232 +++++++++++++++++- csaxs_bec/devices/omny/xray_epics_gui.py | 6 + .../test_x_ray_eye_ellipse_roi.py | 130 ++++++++++ 5 files changed, 505 insertions(+), 87 deletions(-) create mode 100644 tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py index 761a4ed2..56ef03f1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py @@ -3,6 +3,7 @@ from __future__ import annotations import builtins import os import time +from contextlib import contextmanager from typing import TYPE_CHECKING import h5py @@ -793,6 +794,21 @@ class XrayEyeAlign: dev.omny_xray_gui.mvy.set(0) self.update_frame(keep_shutter_open) + @contextmanager + def _ellipse_roi_enabled(self): + """Offer the rotatable elliptical ROI in the GUI for the enclosed clicks. + + Only for the extended-sample rotation-centre calibration, and only entered after + the FZP click (step 0), so the FZP and isolated-particle clicks keep the + rectangle/circle tools. Always switched off again (which also removes the + ellipse and releases its angle lock). + """ + self.gui.enable_ellipse_roi(True) + try: + yield + finally: + self.gui.enable_ellipse_roi(False) + def _collect_click( self, k: int, @@ -1084,56 +1100,57 @@ class XrayEyeAlign: self.gui.set_crosshair_position(fzp_x / self.pixel_calibration, fzp_y / self.pixel_calibration) self.gui.show_crosshair() - iteration = 0 - cumulative_shift_x, cumulative_shift_y = 0.0, 0.0 - while True: - iteration += 1 - print(f"[rotation-center][smear] === iteration {iteration} ===") - self.send_message( - "Watch the sample rotate and locate the stationary rotation centre..." - ) - composite = self._smear_sweep( - sweep_deg=sweep_deg, - keep_shutter_open=keep_shutter_open, - projection=projection, - opaque_level=opaque_level, - ) - self.movement_buttons_enabled(True, True) - center_x, center_y = self._collect_click( - 1, - "Submit the rotation centre.", - 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 - print( - f"[rotation-center][smear] cumulative shift from originally-configured center: " - f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm" - ) - self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y) - - self.send_message("Verifying alignment...") - # No 45 deg detour here (unlike the non-smear path below): the - # _smear_sweep above already rotated continuously through the - # full range with the operator watching, so a stop at 45 deg - # would just re-tread ground already covered -- go straight back - # to 0 deg (still a full ~360 deg move since lsamrot doesn't - # wrap) with live view running. - self._live_sweep([0]) - self.update_frame(keep_shutter_open) - if self.lamni.OMNYTools.yesno( - "Alignment acceptable -- stop here? (n = run another iteration)", "y" - ): - print( - f"[rotation-center][smear] operator accepted alignment after " - f"{iteration} iteration(s)" + with self._ellipse_roi_enabled(): + iteration = 0 + cumulative_shift_x, cumulative_shift_y = 0.0, 0.0 + while True: + iteration += 1 + print(f"[rotation-center][smear] === iteration {iteration} ===") + self.send_message( + "Watch the sample rotate and locate the stationary rotation centre..." ) - break - print("[rotation-center][smear] operator requested another iteration.") + composite = self._smear_sweep( + sweep_deg=sweep_deg, + keep_shutter_open=keep_shutter_open, + projection=projection, + opaque_level=opaque_level, + ) + self.movement_buttons_enabled(True, True) + center_x, center_y = self._collect_click( + 1, + "Submit the rotation centre.", + 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 + print( + f"[rotation-center][smear] cumulative shift from originally-configured center: " + f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm" + ) + self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y) + + self.send_message("Verifying alignment...") + # No 45 deg detour here (unlike the non-smear path below): the + # _smear_sweep above already rotated continuously through the + # full range with the operator watching, so a stop at 45 deg + # would just re-tread ground already covered -- go straight back + # to 0 deg (still a full ~360 deg move since lsamrot doesn't + # wrap) with live view running. + self._live_sweep([0]) + self.update_frame(keep_shutter_open) + if self.lamni.OMNYTools.yesno( + "Alignment acceptable -- stop here? (n = run another iteration)", "y" + ): + print( + f"[rotation-center][smear] operator accepted alignment after " + f"{iteration} iteration(s)" + ) + break + print("[rotation-center][smear] operator requested another iteration.") self.gui.hide_crosshair() new_lsamx = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"] @@ -1457,42 +1474,43 @@ class XrayEyeAlign: target_x, target_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y) self._apply_rotation_center_shift(target_x, target_y) else: - iteration = 0 - cumulative_shift_x, cumulative_shift_y = 0.0, 0.0 - while True: - iteration += 1 - print(f"[rotation-center] === iteration {iteration} ===") - self.send_message( - "Watch the sample rotate and locate the stationary rotation centre..." - ) - self._live_sweep([180, 0]) - self.update_frame(keep_shutter_open) - self.movement_buttons_enabled(False, False) - center_x, center_y = self._collect_click( - 1, - "Submit the rotation centre.", - label=f"rotation centre, iter {iteration}", - ) - 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 - print( - f"[rotation-center] cumulative shift from originally-configured center: " - f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm" - ) - self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y) - - self.send_message("Verifying alignment...") - self._live_sweep([45, 0]) - self.update_frame(keep_shutter_open) - if self.lamni.OMNYTools.yesno( - "Alignment acceptable -- stop here? (n = run another iteration)", "y" - ): - print( - f"[rotation-center] operator accepted alignment after {iteration} iteration(s)" + with self._ellipse_roi_enabled(): + iteration = 0 + cumulative_shift_x, cumulative_shift_y = 0.0, 0.0 + while True: + iteration += 1 + print(f"[rotation-center] === iteration {iteration} ===") + self.send_message( + "Watch the sample rotate and locate the stationary rotation centre..." ) - break - print("[rotation-center] operator requested another iteration.") + self._live_sweep([180, 0]) + self.update_frame(keep_shutter_open) + self.movement_buttons_enabled(False, False) + center_x, center_y = self._collect_click( + 1, + "Submit the rotation centre.", + label=f"rotation centre, iter {iteration}", + ) + 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 + print( + f"[rotation-center] cumulative shift from originally-configured center: " + f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm" + ) + self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y) + + self.send_message("Verifying alignment...") + self._live_sweep([45, 0]) + self.update_frame(keep_shutter_open) + if self.lamni.OMNYTools.yesno( + "Alignment acceptable -- stop here? (n = run another iteration)", "y" + ): + print( + f"[rotation-center] operator accepted alignment after {iteration} iteration(s)" + ) + break + print("[rotation-center] operator requested another iteration.") self.gui.hide_crosshair() new_lsamx = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"] diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 9c5945e8..48d0b7d0 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -295,6 +295,44 @@ class OMNY_XRayEye(RPCBase): Stop auto-snapping ROIs' vertical center -- see ``lock_vertical_center()``. """ + @rpc_timeout(20) + @rpc_call + def enable_ellipse_roi(self, enable: "bool"): + """ + Show/hide the elliptical ROI tool and its angle controls. + + Hidden by default; the LamNI client enables it for the rotation-centre + calibration modes after the FZP centre has been submitted. Disabling removes + an existing ellipse and releases the angle lock. + + Args: + enable(bool): True to offer the ellipse tool. + """ + + @rpc_timeout(20) + @rpc_call + def lock_roi_angle(self, angle: "float | None" = None): + """ + Fix the rotation of elliptical ROIs, now and for ellipses drawn later. + + Args: + angle(float | None): Rotation in degrees. If None, the active ellipse's + current rotation (or, without one, the angle in the spin box) is used. + """ + + @rpc_timeout(20) + @rpc_call + def unlock_roi_angle(self): + """ + Let elliptical ROIs rotate freely again -- see ``lock_roi_angle()``. + """ + + @rpc_call + def roi_angle(self) -> "float | None": + """ + Rotation in degrees of the active elliptical ROI, or None if there is none. + """ + @property @rpc_call def crosshair_visible(self) -> "bool": diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 92da4806..03b32abc 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -11,13 +11,19 @@ from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.waveform.waveform import Waveform from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree -from bec_widgets.widgets.plots.roi.image_roi import BaseROI, CircularROI, RectangularROI +from bec_widgets.widgets.plots.roi.image_roi import ( + BaseROI, + CircularROI, + EllipticalROI, + RectangularROI, +) from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( QFrame, QGridLayout, QHBoxLayout, + QDoubleSpinBox, QLabel, QLineEdit, QPushButton, @@ -55,6 +61,33 @@ SINE_FIT_DAP_PARAMETERS = [ ] +def ellipse_geometry(roi) -> dict: + """Centre, axes and rotation of an (optionally rotated) ``EllipticalROI``. + + ``EllipticalROI.get_coordinates()`` ignores the rotation (the ROI rotates about + its ``pos()``, not its centre) and sorts the axes by size. Here the centre is + mapped through the ROI transform, ``width``/``height`` are the axes along the + ROI's local x/y, and ``angle`` (degrees, pyqtgraph convention, normalised to + (-90, 90]; an ellipse is symmetric under 180 deg) is the rotation of the local + x axis in data coordinates. + """ + size_x, size_y = roi.state["size"] + centre = roi.mapToParent(pg.Point(size_x / 2, size_y / 2)) + return { + "center_x": centre.x(), + "center_y": centre.y(), + "width": abs(size_x), + "height": abs(size_y), + "angle": _normalize_angle(roi.angle()), + } + + +def _normalize_angle(angle: float) -> float: + """Map an angle in degrees onto (-90, 90].""" + angle = (angle + 90.0) % 180.0 - 90.0 + return 90.0 if math.isclose(angle, -90.0, abs_tol=1e-9) else angle + + class TargetCrosshair: """ Fixed, RPC-positionable crosshair overlay for an image plot item. @@ -274,6 +307,10 @@ class OMNY_XRayEye(BECWidget, QWidget): "hide_crosshair", "lock_vertical_center", "unlock_vertical_center", + "enable_ellipse_roi", + "lock_roi_angle", + "unlock_roi_angle", + "roi_angle", "crosshair_visible", "crosshair_visible.setter", "set_crosshair_position", @@ -322,7 +359,12 @@ class OMNY_XRayEye(BECWidget, QWidget): self.target_crosshair = TargetCrosshair(self.image.plot_item) # See lock_vertical_center()/unlock_vertical_center()/_snap_roi_vertical() self._vertical_lock_enabled = False + # Elliptical ROI (LamNI centre calibration only, see enable_ellipse_roi()); + # _roi_angle_locked is the fixed rotation in degrees, or None if free. + self._ellipse_enabled = False + self._roi_angle_locked = None self._make_connections() + self.enable_ellipse_roi(False) # Connection to redis endpoints self.bec_dispatcher.connect_slot( @@ -456,6 +498,20 @@ class OMNY_XRayEye(BECWidget, QWidget): self.vertical_lock_toggle.checked = False self.vertical_lock_toggle.enabled.connect(self._on_vertical_lock_toggle_changed) + # Angle fix for the elliptical ROI (see lock_roi_angle()); only visible + # while the ellipse tool is enabled (enable_ellipse_roi()). + self.roi_angle_lock_label = QLabel("ROI angle lock", parent=self) + self.roi_angle_lock_toggle = ToggleSwitch(parent=self) + self.roi_angle_lock_toggle.checked = False + self.roi_angle_lock_toggle.enabled.connect(self._on_roi_angle_lock_toggle_changed) + self.roi_angle_spinbox = QDoubleSpinBox(parent=self) + self.roi_angle_spinbox.setRange(-90.0, 90.0) + self.roi_angle_spinbox.setDecimals(1) + self.roi_angle_spinbox.setSingleStep(1.0) + self.roi_angle_spinbox.setSuffix(" deg") + self.roi_angle_spinbox.setToolTip("Rotation of the elliptical ROI") + self.roi_angle_spinbox.valueChanged.connect(self._on_roi_angle_spinbox_changed) + _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) @@ -469,6 +525,9 @@ class OMNY_XRayEye(BECWidget, QWidget): switch_grid.addWidget(self.smear_preview_toggle, 2, 4, Qt.AlignmentFlag.AlignVCenter) switch_grid.addWidget(self.vertical_lock_label, 3, 1, _right_vcenter) switch_grid.addWidget(self.vertical_lock_toggle, 3, 2, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.roi_angle_lock_label, 4, 1, _right_vcenter) + switch_grid.addWidget(self.roi_angle_lock_toggle, 4, 2, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.roi_angle_spinbox, 4, 3, 1, 2) self.control_panel_layout.addWidget(self.switch_grid_widget) @@ -689,6 +748,8 @@ class OMNY_XRayEye(BECWidget, QWidget): def _style_new_roi(self, roi): """Force a thinner outline on newly drawn ROIs (color is set via compact_color).""" roi.line_width = self.ROI_LINE_WIDTH + if isinstance(roi, EllipticalROI): + self._setup_ellipse(roi) # Live-update the microns readout while this ROI is drawn/resized. roi.sigRegionChanged.connect(lambda r=roi: self._update_roi_size_readout(r)) self._update_roi_size_readout(roi) @@ -708,6 +769,13 @@ class OMNY_XRayEye(BECWidget, QWidget): if not self._vertical_lock_enabled or not self.target_crosshair.is_visible(): return _, fzp_y = self.crosshair_position() + if isinstance(roi, EllipticalROI): + # The ellipse rotates about pos(), so move it by the centre's offset. + geo = ellipse_geometry(roi) + dy = fzp_y - geo["center_y"] + if not math.isclose(dy, 0.0, abs_tol=1e-9): + roi.setPos(roi.pos().x(), roi.pos().y() + dy) + return if isinstance(roi, RectangularROI): height = roi.get_coordinates(typed=True)["height"] elif isinstance(roi, CircularROI): @@ -764,6 +832,14 @@ class OMNY_XRayEye(BECWidget, QWidget): # Read the raw x/y size so a non-circular (ellipse) state is reflected # correctly; for an aspect-locked circle both values are equal. size_x, size_y = roi.state["size"] + elif isinstance(roi, EllipticalROI): + geo = ellipse_geometry(roi) + scale, unit = self._microns_per_pixel() + self.roi_size_line_edit.setText( + f"a = {geo['width'] * scale:.1f} {unit}, b = {geo['height'] * scale:.1f} {unit}, " + f"angle = {geo['angle']:.1f} deg" + ) + return else: self.roi_size_line_edit.setText("") return @@ -967,6 +1043,142 @@ class OMNY_XRayEye(BECWidget, QWidget): else: self.unlock_vertical_center() + # ------------------------------------------------------------------ + # Elliptical ROI (LamNI rotation-centre calibration) + # ------------------------------------------------------------------ + + def _setup_ellipse(self, roi: EllipticalROI): + """Add independent axis scale handles and the angle bookkeeping to a new ellipse. + + pyqtgraph's ``EllipseROI`` only has a rotate handle and one uniform scale handle, + so the aspect ratio could not be changed after drawing. + """ + roi.addScaleHandle([0.0, 0.5], [1.0, 0.5]) # width, anchored at the opposite side + roi.addScaleHandle([0.5, 1.0], [0.5, 0.0]) # height + roi.sigRegionChanged.connect(lambda r=roi: self._on_ellipse_changed(r)) + if self._roi_angle_locked is not None: + self._apply_locked_angle(roi) + self._set_rotate_handle_visible(roi, self._roi_angle_locked is None) + self._sync_angle_spinbox(roi) + + @staticmethod + def _set_rotate_handle_visible(roi, visible: bool): + for handle in roi.handles: + if handle["type"] == "r": + handle["item"].setVisible(visible) + + def _apply_locked_angle(self, roi): + """Rotate `roi` about its centre to the locked angle (no-op if already there).""" + locked = self._roi_angle_locked + if locked is None: + return + if math.isclose(_normalize_angle(roi.angle()), _normalize_angle(locked), abs_tol=1e-6): + return + roi.setAngle(locked, center=(0.5, 0.5)) + + def _on_ellipse_changed(self, roi): + self._apply_locked_angle(roi) + self._sync_angle_spinbox(roi) + + def _sync_angle_spinbox(self, roi=None): + """Show the active ellipse's rotation in the spin box without re-triggering it.""" + if roi is None: + roi = self.roi_manager.single_active_roi + if not isinstance(roi, EllipticalROI): + return + self.roi_angle_spinbox.blockSignals(True) + self.roi_angle_spinbox.setValue(ellipse_geometry(roi)["angle"]) + self.roi_angle_spinbox.blockSignals(False) + + @SafeSlot(bool) + @rpc_timeout(20) + def enable_ellipse_roi(self, enable: bool): + """Show/hide the elliptical ROI tool and its angle controls. + + Hidden by default; the LamNI client enables it for the rotation-centre + calibration modes after the FZP centre has been submitted. Disabling removes + an existing ellipse and releases the angle lock. + + Args: + enable(bool): True to offer the ellipse tool. + """ + self._ellipse_enabled = enable + self.roi_manager.toolbar.components.get_action("roi_ellipse").action.setVisible(enable) + for widget in (self.roi_angle_lock_label, self.roi_angle_lock_toggle, self.roi_angle_spinbox): + widget.setVisible(enable) + if not enable: + self.unlock_roi_angle() + self.roi_manager.toolbar.components.get_action("roi_ellipse").action.setChecked(False) + active = self.roi_manager.single_active_roi + if isinstance(active, EllipticalROI): + self.roi_manager.controller.remove_roi(active) + self._update_roi_size_readout(None) + + @SafeSlot() + @rpc_timeout(20) + def lock_roi_angle(self, angle: float | None = None): + """Fix the rotation of elliptical ROIs, now and for ellipses drawn later. + + Args: + angle(float | None): Rotation in degrees. If None, the active ellipse's + current rotation (or, without one, the angle in the spin box) is used. + """ + if angle is None: + active = self.roi_manager.single_active_roi + if isinstance(active, EllipticalROI): + angle = ellipse_geometry(active)["angle"] + else: + angle = self.roi_angle_spinbox.value() + self._roi_angle_locked = _normalize_angle(float(angle)) + self.roi_angle_lock_toggle.blockSignals(True) + self.roi_angle_lock_toggle.checked = True + self.roi_angle_lock_toggle.blockSignals(False) + self.roi_angle_spinbox.blockSignals(True) + self.roi_angle_spinbox.setValue(self._roi_angle_locked) + self.roi_angle_spinbox.blockSignals(False) + for roi in self.roi_manager.controller.rois: + if isinstance(roi, EllipticalROI): + self._apply_locked_angle(roi) + self._set_rotate_handle_visible(roi, False) + self._update_roi_size_readout(roi) + + @SafeSlot() + @rpc_timeout(20) + def unlock_roi_angle(self): + """Let elliptical ROIs rotate freely again -- see ``lock_roi_angle()``.""" + self._roi_angle_locked = None + self.roi_angle_lock_toggle.blockSignals(True) + self.roi_angle_lock_toggle.checked = False + self.roi_angle_lock_toggle.blockSignals(False) + for roi in self.roi_manager.controller.rois: + if isinstance(roi, EllipticalROI): + self._set_rotate_handle_visible(roi, True) + + @SafeSlot(bool) + def _on_roi_angle_lock_toggle_changed(self, enabled: bool): + if enabled: + self.lock_roi_angle() + else: + self.unlock_roi_angle() + + @SafeSlot(float) + def _on_roi_angle_spinbox_changed(self, angle: float): + """Operator typed an angle: rotate the active ellipse (and the lock, if engaged).""" + if self._roi_angle_locked is not None: + self.lock_roi_angle(angle) + return + active = self.roi_manager.single_active_roi + if isinstance(active, EllipticalROI): + active.setAngle(angle, center=(0.5, 0.5)) + + @SafeSlot() + def roi_angle(self) -> float | None: + """Rotation in degrees of the active elliptical ROI, or None if there is none.""" + active = self.roi_manager.single_active_roi + if isinstance(active, EllipticalROI): + return ellipse_geometry(active)["angle"] + return None + @SafeProperty(bool) def crosshair_visible(self) -> bool: """Whether the alignment target crosshair is currently shown.""" @@ -1390,11 +1602,20 @@ class OMNY_XRayEye(BECWidget, QWidget): if self.roi_manager.single_active_roi is None: logger.warning("No active ROI") return - roi_coordinates = self.roi_manager.single_active_roi.get_coordinates() + active_roi = self.roi_manager.single_active_roi + roi_angle = None + if isinstance(active_roi, EllipticalROI): + roi_coordinates = ellipse_geometry(active_roi) + roi_angle = roi_coordinates["angle"] + else: + roi_coordinates = active_roi.get_coordinates() roi_center_x = roi_coordinates["center_x"] roi_center_y = roi_coordinates["center_y"] # Case of rectangular ROI - if isinstance(self.roi_manager.single_active_roi, RectangularROI): + if isinstance(active_roi, EllipticalROI): + roi_width = roi_coordinates["width"] + roi_height = roi_coordinates["height"] + elif isinstance(self.roi_manager.single_active_roi, RectangularROI): roi_width = roi_coordinates["width"] roi_height = roi_coordinates["height"] elif isinstance(self.roi_manager.single_active_roi, CircularROI): @@ -1411,6 +1632,11 @@ class OMNY_XRayEye(BECWidget, QWidget): getattr(self.dev.omny_xray_gui, f"yval_y_{step}").set(roi_center_y) getattr(self.dev.omny_xray_gui, f"width_x_{step}").set(roi_width) getattr(self.dev.omny_xray_gui, f"width_y_{step}").set(roi_height) + # Rotation only matters for the ellipse; reset it for every other ROI so a + # stale angle from an earlier submit is never read for this step. + angle_signal = getattr(self.dev.omny_xray_gui, f"roi_angle_{step}", None) + if angle_signal is not None: + angle_signal.set(roi_angle if roi_angle is not None else 0.0) self.dev.omny_xray_gui.submit.set(1) finally: self.submit_button.blockSignals(False) diff --git a/csaxs_bec/devices/omny/xray_epics_gui.py b/csaxs_bec/devices/omny/xray_epics_gui.py index 0f0760de..a18e4df6 100644 --- a/csaxs_bec/devices/omny/xray_epics_gui.py +++ b/csaxs_bec/devices/omny/xray_epics_gui.py @@ -32,6 +32,12 @@ class OMNYXRayAlignGUI(Device): for i in range(11): locals()[f"width_x_{i}"] = Cpt(Signal, value=0) + # Generate roi_angle_0 to roi_angle_10 (rotation of an elliptical ROI in degrees, + # see OMNY_XRayEye; width_x/width_y are then the ellipse axes along/perpendicular + # to this rotation) + for i in range(11): + locals()[f"roi_angle_{i}"] = Cpt(Signal, value=0) + # Generate xval_x_0 to xval_x_10 for i in range(11): locals()[f"xval_x_{i}"] = Cpt(Signal, value=0) diff --git a/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py b/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py new file mode 100644 index 00000000..e07ece3b --- /dev/null +++ b/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py @@ -0,0 +1,130 @@ +"""Tests for OMNY_XRayEye's elliptical ROI (LamNI rotation-centre calibration): +rotation-aware geometry, angle lock, vertical snap, and the submit payload. + +Like test_x_ray_eye_vertical_lock.py, the widget methods are borrowed onto a +minimal stub (the full widget needs a live BEC client context). The ROIs +themselves are pyqtgraph ellipses; ``EllipticalROI`` is only needed for the +``isinstance`` dispatch, so a bare subclass of ``pg.EllipseROI`` that is +registered as one is used instead of the BECConnector-based real class. +""" + +import math +from unittest import mock + +import pyqtgraph as pg +import pytest + +from csaxs_bec.bec_widgets.widgets.xray_eye import x_ray_eye +from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import ( + OMNY_XRayEye, + _normalize_angle, + ellipse_geometry, +) + + +@pytest.fixture(autouse=True, scope="module") +def _qapp(): + return pg.mkQApp() + + +class _FakeEllipticalROI(pg.EllipseROI): + """pg ellipse standing in for bec_widgets' EllipticalROI in isinstance checks.""" + + +@pytest.fixture(autouse=True) +def _patch_roi_class(): + with mock.patch.object(x_ray_eye, "EllipticalROI", _FakeEllipticalROI): + yield + + +def _roi(pos=(10.0, 20.0), size=(40.0, 20.0), angle=0.0): + roi = _FakeEllipticalROI(pos, size, angle=angle) + return roi + + +class _Stub: + _apply_locked_angle = OMNY_XRayEye._apply_locked_angle + _set_rotate_handle_visible = staticmethod(OMNY_XRayEye._set_rotate_handle_visible) + _snap_roi_vertical = OMNY_XRayEye._snap_roi_vertical + + +def _stub(locked=None, lock_vertical=False, fzp_y=100.0): + stub = _Stub() + stub._roi_angle_locked = locked + stub._vertical_lock_enabled = lock_vertical + stub.target_crosshair = mock.Mock() + stub.target_crosshair.is_visible.return_value = True + stub.crosshair_position = mock.Mock(return_value=(0.0, fzp_y)) + return stub + + +@pytest.mark.parametrize("raw, expected", [(0, 0), (90, 90), (-90, 90), (100, -80), (270, 90), (45, 45)]) +def test_normalize_angle(raw, expected): + assert _normalize_angle(raw) == pytest.approx(expected) + + +def test_geometry_unrotated(): + geo = ellipse_geometry(_roi()) + assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 30.0)) + assert (geo["width"], geo["height"], geo["angle"]) == pytest.approx((40.0, 20.0, 0.0)) + + +def test_geometry_centre_is_independent_of_rotation_about_centre(): + roi = _roi() + roi.setAngle(30.0, center=(0.5, 0.5)) + geo = ellipse_geometry(roi) + assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 30.0)) + assert geo["angle"] == pytest.approx(30.0) + # axes are not sorted by size: width stays the local-x axis + assert (geo["width"], geo["height"]) == pytest.approx((40.0, 20.0)) + + +def test_geometry_handles_negative_size_from_up_left_drag(): + geo = ellipse_geometry(_roi(pos=(50.0, 50.0), size=(-40.0, -20.0))) + assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 40.0)) + assert (geo["width"], geo["height"]) == pytest.approx((40.0, 20.0)) + + +def test_apply_locked_angle_rotates_about_centre(): + roi = _roi() + _stub(locked=40.0)._apply_locked_angle(roi) + geo = ellipse_geometry(roi) + assert geo["angle"] == pytest.approx(40.0) + assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 30.0)) + + +def test_apply_locked_angle_noop_when_free_or_already_at_angle(): + roi = _roi(angle=25.0) + _stub(locked=None)._apply_locked_angle(roi) + assert roi.angle() == pytest.approx(25.0) + with mock.patch.object(roi, "setAngle") as set_angle: + _stub(locked=25.0)._apply_locked_angle(roi) + # equivalent angle mod 180 deg is also "already there" + _stub(locked=-155.0)._apply_locked_angle(roi) + set_angle.assert_not_called() + + +def test_rotate_handle_visibility(): + roi = _roi() + rotate = [h["item"] for h in roi.handles if h["type"] == "r"] + assert len(rotate) == 1 + _Stub._set_rotate_handle_visible(roi, False) + assert rotate[0].isVisible() is False + _Stub._set_rotate_handle_visible(roi, True) + assert rotate[0].isVisible() is True + + +def test_snap_vertical_moves_ellipse_centre_to_crosshair_when_rotated(): + roi = _roi() + roi.setAngle(30.0, center=(0.5, 0.5)) + _stub(lock_vertical=True, fzp_y=100.0)._snap_roi_vertical(roi) + geo = ellipse_geometry(roi) + assert geo["center_y"] == pytest.approx(100.0) + assert geo["center_x"] == pytest.approx(30.0) + assert geo["angle"] == pytest.approx(30.0) + + +def test_snap_vertical_noop_when_lock_off(): + roi = _roi() + _stub(lock_vertical=False)._snap_roi_vertical(roi) + assert ellipse_geometry(roi)["center_y"] == pytest.approx(30.0) -- 2.54.0 From 636eb55e96542a6c3ebf3f47aa297f3b56ea6a8a Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 13 Oct 2026 09:50:40 +0200 Subject: [PATCH 2/3] feat(lamni): refit the X-ray eye sine fit without bad clicks lamni.xrayeye_refit([angles]) redoes the sinusoidal X-ray eye fit from the last alignment's points, leaving out the given angles (misclicks, sample barely visible). Prints each point's deviation from the new fit, shows the kept points on the GUI Fit tab and asks before replacing tomo_fit_xray_eye. The raw offsets are kept in the lamni_xeye_fit_data global var (cleared with the fit); archived alignments can be refitted via file=. Also clear omny_xray_gui.fit_params_x/y before submitting new alignment points, so a previous run's fit is not taken as the new one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ALDnH16dC6iyHjhe5Ey4w9 --- .../plugins/LamNI/lamni_alignment_mixin.py | 145 ++++++++++++++- .../plugins/LamNI/x_ray_eye_align.py | 9 + .../test_lamni_xrayeye_refit.py | 165 ++++++++++++++++++ 3 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 tests/tests_bec_ipython_client/test_lamni_xrayeye_refit.py diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py index a60d914f..f829f225 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py @@ -17,6 +17,7 @@ from __future__ import annotations import builtins import os +import h5py import numpy as np from bec_lib import bec_logger from typeguard import typechecked @@ -25,6 +26,27 @@ logger = bec_logger.logger dev = builtins.__dict__.get("dev") +# Raw per-angle offsets of the most recent X-ray eye alignment, as written by +# XrayEyeAlign.write_output(): [angles_deg, x_offsets_um, y_offsets_um]. +XEYE_FIT_DATA_VAR = "lamni_xeye_fit_data" + + +def fit_xray_eye_sine(angles_deg, values): + """Least-squares fit of ``A * sin(radians(angle) + B) + C`` to *values*. + + Same model as the XRayEye GUI's DAP fit (SineModel with the frequency + fixed to 1 cycle/360 deg, plus a constant) and the same convention as + ``tomo_fit_xray_eye``. Written as ``a*sin + b*cos + C`` the model is + linear, so the fit is exact and needs no start values. + + Returns: + tuple: ``(A, B, C)`` with ``A >= 0`` and ``B`` in (-pi, pi]. + """ + theta = np.radians(np.asarray(angles_deg, dtype=float)) + design = np.column_stack([np.sin(theta), np.cos(theta), np.ones_like(theta)]) + (a, b, c), *_ = np.linalg.lstsq(design, np.asarray(values, dtype=float), rcond=None) + return float(np.hypot(a, b)), float(np.arctan2(b, a)), float(c) + class LamNIAlignmentMixin: """Correction infrastructure for LamNI laminography. @@ -54,8 +76,10 @@ class LamNIAlignmentMixin: self.corr_angle_2 = [] def reset_xray_eye_correction(self): - """Delete the X-ray eye sinusoidal fit from the BEC global variable store.""" + """Delete the X-ray eye sinusoidal fit, and the alignment points it was + fitted to, from the BEC global variable store.""" self.client.delete_global_var("tomo_fit_xray_eye") + self.client.delete_global_var(XEYE_FIT_DATA_VAR) # ------------------------------------------------------------------ # Additional lookup-table correction properties -- global-var-backed so @@ -241,6 +265,125 @@ class LamNIAlignmentMixin: print("New alignment parameters loaded from XRayEye GUI fit:") self._print_xeye_fit(tomo_fit_xray_eye) + def xrayeye_refit(self, exclude_angles=None, file: str | None = None, confirm: bool = True): + """Redo the X-ray eye sinusoidal fit, optionally leaving out angles. + + Use this after ``xrayeye_alignment_start()`` when one click is clearly + off the curve (sample barely visible at that angle, misclick, ...). + The clicks of the last alignment are refitted without the excluded + angles, a table of all points with their deviation from the new fit + is printed, and after confirmation the result replaces + ``tomo_fit_xray_eye``. Nothing is moved and no new frame is taken. + + Call it without arguments first to see the deviations of all points + from the full fit and spot the outlier. Calling it again with an + empty exclusion list restores the fit over all points. + + Args: + exclude_angles: angle or list of angles in deg (of 0, 45, ..., + 315) whose point is left out of the fit in both x and y. + file: refit an archived alignment instead of the last one, e.g. + ~/data/raw/logs/xrayeye_alignmentvalues/xrayeye_alignmentvalues_.h5 + confirm: ask before replacing the stored fit. + + Example: + lamni.xrayeye_refit() # inspect all points + lamni.xrayeye_refit([135]) # drop the 135 deg click + lamni.xrayeye_refit([135, 270]) + """ + data = self._load_xeye_fit_data(file) + angles = np.asarray(data[0], dtype=float) + offsets = {"x": np.asarray(data[1], dtype=float), "y": np.asarray(data[2], dtype=float)} + + if exclude_angles is None: + exclude_angles = [] + elif np.isscalar(exclude_angles): + exclude_angles = [exclude_angles] + excluded = np.zeros(len(angles), dtype=bool) + for angle in exclude_angles: + match = np.isclose(angles, float(angle) % 360) + if not match.any(): + raise ValueError( + f"No alignment point at {angle} deg. " + f"Available angles: {', '.join(f'{a:g}' for a in angles)}." + ) + excluded |= match + kept = ~excluded + # 3 free parameters per axis; with only 3 points any set of clicks + # fits exactly, so a bad click could no longer be noticed. + if kept.sum() < 4: + raise ValueError( + f"Only {kept.sum()} of {len(angles)} points left -- at least 4 are " + "needed for a meaningful fit." + ) + + fit = np.array([fit_xray_eye_sine(angles[kept], offsets[ax][kept]) for ax in ("x", "y")]) + residuals = {} + for i, ax in enumerate(("x", "y")): + amp, shift, const = fit[i] + residuals[ax] = offsets[ax] - (amp * np.sin(np.radians(angles) + shift) + const) + + print("X-ray eye points (offsets and deviation from the new fit, in um):") + print(" angle x_off x_dev y_off y_dev") + for j, angle in enumerate(angles): + flag = " excluded" if excluded[j] else "" + print( + f" {angle:5.0f} {offsets['x'][j]:9.2f} {residuals['x'][j]:8.2f}" + f" {offsets['y'][j]:9.2f} {residuals['y'][j]:8.2f}{flag}" + ) + for ax in ("x", "y"): + rms = np.sqrt(np.mean(residuals[ax][kept] ** 2)) + print(f" rms deviation of the fitted points, {ax}: {rms:.2f} um") + + print("New fit:") + self._print_xeye_fit(fit) + old_fit = self.client.get_global_var("tomo_fit_xray_eye") + if old_fit is not None: + print("Currently stored fit:") + self._print_xeye_fit(old_fit) + + self._show_xeye_refit_in_gui(angles[kept], offsets["x"][kept], offsets["y"][kept]) + + if confirm and not self.OMNYTools.yesno("Replace the stored X-ray eye fit?", "y"): + print("Stored fit left unchanged.") + return + self.client.set_global_var("tomo_fit_xray_eye", fit.tolist()) + if excluded.any(): + print( + "New X-ray eye fit stored (excluded: " + + ", ".join(f"{a:g}" for a in angles[excluded]) + + " deg)." + ) + else: + print("New X-ray eye fit stored (all points).") + + def _load_xeye_fit_data(self, file: str | None = None): + """Return the ``[angles, x_offsets, y_offsets]`` of the last alignment, + or of the archived alignment in *file*.""" + if file is not None: + with h5py.File(os.path.expanduser(file), "r") as f: + return f["alignment_fit"][()].tolist() + data = self.client.get_global_var(XEYE_FIT_DATA_VAR) + if data is None: + raise RuntimeError( + "No X-ray eye alignment points stored -- run lamni.xrayeye_alignment_start(), " + "or pass the archived alignment as file=... (see " + "~/data/raw/logs/xrayeye_alignmentvalues/)." + ) + return data + + def _show_xeye_refit_in_gui(self, angles, x_offsets, y_offsets): + """Show the points used in the refit on the XRayEye Fit tab. + + Display only: DAP parameter forwarding stays off, so the GUI's own fit + of these points does not touch the stored correction. + """ + try: + self.lamnigui_show_xeyealign_fittab() + self.xeyegui.submit_fit_array(np.array([angles, x_offsets, y_offsets])) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Could not show the refit points in the XRayEye GUI: {exc}") + @staticmethod def _print_xeye_fit(fit): """Pretty-print the 2×3 X-ray eye fit array.""" diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py index 56ef03f1..daa62265 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py @@ -11,6 +11,8 @@ import numpy as np from bec_lib import bec_logger +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_alignment_mixin import XEYE_FIT_DATA_VAR + logger = bec_logger.logger bec = builtins.__dict__.get("bec") @@ -602,6 +604,11 @@ class XrayEyeAlign: # ------------------------------------------------------------------ # All submits received - compute offsets and push to GUI fit tab # ------------------------------------------------------------------ + # Clear the previous run's fit parameters first: they'd otherwise + # satisfy _wait_for_dap_fit() immediately and be read back as this + # run's fit before DAP has fitted the new points. + dev.omny_xray_gui.fit_params_x.set({}) + dev.omny_xray_gui.fit_params_y.set({}) self.write_output() fovx = self._xray_fov_xy[0] * self.pixel_calibration * 1000 / 2 @@ -731,6 +738,8 @@ class XrayEyeAlign: timestamp = time.strftime("%Y%m%d_%H%M%S") file_h5 = f"~/data/raw/logs/xrayeye_alignmentvalues/xrayeye_alignmentvalues_{timestamp}.h5" self._save_alignment_data(file_h5, fit_data=data) + # Raw offsets for lamni.xrayeye_refit(), which can leave out bad clicks + self.client.set_global_var(XEYE_FIT_DATA_VAR, data.tolist()) # Push to XRayEye widget: feeds waveform_x (row 1) and waveform_y (row 2) self.gui.submit_fit_array(data) diff --git a/tests/tests_bec_ipython_client/test_lamni_xrayeye_refit.py b/tests/tests_bec_ipython_client/test_lamni_xrayeye_refit.py new file mode 100644 index 00000000..8cd08eab --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_xrayeye_refit.py @@ -0,0 +1,165 @@ +"""lamni.xrayeye_refit(): redo the X-ray eye sine fit without bad clicks.""" + +import h5py +import numpy as np +import pytest + +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_alignment_mixin import ( + XEYE_FIT_DATA_VAR, + fit_xray_eye_sine, +) + +ANGLES = np.arange(8) * 45.0 +TRUE_X = (12.0, 0.7, -3.0) +TRUE_Y = (5.0, -2.1, 8.0) + + +def _model(params, angles=ANGLES): + amp, shift, const = params + return amp * np.sin(np.radians(angles) + shift) + const + + +class _FakeClient: + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + def delete_global_var(self, key): + self._vars.pop(key, None) + + +class _FakeTools: + def __init__(self, answer=True): + self.answer = answer + self.asked = 0 + + def yesno(self, *_args, **_kwargs): + self.asked += 1 + return self.answer + + +class _FakeXeyeGui: + def __init__(self): + self.submitted = None + + def submit_fit_array(self, data): + self.submitted = data + + +def make_lamni(data=None, answer=True) -> LamNI: + obj = object.__new__(LamNI) + obj.client = _FakeClient() + obj.OMNYTools = _FakeTools(answer) + obj.xeyegui = _FakeXeyeGui() + obj.lamnigui_show_xeyealign_fittab = lambda: None + if data is not None: + obj.client.set_global_var(XEYE_FIT_DATA_VAR, data) + return obj + + +def _data_with_outlier(angle_idx=3, dx=40.0, dy=-25.0): + x = _model(TRUE_X) + y = _model(TRUE_Y) + x[angle_idx] += dx + y[angle_idx] += dy + return [ANGLES.tolist(), x.tolist(), y.tolist()] + + +def test_fit_recovers_parameters(): + assert np.allclose(fit_xray_eye_sine(ANGLES, _model(TRUE_X)), TRUE_X) + + +def test_fit_negative_amplitude_is_equivalent(): + # A DAP fit may report -A with shift+pi; the function must be the same. + amp, shift, const = fit_xray_eye_sine(ANGLES, _model((-12.0, 0.7, -3.0))) + assert amp > 0 + assert np.allclose(_model((amp, shift, const)), _model((-12.0, 0.7, -3.0))) + + +def test_exclude_outlier_recovers_true_fit(): + lamni = make_lamni(_data_with_outlier()) + lamni.xrayeye_refit([135]) + fit = lamni.client.get_global_var("tomo_fit_xray_eye") + assert np.allclose(fit[0], TRUE_X) + assert np.allclose(fit[1], TRUE_Y) + # Only the kept points go to the GUI. + assert lamni.xeyegui.submitted.shape == (3, 7) + assert 135 not in lamni.xeyegui.submitted[0] + + +def test_scalar_and_wrapped_angle_accepted(): + lamni = make_lamni(_data_with_outlier(angle_idx=0)) + lamni.xrayeye_refit(360) + assert np.allclose(lamni.client.get_global_var("tomo_fit_xray_eye")[0], TRUE_X) + + +def test_no_exclusion_fits_all_points(): + lamni = make_lamni(_data_with_outlier()) + lamni.xrayeye_refit() + fit = lamni.client.get_global_var("tomo_fit_xray_eye") + assert not np.allclose(fit[0], TRUE_X) + assert lamni.xeyegui.submitted.shape == (3, 8) + + +def test_declined_leaves_stored_fit(): + lamni = make_lamni(_data_with_outlier(), answer=False) + lamni.client.set_global_var("tomo_fit_xray_eye", [[1, 2, 3], [4, 5, 6]]) + lamni.xrayeye_refit([135]) + assert lamni.OMNYTools.asked == 1 + assert lamni.client.get_global_var("tomo_fit_xray_eye") == [[1, 2, 3], [4, 5, 6]] + + +def test_confirm_false_does_not_ask(): + lamni = make_lamni(_data_with_outlier()) + lamni.xrayeye_refit([135], confirm=False) + assert lamni.OMNYTools.asked == 0 + assert lamni.client.get_global_var("tomo_fit_xray_eye") is not None + + +def test_unknown_angle_rejected(): + lamni = make_lamni(_data_with_outlier()) + with pytest.raises(ValueError, match="No alignment point at 100"): + lamni.xrayeye_refit([100]) + + +def test_too_few_points_rejected(): + lamni = make_lamni(_data_with_outlier()) + with pytest.raises(ValueError, match="at least 4"): + lamni.xrayeye_refit([0, 45, 90, 135, 180]) + + +def test_no_data_gives_clear_error(): + with pytest.raises(RuntimeError, match="xrayeye_alignment_start"): + make_lamni().xrayeye_refit() + + +def test_reset_clears_points(): + lamni = make_lamni(_data_with_outlier()) + lamni.reset_xray_eye_correction() + assert lamni.client.get_global_var(XEYE_FIT_DATA_VAR) is None + + +def test_refit_from_archive_file(tmp_path): + path = tmp_path / "xrayeye_alignmentvalues_20261013_120000.h5" + with h5py.File(path, "w") as f: + f.create_dataset("alignment_fit", data=np.array(_data_with_outlier())) + lamni = make_lamni() + lamni.xrayeye_refit([135], file=str(path)) + assert np.allclose(lamni.client.get_global_var("tomo_fit_xray_eye")[1], TRUE_Y) + + +def test_gui_failure_does_not_block_refit(): + lamni = make_lamni(_data_with_outlier()) + + def _boom(): + raise RuntimeError("GUI closed") + + lamni.lamnigui_show_xeyealign_fittab = _boom + lamni.xrayeye_refit([135]) + assert np.allclose(lamni.client.get_global_var("tomo_fit_xray_eye")[0], TRUE_X) -- 2.54.0 From ca96f41a45707bae94525439b2bfa8c9a1eec873 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 13 Oct 2026 10:22:15 +0200 Subject: [PATCH 3/3] fix(webpage): name the setup's own client in the web password hint The "no web authentication yet" hint always said flomni.set_web_password(), which doesn't exist in a LamNI session. Each generator now declares CLIENT_NAME and the hint uses it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ALDnH16dC6iyHjhe5Ey4w9 --- .../plugins/LamNI/LamNI_webpage_generator.py | 2 ++ .../OMNY_shared/webpage_generator_base.py | 9 +++-- .../flomni/flomni_webpage_generator.py | 2 ++ .../test_webpage_password_hint.py | 35 +++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/tests_bec_ipython_client/test_webpage_password_hint.py diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py index f64dc4df..5ad2ac99 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py @@ -46,6 +46,8 @@ class LamniWebpageGenerator(WebpageGeneratorBase): sample name, and measurement settings. """ + CLIENT_NAME = "lamni" + # LamNI's tomo-queue jobs (added via TomoQueueMixin, see # OMNY_shared/tomo_queue_mixin.py) are persisted under the same # "tomo_queue" global var key flomni uses, so the shared "Tomo queue" diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py index 38028410..8b0dbf25 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py @@ -443,6 +443,10 @@ class WebpageGeneratorBase: whole shared output dir, EPS files included. """ + # Name of the setup's object in the BEC client namespace (e.g. "lamni"), + # used in console hints such as the set_web_password() one. + CLIENT_NAME: str | None = None + HAS_TOMO_QUEUE = True HAS_EPS_STATUS = True TOMO_TYPES = { @@ -816,7 +820,8 @@ class WebpageGeneratorBase: _BOLD_RED = "\033[1;31m" _RESET = "\033[0m" print(f"{_BOLD_RED}***\nWebpageGenerator: '{current_account}' has no web authentication yet.{_RESET}") - print(f"{_BOLD_RED} To set a password run: flomni.set_web_password(\"your_password\")\n***{_RESET}") + prefix = f"{self.CLIENT_NAME}." if self.CLIENT_NAME else "" + print(f"{_BOLD_RED} To set a password run: {prefix}set_web_password(\"your_password\")\n***{_RESET}") def _clear_session_htpasswd(self) -> None: """Write an empty session.htpasswd locally and upload it.""" @@ -835,7 +840,7 @@ class WebpageGeneratorBase: The username is taken from bec.active_account (e.g. 'p23092'). Example:: - flomni.webpage_gen.set_web_password("my_secret_password") + flomni.set_web_password("my_secret_password") """ if self._set_password_url is None: print("set_web_password: no upload URL configured — cannot reach server") diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 2e9dae97..7538e26c 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -54,6 +54,8 @@ class FlomniWebpageGenerator(WebpageGeneratorBase): Logo: flOMNI.png from the same directory as this file. """ + CLIENT_NAME = "flomni" + # Global-var names of the tomo parameters to read live for the # 'Current measurement' section. Kept in the same order as, and matching, # the tomo queue's TQ_PARAM_DISPLAY keys so the two render identically. diff --git a/tests/tests_bec_ipython_client/test_webpage_password_hint.py b/tests/tests_bec_ipython_client/test_webpage_password_hint.py new file mode 100644 index 00000000..7d46da88 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_webpage_password_hint.py @@ -0,0 +1,35 @@ +"""The 'no web authentication' hint names the setup's own client object.""" + +import types + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_webpage_generator import ( + FlomniWebpageGenerator, +) +from csaxs_bec.bec_ipython_client.plugins.LamNI.LamNI_webpage_generator import ( + LamniWebpageGenerator, +) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base import ( + WebpageGeneratorBase, +) + + +@pytest.mark.parametrize( + "cls, expected", + [ + (LamniWebpageGenerator, 'lamni.set_web_password("your_password")'), + (FlomniWebpageGenerator, 'flomni.set_web_password("your_password")'), + (WebpageGeneratorBase, 'run: set_web_password("your_password")'), + ], +) +def test_password_hint_uses_client_name(cls, expected, capsys): + gen = object.__new__(cls) + gen._session_query_url = "http://example.invalid/session.php" + gen._bec = types.SimpleNamespace(active_account="p12345") + gen._get_remote_session_user = lambda: None + gen._check_session_user() + out = capsys.readouterr().out + assert expected in out + if cls is not FlomniWebpageGenerator: + assert "flomni." not in out -- 2.54.0