From 3378a493c7a7d4d05c21269d17f7cbb7f9af2d31 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 23 Jul 2026 15:44:13 +0200 Subject: [PATCH] fix(LamNI): remove redundant rotate, simplify sweep, add center precondition Three refinements found while testing find_rotation_center() against the simulated LamNI: - Drop the trailing tomo_rotate(0) at the end of find_rotation_center(): both the isolated path (explicit rotate-back before applying the correction) and the extended path (verification sweep ends at 0) already leave lsamrot at 0 by then, so it was a pure duplicate move. - Simplify _live_sweep() to rotate directly to each waypoint instead of stepping through artificial step_deg-sized sub-moves with settle pauses in between -- a single tomo_rotate() already produces continuous physical rotation, so the stepping added nothing but jerkier motion and extra queue overhead. - Add _ensure_at_configured_center(), called at the start of find_rotation_center() before lfzp_in(). lamni_move_to_scan_center's interferometer-drift safety check (LamNIFermatScan.py) assumes lsamx/ lsamy start near their configured center and silently skips the entire corrective move if that drift exceeds 150um -- exactly the situation find_rotation_center() is meant to handle, since its purpose is recalibrating a potentially-stale center. Confirmed this was a real gap: a run where the stage hadn't started at its configured center produced a correct absolute target (verified via the interferometer reading) but a confusingly small apparent motor delta. Co-Authored-By: Claude Sonnet 5 --- .../plugins/LamNI/x_ray_eye_align.py | 60 +++++++++++++++---- .../test_x_ray_eye_align.py | 40 +++++++++++++ 2 files changed, 88 insertions(+), 12 deletions(-) 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 1624795..9fd9559 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 @@ -632,7 +632,7 @@ class XrayEyeAlign: 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): + def _live_sweep(self, angles: list): """Rotate lsamrot through a sequence of waypoint angles with the shutter/live-view kept open, for continuous visual tracking. @@ -649,19 +649,52 @@ class XrayEyeAlign: 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(f"[rotation-center] rotating to {target:.1f} deg") + self.tomo_rotate(target) + print(f"[rotation-center] at {self.get_tomo_angle():.1f} deg") print("[rotation-center] live sweep done") + def _ensure_at_configured_center(self, tol: float = 0.003): + """Move lsamx/lsamy to their currently-configured center if they + aren't already there. + + lamni_move_to_scan_center's interferometer-drift check assumes the + stage starts near the configured center; if lsamx/lsamy were left + somewhere else (e.g. from an unrelated previous experiment or a + sample change -- exactly when this calibration is needed most, + since the old center may now be stale), that drift check can exceed + its 150 um safety cutoff and silently skip the corrective moves + later in this procedure. A plain absolute move here (not via + lamni_move_to_scan_center, which is itself subject to that same + cutoff) establishes a valid baseline first. + """ + lsamx_center = dev.lsamx.user_parameter.get("center") + lsamy_center = dev.lsamy.user_parameter.get("center") + if lsamx_center is None or lsamy_center is None: + print( + "[rotation-center] lsamx/lsamy center not yet configured -- " + "skipping return-to-center step." + ) + return + current_x = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"] + current_y = self.device_manager.devices.lsamy.readback.read()["lsamy"]["value"] + if np.isclose(current_x, lsamx_center, atol=tol) and np.isclose( + current_y, lsamy_center, atol=tol + ): + print( + f"[rotation-center] lsamx/lsamy already at configured center " + f"({lsamx_center:.4f}, {lsamy_center:.4f}) mm." + ) + return + print( + f"[rotation-center] moving lsamx/lsamy from ({current_x:.4f}, {current_y:.4f}) " + f"to configured center ({lsamx_center:.4f}, {lsamy_center:.4f}) mm before " + "starting calibration..." + ) + self._disable_rt_feedback() + umv(dev.lsamx, lsamx_center, dev.lsamy, lsamy_center) + 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). @@ -748,6 +781,10 @@ class XrayEyeAlign: # otherwise stays wherever it was left by a previous scan setup until # loptics_out() below, which only runs *after* this click. self.lamni.losa_out() + # lsamx/lsamy must start near their configured center -- otherwise + # lamni_move_to_scan_center's interferometer-drift safety check can + # exceed its threshold and silently skip the corrective moves below. + self._ensure_at_configured_center() 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") @@ -855,5 +892,4 @@ class XrayEyeAlign: else: print("Shutter left open.") - self.tomo_rotate(0) return (new_lsamx, new_lsamy) \ No newline at end of file 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 810ac84..ad818bb 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 @@ -200,6 +200,46 @@ def test_apply_rotation_center_shift_math(bec_client_mock): assert call.kwargs["angle"] == 0.0 +def test_ensure_at_configured_center_skips_when_already_there(bec_client_mock, capsys): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + # readback (8.77, 10.05, set in _make_calibration_align) matches configured center + dev_mock.lsamx.user_parameter.get.return_value = 8.77 + dev_mock.lsamy.user_parameter.get.return_value = 10.05 + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.umv") as umv_mock: + align._ensure_at_configured_center() + + umv_mock.assert_not_called() + assert "already at configured center" in capsys.readouterr().out + + +def test_ensure_at_configured_center_moves_when_not_there(bec_client_mock): + client = bec_client_mock + # readback (8.77, 10.05) vs configured center (8.5, 9.9) -- from _make_calibration_align + align, dev_mock = _make_calibration_align(client) + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.umv") as umv_mock: + align._ensure_at_configured_center() + + umv_mock.assert_called_once_with(dev_mock.lsamx, 8.5, dev_mock.lsamy, 9.9) + + +def test_ensure_at_configured_center_skips_when_center_not_configured(bec_client_mock, capsys): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + dev_mock.lsamx.user_parameter.get.return_value = None + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.umv") as umv_mock: + align._ensure_at_configured_center() + + umv_mock.assert_not_called() + assert "not yet configured" in capsys.readouterr().out + + def test_find_rotation_center_isolated_computes_midpoint(bec_client_mock): client = bec_client_mock align, dev_mock = _make_calibration_align(client)