feat(LamNI): add automated rotation-center calibration via X-ray eye

lsamx_center/lsamy_center have to be re-measured by hand for nearly every
new sample (the rotation stage is tilted on top of lsamx/lsamy, so the
effective axis position shifts with sample thickness/mounting). Add
XrayEyeAlign.find_rotation_center()/lamni.rotation_center_calibration_start()
to automate this using the existing X-ray eye GUI and lamni_move_to_scan_center
interferometer-feedback move:

- "isolated" sample: click particle centre at 0 and 180 deg, use the
  midpoint (angle-tilt-independent) as the rotation axis position.
- "extended" sample: live 0->180->0 sweep for visual identification, single
  click, then a verification sweep and an accept/iterate prompt.

Both write the result to lsamx/lsamy's "center" userParameter after
confirmation. Heavily verbose logging throughout since this is fundamentally
an interactive, hardware-in-the-loop procedure that can't be fully exercised
by automated tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-24 13:02:01 +02:00
committed by holler
co-authored by Claude Sonnet 5
parent 1a12212609
commit bd2e87ebdd
4 changed files with 498 additions and 3 deletions
@@ -1,5 +1,6 @@
from unittest import mock
import pytest
from bec_lib.device import DeviceBase
from csaxs_bec.bec_ipython_client.plugins.LamNI import LamNI
@@ -111,4 +112,166 @@ def test_tomo_rotate(bec_client_mock):
)
with mock.patch(f"{XRAY_EYE_ALIGN}.umv") as umv:
align.tomo_rotate(5)
umv.assert_called_once_with(client.device_manager.devices.lsamrot, 5)
umv.assert_called_once_with(client.device_manager.devices.lsamrot, 5)
# ----------------------------------------------------------------------
# Rotation-center calibration (find_rotation_center)
# ----------------------------------------------------------------------
class PositionerMock(DeviceBase):
"""A DeviceBase subclass with a working readback.read(), for devices the
DeviceManager container requires to be actual DeviceBase instances
(client.device_manager.devices.<name> = ... rejects plain mocks)."""
controller = mock.MagicMock()
enabled = True
def __init__(self, name, value, **kwargs):
super().__init__(
name=name,
config={"enabled": True, "deviceClass": "test_class", "readoutPriority": "baseline"},
**kwargs,
)
self._value = value
self.readback = mock.MagicMock()
self.readback.read.return_value = {name: {"value": value}}
self.readback.get.return_value = value
def _fake_positioner(name, value):
return PositionerMock(name, value)
def _make_calibration_align(client):
"""Build an XrayEyeAlign wired up for find_rotation_center() tests:
dev.omny_xray_gui/cam_xeye/fsh are mocked, lsamx/lsamy/rtx/rty readbacks
are fake positioners, and the lamni hardware-setup methods
(lfzp_in/loptics_out/lamnigui_show_xeyealign) are no-ops so the test only
exercises the click/shift/apply control flow.
"""
align = _make_align(client)
align.lamni.xeyegui = mock.MagicMock()
align.lamni.lfzp_in = mock.MagicMock()
align.lamni.loptics_out = mock.MagicMock()
align.lamni.lamnigui_show_xeyealign = mock.MagicMock()
# Replace the real Scans proxy (which would try to talk to a live scan
# server) with a plain mock -- these tests only care that
# lamni_move_to_scan_center is *called* with the right kwargs.
align.scans = mock.MagicMock()
client.device_manager.devices.lsamx = _fake_positioner("lsamx", 8.77)
client.device_manager.devices.lsamy = _fake_positioner("lsamy", 10.05)
client.device_manager.devices.rtx = _fake_positioner("rtx", 0.0)
client.device_manager.devices.rty = _fake_positioner("rty", 0.0)
# get_tomo_angle()/tomo_rotate() only need a working readback -- umv()
# itself is patched away in the tests below (it resolves `scans` from the
# module-level builtins snapshot, not from self.scans/align.scans).
client.device_manager.devices.lsamrot = _fake_positioner("lsamrot", 0.0)
dev_mock = mock.MagicMock()
dev_mock.cam_xeye.user_parameter.get.return_value = 0.001 # mm/px, clean test math
dev_mock.cam_xeye.live_mode_enabled.get.return_value = True
dev_mock.omny_xray_gui.submit.get.return_value = 1 # every _collect_click returns immediately
dev_mock.lsamx.user_parameter.get.return_value = 8.5 # "old" center, for the printed diff
dev_mock.lsamy.user_parameter.get.return_value = 9.9
# FZP reference click (step 0): (0, 0) px -> (0.0, 0.0) mm
dev_mock.omny_xray_gui.xval_x_0.get.return_value = 0.0
dev_mock.omny_xray_gui.yval_y_0.get.return_value = 0.0
return align, dev_mock
def test_apply_rotation_center_shift_math(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)
assert align.scans.lamni_move_to_scan_center.call_count == 2
for call in align.scans.lamni_move_to_scan_center.call_args_list:
assert call.kwargs["shift_x"] == pytest.approx(-0.4)
assert call.kwargs["shift_y"] == pytest.approx(0.5)
assert call.kwargs["angle"] == 0.0
def test_find_rotation_center_isolated_computes_midpoint(bec_client_mock):
client = bec_client_mock
align, dev_mock = _make_calibration_align(client)
# particle @0deg (step 1): (300, 300) px -> (0.3, 0.3) mm
dev_mock.omny_xray_gui.xval_x_1.get.return_value = 300.0
dev_mock.omny_xray_gui.yval_y_1.get.return_value = 300.0
# particle @180deg (step 2): (500, 700) px -> (0.5, 0.7) mm
dev_mock.omny_xray_gui.xval_x_2.get.return_value = 500.0
dev_mock.omny_xray_gui.yval_y_2.get.return_value = 700.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", return_value="y"):
result = align.find_rotation_center(sample_type="isolated")
# midpoint of (0.3,0.3) and (0.5,0.7) is (0.4, 0.5) ->
# target_x = fzp_x(0) - 0.4 = -0.4, target_y = 0.5 - fzp_y(0) = 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:
assert call.kwargs["shift_x"] == pytest.approx(-0.4)
assert call.kwargs["shift_y"] == pytest.approx(0.5)
assert call.kwargs["angle"] == 0.0
assert result == (8.77, 10.05)
dev_mock.lsamx.update_user_parameter.assert_called_once_with({"center": 8.77})
dev_mock.lsamy.update_user_parameter.assert_called_once_with({"center": 10.05})
def test_find_rotation_center_isolated_declines_apply(bec_client_mock):
client = bec_client_mock
align, dev_mock = _make_calibration_align(client)
dev_mock.omny_xray_gui.xval_x_1.get.return_value = 0.0
dev_mock.omny_xray_gui.yval_y_1.get.return_value = 0.0
dev_mock.omny_xray_gui.xval_x_2.get.return_value = 0.0
dev_mock.omny_xray_gui.yval_y_2.get.return_value = 0.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", return_value="n"):
align.find_rotation_center(sample_type="isolated")
dev_mock.lsamx.update_user_parameter.assert_not_called()
dev_mock.lsamy.update_user_parameter.assert_not_called()
def test_find_rotation_center_invalid_sample_type(bec_client_mock):
client = bec_client_mock
align, _ = _make_calibration_align(client)
with pytest.raises(ValueError):
align.find_rotation_center(sample_type="bogus")
def test_find_rotation_center_extended_iterates_until_happy(bec_client_mock):
client = bec_client_mock
align, dev_mock = _make_calibration_align(client)
dev_mock.omny_xray_gui.xval_x_1.get.return_value = 100.0
dev_mock.omny_xray_gui.yval_y_1.get.return_value = 0.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"):
# first iteration: "not happy" -> second iteration: "happy",
# then accept the final apply prompt
with mock.patch(f"{XRAY_EYE_ALIGN}.input", side_effect=["n", "y", "y"]):
align.find_rotation_center(sample_type="extended")
# one lamni_move_to_scan_center pair per iteration -> 2 iterations = 4 calls
assert align.scans.lamni_move_to_scan_center.call_count == 4
dev_mock.lsamx.update_user_parameter.assert_called_once()
dev_mock.lsamy.update_user_parameter.assert_called_once()