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 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-23 15:44:13 +02:00
co-authored by Claude Sonnet 5
parent b66aeae2d3
commit 3378a493c7
2 changed files with 88 additions and 12 deletions
@@ -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)
@@ -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)