From bd2e87ebdd06545b9064bacbc4cdf0575eb84202 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 23 Jul 2026 10:00:49 +0200 Subject: [PATCH] feat(LamNI): add automated rotation-center calibration via X-ray eye lsamx_center/lsamy_center have to be re-measured by hand for nearly every new sample (the rotation stage is tilted on top of lsamx/lsamy, so the effective axis position shifts with sample thickness/mounting). Add XrayEyeAlign.find_rotation_center()/lamni.rotation_center_calibration_start() to automate this using the existing X-ray eye GUI and lamni_move_to_scan_center interferometer-feedback move: - "isolated" sample: click particle centre at 0 and 180 deg, use the midpoint (angle-tilt-independent) as the rotation axis position. - "extended" sample: live 0->180->0 sweep for visual identification, single click, then a verification sweep and an accept/iterate prompt. Both write the result to lsamx/lsamy's "center" userParameter after confirmation. Heavily verbose logging throughout since this is fundamentally an interactive, hardware-in-the-loop procedure that can't be fully exercised by automated tests. Co-Authored-By: Claude Sonnet 5 --- .../bec_ipython_client/plugins/LamNI/lamni.py | 37 +++ .../plugins/LamNI/x_ray_eye_align.py | 278 +++++++++++++++++- docs/user/ptychography/lamni.md | 21 +- .../test_x_ray_eye_align.py | 165 ++++++++++- 4 files changed, 498 insertions(+), 3 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index bc7105a..7bb4ee1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -460,6 +460,43 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools aligner = XrayEyeAlignGUI(self.client, self) aligner.update_frame(keep_shutter_open=keep_shutter_open) + def rotation_center_calibration_start( + self, sample_type: str = "isolated", keep_shutter_open: bool = False + ): + """Determine new lsamx/lsamy rotation-center values via the X-ray eye GUI. + + Creates a fresh :class:`XrayEyeAlignGUI` instance and calls its + ``find_rotation_center()`` method. The GUI window is opened + automatically. Interrupt with Ctrl-C to abort. + + Args: + sample_type: "isolated" for a sparse/isolated particle (compares + its position at 0 and 180 deg), or "extended" for a + non-isolated sample (visual identification of the rotation + centre, with a verification sweep and iterate/accept prompt). + keep_shutter_open: If True the shutter is left open between steps + so the sample remains visible in live view. + + Returns: + tuple: (new_lsamx_center, new_lsamy_center) in mm. + """ + aligner = XrayEyeAlignGUI(self.client, self) + try: + return aligner.find_rotation_center( + sample_type=sample_type, keep_shutter_open=keep_shutter_open + ) + except KeyboardInterrupt as exc: + print("Rotation-center calibration interrupted by user.") + try: + aligner.gui.hide_crosshair() + except Exception as gui_exc: # pylint: disable=broad-except + logger.warning(f"Failed to hide XRayEye alignment crosshair: {gui_exc}") + try: + aligner.gui.set_dap_params_forwarding(False) + except Exception as gui_exc: # pylint: disable=broad-except + logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {gui_exc}") + raise exc + # ------------------------------------------------------------------ # RT feedback / interferometer helpers # ------------------------------------------------------------------ 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 ebaffc4..9ddb38e 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 @@ -572,4 +572,280 @@ class XrayEyeAlign: # Push to XRayEye widget: feeds waveform_x (row 1) and waveform_y (row 2) self.gui.submit_fit_array(data) - print(f"Fit data submitted with shape {data.shape}:\n{data}") \ No newline at end of file + print(f"Fit data submitted with shape {data.shape}:\n{data}") + + # ------------------------------------------------------------------ + # Rotation-center calibration + # ------------------------------------------------------------------ + # + # Determines lsamx_center/lsamy_center (the userParameter "center" of the + # lsamx/lsamy devices) -- the stage position at which the physical + # rotation axis coincides with the FZP/beam reference. This has to be + # re-measured whenever the sample mount/thickness changes, because the + # rotation stage is tilted on top of lsamx/lsamy (see docs/user/ + # ptychography/lamni.md, "Coarse axis alignment", for the manual + # procedure this automates). + # + # Math note: let the rotation axis' projected position (a function of + # the *current*, possibly-stale lsamx/lsamy) be P_axis -- this does not + # move as lsamrot rotates, only lsamx/lsamy moves it. A particle rigidly + # mounted at eccentricity vector e (expressed in the angle-0 frame) + # images at P_axis + T(e, angle), where T is exactly + # _lamni_compute_scan_center's transform (T(v,0) = v, T(v,180) = -v, + # since the transform is linear/homogeneous -- no translation term). So + # S0 = P_axis + e, S180 = P_axis - e --> (S0+S180)/2 = P_axis, + # independent of e and of the axis tilt. The needed correction + # target = FZP - P_axis is a *plain translation*, not an eccentric, + # angle-dependent quantity, so it must always be passed through + # scans.lamni_move_to_scan_center with angle=0.0 regardless of the + # physical lsamrot angle at click time -- angle=0 is the only angle for + # which the transform doesn't distort a translation-type vector. + + ROTATION_CENTER_SAMPLE_TYPES = ("isolated", "extended") + + def _collect_click(self, k: int, message: str, label: str = "") -> tuple[float, float]: + """Wait for the operator to submit a click at GUI step *k*. + + Returns the click position in mm (converted via pixel_calibration). + """ + print(f"[rotation-center] step {k}: waiting for submit -- {message}") + dev.omny_xray_gui.step.set(k) + self.send_message(message) + self.gui.enable_submit_button(True) + while True: + if dev.omny_xray_gui.submit.get() == 1: + raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() + raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() + val_x = raw_x * self.pixel_calibration + val_y = raw_y * self.pixel_calibration + dev.omny_xray_gui.submit.set(0) + self.gui.enable_submit_button(False) + tag = f" ({label})" if label else "" + print( + f"[rotation-center] received click{tag}: step={k} " + f"px=({raw_x:.1f}, {raw_y:.1f}) -> mm=({val_x:.4f}, {val_y:.4f})" + ) + return (val_x, val_y) + time.sleep(0.2) + + def _live_sweep(self, angles: list, step_deg: float = 30.0, settle_time: float = 0.3): + """Rotate lsamrot through a sequence of waypoint angles with the + shutter/live-view kept open, for continuous visual tracking. + + No frames are captured/clicked during the sweep -- it exists purely + so the operator can watch the sample rotate live (e.g. to visually + identify the stationary rotation-center feature on an extended + sample, or to reposition their ROI selector). RT feedback is + disabled for the duration; it is re-enabled naturally by whichever + lamni_move_to_scan_center call follows the sweep. + """ + print(f"[rotation-center] starting live sweep through {angles} deg (shutter/live-view open)") + if not dev.cam_xeye.live_mode_enabled.get(): + dev.cam_xeye.live_mode_enabled.put(True) + self.gui.on_live_view_enabled(True) + dev.fsh.fshopen() + self._disable_rt_feedback() + waypoints = [self.get_tomo_angle()] + for target in angles: + start = waypoints[-1] + n_steps = max(1, int(abs(target - start) / step_deg)) + print(f"[rotation-center] sweeping {start:.1f} -> {target:.1f} deg in {n_steps} step(s)") + for i in range(1, n_steps + 1): + next_angle = start + (target - start) * i / n_steps + self.tomo_rotate(next_angle) + print(f"[rotation-center] at {self.get_tomo_angle():.1f} deg") + time.sleep(settle_time) + waypoints.append(target) + print("[rotation-center] live sweep done") + + def _apply_rotation_center_shift(self, fzp_x: float, fzp_y: float, center_x: float, center_y: float): + """Move lsamx/lsamy so the rotation axis (currently imaged at + (center_x, center_y)) lands on the FZP/beam reference (fzp_x, fzp_y). + + angle=0.0 is passed deliberately -- see the module-level math note + above: the correction is a plain translation, not an eccentric + angle-dependent feature. + """ + target_x = fzp_x - center_x + target_y = center_y - fzp_y + print( + f"[rotation-center] shift needed: target_x = fzp_x({fzp_x:.4f}) - center_x({center_x:.4f}) " + f"= {target_x:.4f} mm; target_y = center_y({center_y:.4f}) - fzp_y({fzp_y:.4f}) " + f"= {target_y:.4f} mm" + ) + lx_before = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"] + ly_before = self.device_manager.devices.lsamy.readback.read()["lsamy"]["value"] + print(f"[rotation-center] lsamx/lsamy before move: ({lx_before:.4f}, {ly_before:.4f})") + + self.scans.lamni_move_to_scan_center(shift_x=target_x, shift_y=target_y, angle=0.0).wait() + time.sleep(1) + self.scans.lamni_move_to_scan_center(shift_x=target_x, shift_y=target_y, angle=0.0).wait() + + lx_after = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"] + ly_after = self.device_manager.devices.lsamy.readback.read()["lsamy"]["value"] + rtx_after = self.device_manager.devices.rtx.readback.read()["rtx"]["value"] + rty_after = self.device_manager.devices.rty.readback.read()["rty"]["value"] + print( + f"[rotation-center] lsamx/lsamy after move: ({lx_after:.4f}, {ly_after:.4f}); " + f"interferometer rtx/rty now read ({rtx_after:.2f}, {rty_after:.2f}) um" + ) + + def find_rotation_center( + self, sample_type: str = "isolated", keep_shutter_open: bool = False, apply: bool = True + ): + """Determine (and optionally apply) new lsamx/lsamy rotation-center values. + + Two operator-selectable strategies: + + "isolated" -- for a sparse/isolated particle. Its imaged position is + submitted at 0 deg and again at 180 deg; the midpoint of the two + is the rotation axis' projected position, independent of the + axis tilt (see the module-level math note above). No visual + judgement call and no iteration. + + "extended" -- for a non-isolated/textured sample where the rotation + centre can be visually identified directly. The sample is swept + 0 -> 180 -> 0 deg with the live view open so the operator can + watch for the stationary point, then a single click is submitted + at 0 deg. After applying the correction, a short verification + sweep (0 -> 45 -> 0 deg) runs and the operator is asked whether + to accept the alignment or run another iteration. + + Args: + sample_type: "isolated" or "extended", see above. + keep_shutter_open: passed through to update_frame(), see align(). + apply: if True (default), ask for confirmation and then persist + the computed values via dev.lsamx.update_user_parameter() / + dev.lsamy.update_user_parameter(). + + Returns: + tuple: (new_lsamx_center, new_lsamy_center) in mm. + """ + if sample_type not in self.ROTATION_CENTER_SAMPLE_TYPES: + raise ValueError( + f"sample_type must be one of {self.ROTATION_CENTER_SAMPLE_TYPES}, got {sample_type!r}" + ) + + self.lamni.lamnigui_show_xeyealign() + self.gui.set_dap_params_forwarding(False) + self._reset_init_values() + self.alignment_images = [] + self.movement_buttons_enabled(False, False) + self.gui.enable_submit_button(False) + dev.omny_xray_gui.mvx.set(0) + dev.omny_xray_gui.mvy.set(0) + dev.omny_xray_gui.submit.set(0) + + print(f"[rotation-center] starting find_rotation_center(sample_type={sample_type!r})") + + # --- FZP reference (step 0) ----------------------------------- + self.send_message("Getting things ready. Please wait...") + self.lamni.lfzp_in(force_feedback_reset=True) + self.update_frame(keep_shutter_open) + fzp_x, fzp_y = self._collect_click(0, "Submit centre of FZP.", label="FZP reference") + + self.send_message("Please wait - moving sample in...") + self.lamni.loptics_out() + self._disable_rt_feedback() + self.tomo_rotate(0) + self.update_frame(keep_shutter_open) + self.gui.set_crosshair_position(fzp_x / self.pixel_calibration, fzp_y / self.pixel_calibration) + self.gui.show_crosshair() + + if sample_type == "isolated": + self.movement_buttons_enabled(True, True) + p0 = self._collect_click(1, "Submit particle centre (0 deg).", label="particle @0deg") + self._disable_rt_feedback() + self.tomo_rotate(180) + self.update_frame(keep_shutter_open) + p180 = self._collect_click( + 2, "Submit the SAME particle's centre (180 deg).", label="particle @180deg" + ) + center_x, center_y = (p0[0] + p180[0]) / 2, (p0[1] + p180[1]) / 2 + print( + f"[rotation-center] midpoint of ({p0[0]:.4f},{p0[1]:.4f}) and " + f"({p180[0]:.4f},{p180[1]:.4f}) = ({center_x:.4f},{center_y:.4f}) mm " + "-- this is the rotation axis' current projected position" + ) + self._disable_rt_feedback() + self.tomo_rotate(0) + self._apply_rotation_center_shift(fzp_x, fzp_y, center_x, center_y) + else: + iteration = 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.movement_buttons_enabled(True, True) + center_x, center_y = self._collect_click( + 1, + "Submit the rotation centre (0 deg).", + label=f"rotation centre, iter {iteration}", + ) + self._apply_rotation_center_shift(fzp_x, fzp_y, center_x, center_y) + + self.send_message("Verifying alignment...") + self._live_sweep([45, 0]) + self.update_frame(keep_shutter_open) + answer = ( + input( + "Are you happy with this alignment, or shall we do another iteration? " + "[Y/n]: " + ) + .strip() + .lower() + ) + if answer in ("", "y", "yes"): + 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"] + new_lsamy = self.device_manager.devices.lsamy.readback.read()["lsamy"]["value"] + old_lsamx = dev.lsamx.user_parameter.get("center") + old_lsamy = dev.lsamy.user_parameter.get("center") + print( + f"[rotation-center] RESULT: new lsamx_center={new_lsamx:.4f} mm " + f"(was {old_lsamx}), new lsamy_center={new_lsamy:.4f} mm (was {old_lsamy})" + ) + + if apply: + answer = ( + input( + f"Update lsamx/lsamy center user parameters to " + f"({new_lsamx:.4f}, {new_lsamy:.4f})? [Y/n]: " + ) + .strip() + .lower() + ) + if answer in ("", "y", "yes"): + dev.lsamx.update_user_parameter({"center": float(new_lsamx)}) + dev.lsamy.update_user_parameter({"center": float(new_lsamy)}) + print( + f"[rotation-center] lsamx.user_parameter['center'] = " + f"{dev.lsamx.user_parameter.get('center')}, " + f"lsamy.user_parameter['center'] = {dev.lsamy.user_parameter.get('center')}" + ) + else: + print( + "[rotation-center] NOT updated -- config unchanged. Apply manually with " + "dev.lsamx.update_user_parameter({'center': ...}) / same for lsamy." + ) + + if keep_shutter_open: + answer = input("Close the shutter now? [Y/n]: ").strip().lower() + if answer in ("", "y", "yes"): + dev.fsh.fshclose() + self.gui.on_live_view_enabled(False) + print("Shutter closed.") + else: + print("Shutter left open.") + + self.tomo_rotate(0) + return (new_lsamx, new_lsamy) \ No newline at end of file diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index 590f538..511475b 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -18,7 +18,26 @@ Mount the new sample. The X-ray eye is already in, but the X-ray optics needs to #### Coarse axis alignment -The effective position of the axis of rotation shifts with sample thickness or mounting position of the sample along the axis of rotation. The position of the axis of rotation is controlled by user parameters **center** of the **lsamx** and **lsamy** stages. To observe the axis of rotation obtain the position of the Fresnel zone plate on the X-ray eye, possibly in the *ueye gui* by: +The effective position of the axis of rotation shifts with sample thickness or mounting position of the sample along the axis of rotation. The position of the axis of rotation is controlled by user parameters **center** of the **lsamx** and **lsamy** stages. + +The recommended way to (re-)measure `center` for a new sample is the GUI-driven procedure below. It replaces the fully manual jog-by-hand steps that used to live here; those are kept further down as a fallback for when the automated procedure or the GUI is unavailable. + +**Automated rotation-center calibration** + +`lamni.rotation_center_calibration_start(sample_type="isolated")` + +This opens the X-ray eye widget and walks through the calibration interactively. Choose `sample_type` based on what's mounted: + +- `"isolated"` — for a sparse/isolated particle. You submit its centre position once at 0° and once at 180°; the midpoint of the two is the rotation axis' projected position (this works regardless of the axis tilt). No further confirmation step. +- `"extended"` — for a non-isolated/textured sample where the rotation centre can be identified visually. The sample sweeps 0° → 180° → 0° with the live view left open so you can watch for the point that doesn't move, then you submit a single click at 0°. The correction is applied, a short verification sweep (0° → 45° → 0°) runs, and you're asked *"Are you happy with this alignment, or shall we do another iteration?"* — answer `n` to refine further. + +At the end you're shown the computed `lsamx_center`/`lsamy_center` and asked to confirm before they're written via `dev.lsamx.update_user_parameter(...)` / `dev.lsamy.update_user_parameter(...)`. + +Pass `keep_shutter_open=True` if it's hard to relocate the sample between steps, matching `xrayeye_alignment_start()` below. Interrupt with Ctrl-C to abort; the crosshair and DAP forwarding are cleaned up automatically. + +**Manual fallback** + +To observe the axis of rotation obtain the position of the Fresnel zone plate on the X-ray eye, possibly in the *ueye gui* by: 1. `lamni.lfzp_in()`, move the FZP in 2. `dev.rtx.controller.feedback_disable()`, disable feedback to allow lsam movements diff --git a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py index 606f405..0e4b58a 100644 --- a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py +++ b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py @@ -1,5 +1,6 @@ from unittest import mock +import pytest from bec_lib.device import DeviceBase from csaxs_bec.bec_ipython_client.plugins.LamNI import LamNI @@ -111,4 +112,166 @@ def test_tomo_rotate(bec_client_mock): ) with mock.patch(f"{XRAY_EYE_ALIGN}.umv") as umv: align.tomo_rotate(5) - umv.assert_called_once_with(client.device_manager.devices.lsamrot, 5) \ No newline at end of file + umv.assert_called_once_with(client.device_manager.devices.lsamrot, 5) + + +# ---------------------------------------------------------------------- +# Rotation-center calibration (find_rotation_center) +# ---------------------------------------------------------------------- + + +class PositionerMock(DeviceBase): + """A DeviceBase subclass with a working readback.read(), for devices the + DeviceManager container requires to be actual DeviceBase instances + (client.device_manager.devices. = ... rejects plain mocks).""" + + controller = mock.MagicMock() + enabled = True + + def __init__(self, name, value, **kwargs): + super().__init__( + name=name, + config={"enabled": True, "deviceClass": "test_class", "readoutPriority": "baseline"}, + **kwargs, + ) + self._value = value + self.readback = mock.MagicMock() + self.readback.read.return_value = {name: {"value": value}} + self.readback.get.return_value = value + + +def _fake_positioner(name, value): + return PositionerMock(name, value) + + +def _make_calibration_align(client): + """Build an XrayEyeAlign wired up for find_rotation_center() tests: + dev.omny_xray_gui/cam_xeye/fsh are mocked, lsamx/lsamy/rtx/rty readbacks + are fake positioners, and the lamni hardware-setup methods + (lfzp_in/loptics_out/lamnigui_show_xeyealign) are no-ops so the test only + exercises the click/shift/apply control flow. + """ + align = _make_align(client) + align.lamni.xeyegui = mock.MagicMock() + align.lamni.lfzp_in = mock.MagicMock() + align.lamni.loptics_out = mock.MagicMock() + align.lamni.lamnigui_show_xeyealign = mock.MagicMock() + # Replace the real Scans proxy (which would try to talk to a live scan + # server) with a plain mock -- these tests only care that + # lamni_move_to_scan_center is *called* with the right kwargs. + align.scans = mock.MagicMock() + + client.device_manager.devices.lsamx = _fake_positioner("lsamx", 8.77) + client.device_manager.devices.lsamy = _fake_positioner("lsamy", 10.05) + client.device_manager.devices.rtx = _fake_positioner("rtx", 0.0) + client.device_manager.devices.rty = _fake_positioner("rty", 0.0) + # get_tomo_angle()/tomo_rotate() only need a working readback -- umv() + # itself is patched away in the tests below (it resolves `scans` from the + # module-level builtins snapshot, not from self.scans/align.scans). + client.device_manager.devices.lsamrot = _fake_positioner("lsamrot", 0.0) + + dev_mock = mock.MagicMock() + dev_mock.cam_xeye.user_parameter.get.return_value = 0.001 # mm/px, clean test math + dev_mock.cam_xeye.live_mode_enabled.get.return_value = True + dev_mock.omny_xray_gui.submit.get.return_value = 1 # every _collect_click returns immediately + dev_mock.lsamx.user_parameter.get.return_value = 8.5 # "old" center, for the printed diff + dev_mock.lsamy.user_parameter.get.return_value = 9.9 + + # FZP reference click (step 0): (0, 0) px -> (0.0, 0.0) mm + dev_mock.omny_xray_gui.xval_x_0.get.return_value = 0.0 + dev_mock.omny_xray_gui.yval_y_0.get.return_value = 0.0 + + return align, dev_mock + + +def test_apply_rotation_center_shift_math(bec_client_mock): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + align._apply_rotation_center_shift(fzp_x=0.0, fzp_y=0.0, center_x=0.4, center_y=0.5) + + assert align.scans.lamni_move_to_scan_center.call_count == 2 + for call in align.scans.lamni_move_to_scan_center.call_args_list: + assert call.kwargs["shift_x"] == pytest.approx(-0.4) + assert call.kwargs["shift_y"] == pytest.approx(0.5) + assert call.kwargs["angle"] == 0.0 + + +def test_find_rotation_center_isolated_computes_midpoint(bec_client_mock): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + # particle @0deg (step 1): (300, 300) px -> (0.3, 0.3) mm + dev_mock.omny_xray_gui.xval_x_1.get.return_value = 300.0 + dev_mock.omny_xray_gui.yval_y_1.get.return_value = 300.0 + # particle @180deg (step 2): (500, 700) px -> (0.5, 0.7) mm + dev_mock.omny_xray_gui.xval_x_2.get.return_value = 500.0 + dev_mock.omny_xray_gui.yval_y_2.get.return_value = 700.0 + + 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}.time.sleep"): + with mock.patch(f"{XRAY_EYE_ALIGN}.input", return_value="y"): + result = align.find_rotation_center(sample_type="isolated") + + # midpoint of (0.3,0.3) and (0.5,0.7) is (0.4, 0.5) -> + # target_x = fzp_x(0) - 0.4 = -0.4, target_y = 0.5 - fzp_y(0) = 0.5 + assert align.scans.lamni_move_to_scan_center.call_count == 2 + for call in align.scans.lamni_move_to_scan_center.call_args_list: + assert call.kwargs["shift_x"] == pytest.approx(-0.4) + assert call.kwargs["shift_y"] == pytest.approx(0.5) + assert call.kwargs["angle"] == 0.0 + + assert result == (8.77, 10.05) + dev_mock.lsamx.update_user_parameter.assert_called_once_with({"center": 8.77}) + dev_mock.lsamy.update_user_parameter.assert_called_once_with({"center": 10.05}) + + +def test_find_rotation_center_isolated_declines_apply(bec_client_mock): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + dev_mock.omny_xray_gui.xval_x_1.get.return_value = 0.0 + dev_mock.omny_xray_gui.yval_y_1.get.return_value = 0.0 + dev_mock.omny_xray_gui.xval_x_2.get.return_value = 0.0 + dev_mock.omny_xray_gui.yval_y_2.get.return_value = 0.0 + + 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}.time.sleep"): + with mock.patch(f"{XRAY_EYE_ALIGN}.input", return_value="n"): + align.find_rotation_center(sample_type="isolated") + + dev_mock.lsamx.update_user_parameter.assert_not_called() + dev_mock.lsamy.update_user_parameter.assert_not_called() + + +def test_find_rotation_center_invalid_sample_type(bec_client_mock): + client = bec_client_mock + align, _ = _make_calibration_align(client) + + with pytest.raises(ValueError): + align.find_rotation_center(sample_type="bogus") + + +def test_find_rotation_center_extended_iterates_until_happy(bec_client_mock): + 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 + + 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}.time.sleep"): + # first iteration: "not happy" -> second iteration: "happy", + # then accept the final apply prompt + with mock.patch(f"{XRAY_EYE_ALIGN}.input", side_effect=["n", "y", "y"]): + align.find_rotation_center(sample_type="extended") + + # one lamni_move_to_scan_center pair per iteration -> 2 iterations = 4 calls + assert align.scans.lamni_move_to_scan_center.call_count == 4 + dev_mock.lsamx.update_user_parameter.assert_called_once() + dev_mock.lsamy.update_user_parameter.assert_called_once() \ No newline at end of file