From b7b5b9282a9220a9002d0ae37d232b1904e27f5c Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 23 Jul 2026 16:21:48 +0200 Subject: [PATCH] fix(LamNI): accumulate rotation-center shift across calibration iterations lamni_move_to_scan_center's shift_x/shift_y are absolute offsets from the currently-configured lsamx_center/lsamy_center (unchanged until the operator confirms at the end of the procedure), not incremental deltas from wherever the stage currently sits. The extended-sample iteration loop was passing only the latest click's delta each time, so each new iteration partially undid the previous one's correction instead of adding to it. Fixed by accumulating the total shift across iterations and always applying the running total. Co-Authored-By: Claude Sonnet 5 --- .../plugins/LamNI/x_ray_eye_align.py | 53 ++++++++++++++---- .../test_x_ray_eye_align.py | 54 ++++++++++++++++++- 2 files changed, 94 insertions(+), 13 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 9fd9559..d283ab5 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 @@ -695,13 +695,12 @@ class XrayEyeAlign: 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). - - 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. + def _compute_shift_to_fzp( + self, fzp_x: float, fzp_y: float, center_x: float, center_y: float + ) -> tuple: + """Compute the lamni-frame shift needed to move the rotation axis + (currently imaged at (center_x, center_y)) onto the FZP/beam + reference (fzp_x, fzp_y). """ target_x = fzp_x - center_x target_y = center_y - fzp_y @@ -710,13 +709,36 @@ class XrayEyeAlign: f"= {target_x:.4f} mm; target_y = center_y({center_y:.4f}) - fzp_y({fzp_y:.4f}) " f"= {target_y:.4f} mm" ) + return target_x, target_y + + def _apply_rotation_center_shift(self, shift_x: float, shift_y: float): + """Move lsamx/lsamy to the given total shift from the currently + configured lsamx_center/lsamy_center. + + lamni_move_to_scan_center's shift_x/shift_y are absolute offsets + from the *configured* center (dev.lsamx/lsamy.user_parameter["center"]), + which is not updated until the operator confirms at the end of this + procedure -- not incremental deltas from the stage's current + position. Callers doing repeated iterations (extended sample_type) + must therefore pass the running total shift each time, not just the + latest click's delta, or each new iteration partially undoes the + previous one's correction instead of adding to it. + + 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. + """ + print( + f"[rotation-center] applying total shift from configured center: " + f"({shift_x:.4f}, {shift_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() + self.scans.lamni_move_to_scan_center(shift_x=shift_x, shift_y=shift_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() + self.scans.lamni_move_to_scan_center(shift_x=shift_x, shift_y=shift_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"] @@ -814,9 +836,11 @@ class XrayEyeAlign: ) self._disable_rt_feedback() self.tomo_rotate(0) - self._apply_rotation_center_shift(fzp_x, fzp_y, center_x, center_y) + 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} ===") @@ -830,7 +854,14 @@ class XrayEyeAlign: "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) + 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]) 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 ad818bb..2435dc3 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 @@ -185,13 +185,25 @@ def _make_calibration_align(client): return align, dev_mock -def test_apply_rotation_center_shift_math(bec_client_mock): +def test_compute_shift_to_fzp_math(bec_client_mock): + client = bec_client_mock + align, _dev_mock = _make_calibration_align(client) + + target_x, target_y = align._compute_shift_to_fzp( + fzp_x=0.0, fzp_y=0.0, center_x=0.4, center_y=0.5 + ) + + assert target_x == pytest.approx(-0.4) + assert target_y == pytest.approx(0.5) + + +def test_apply_rotation_center_shift_moves_by_given_absolute_shift(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) + align._apply_rotation_center_shift(-0.4, 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: @@ -200,6 +212,44 @@ def test_apply_rotation_center_shift_math(bec_client_mock): assert call.kwargs["angle"] == 0.0 +def test_extended_calibration_accumulates_shift_across_iterations(bec_client_mock): + """Regression test: lamni_move_to_scan_center's shift_x/shift_y are + absolute offsets from the (not-yet-updated) configured center, so each + iteration in the extended path must pass the running total shift, not + just the latest click's delta -- otherwise iteration N+1 partially + undoes iteration N's correction instead of adding to it.""" + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + # Two clicks of the rotation centre across two iterations, at different + # (px) positions -- deltas of (-0.4, 0.5) then (-0.1, 0.2) mm to FZP (0,0). + dev_mock.omny_xray_gui.xval_x_1.get.side_effect = [400.0, 100.0] + dev_mock.omny_xray_gui.yval_y_1.get.side_effect = [-500.0, -200.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", side_effect=["n", "y"]): + align.find_rotation_center(sample_type="extended", apply=False) + + calls = align.scans.lamni_move_to_scan_center.call_args_list + # 2 calls per iteration (the function is called twice for settling) + assert len(calls) == 4 + # iteration 1: cumulative == first delta (fzp=(0,0), center=(0.4,-0.5) mm + # -> target_x = 0-0.4 = -0.4, target_y = -0.5-0 = -0.5) + assert calls[0].kwargs["shift_x"] == pytest.approx(-0.4) + assert calls[0].kwargs["shift_y"] == pytest.approx(-0.5) + assert calls[1].kwargs["shift_x"] == pytest.approx(-0.4) + assert calls[1].kwargs["shift_y"] == pytest.approx(-0.5) + # iteration 2: cumulative == first delta + second delta, NOT just the + # second delta on its own (second click center=(0.1,-0.2) mm -> + # target_x=-0.1, target_y=-0.2; cumulative = (-0.5, -0.7)) + assert calls[2].kwargs["shift_x"] == pytest.approx(-0.5) + assert calls[2].kwargs["shift_y"] == pytest.approx(-0.7) + assert calls[3].kwargs["shift_x"] == pytest.approx(-0.5) + assert calls[3].kwargs["shift_y"] == pytest.approx(-0.7) + + 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)