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)