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