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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user