fix(LamNI): accumulate rotation-center shift across calibration iterations
CI for csaxs_bec / test (push) Successful in 1m44s
CI for csaxs_bec / test (pull_request) Successful in 1m47s

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 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-23 16:21:48 +02:00
co-authored by Claude Sonnet 5
parent 227cbb43ec
commit e8cee257c3
2 changed files with 94 additions and 13 deletions
@@ -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])
@@ -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)