Feat/xray centering lamni #270

Merged
holler merged 7 commits from feat/xray_centering_lamni into main 2026-07-24 13:02:02 +02:00
5 changed files with 691 additions and 5 deletions
@@ -455,6 +455,70 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
aligner = XrayEyeAlignGUI(self.client, self)
aligner.update_frame(keep_shutter_open=keep_shutter_open)
def xrayeye_rotation_center_calibration_isolated(self, keep_shutter_open: bool = False):
"""Determine new lsamx/lsamy rotation-center values for a sparse/isolated
particle, via the X-ray eye GUI.
Compares the particle's imaged position at 0 and 180 deg -- the
midpoint of the two is the rotation axis' position, independent of
the axis tilt. No visual judgement call, no iteration.
Args:
keep_shutter_open: If True the shutter is left open between steps
so the sample remains visible in live view.
Returns:
tuple: (new_lsamx_center, new_lsamy_center) in mm.
"""
return self._xrayeye_rotation_center_calibration(
sample_type="isolated", keep_shutter_open=keep_shutter_open
)
def xrayeye_rotation_center_calibration_extended(self, keep_shutter_open: bool = False):
"""Determine new lsamx/lsamy rotation-center values for a non-isolated/
textured sample, via the X-ray eye GUI.
Sweeps 0 -> 180 -> 0 deg with the live view open so the rotation
centre can be visually identified, then a single click is submitted
at 0 deg. After applying the correction, a verification sweep
(0 -> 45 -> 0 deg) runs and you're asked whether to accept the
alignment or run another iteration.
Args:
keep_shutter_open: If True the shutter is left open between steps
so the sample remains visible in live view.
Returns:
tuple: (new_lsamx_center, new_lsamy_center) in mm.
"""
return self._xrayeye_rotation_center_calibration(
sample_type="extended", keep_shutter_open=keep_shutter_open
)
def _xrayeye_rotation_center_calibration(self, sample_type: str, keep_shutter_open: bool):
"""Shared implementation for the two public entry points above.
Creates a fresh :class:`XrayEyeAlignGUI` instance and calls its
``find_rotation_center()`` method. The GUI window is opened
automatically. Interrupt with Ctrl-C to abort.
"""
aligner = XrayEyeAlignGUI(self.client, self)
try:
return aligner.find_rotation_center(
sample_type=sample_type, keep_shutter_open=keep_shutter_open
)
except KeyboardInterrupt as exc:
print("Rotation-center calibration interrupted by user.")
try:
aligner.gui.hide_crosshair()
except Exception as gui_exc: # pylint: disable=broad-except
logger.warning(f"Failed to hide XRayEye alignment crosshair: {gui_exc}")
try:
aligner.gui.set_dap_params_forwarding(False)
except Exception as gui_exc: # pylint: disable=broad-except
logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {gui_exc}")
raise exc
# ------------------------------------------------------------------
# RT feedback / interferometer helpers
# ------------------------------------------------------------------
@@ -296,7 +296,7 @@ class LamNIOpticsMixin:
self._lfzp_in()
if rtx_present and do_cycle:
print("Re-establishing interferometer feedback...")
print("Re-establishing interferometer feedback with reset...")
_t0 = time.time()
dev.rtx.controller.feedback_enable_with_reset()
print(f"Interferometer feedback re-established ({time.time() - _t0:.1f} s).")
@@ -326,7 +326,7 @@ class LamNIOpticsMixin:
if "rtx" in dev and dev.rtx.enabled:
time.sleep(1)
print("Re-establishing interferometer feedback...")
print("Re-establishing interferometer feedback with reset...")
_t0 = time.time()
dev.rtx.controller.feedback_enable_with_reset()
print(f"Interferometer feedback re-established ({time.time() - _t0:.1f} s).")
@@ -254,6 +254,10 @@ class XrayEyeAlign:
self.alignment_images = []
# --- Step 0: FZP centre ------------------------------------------
# OSA must be confirmed out before the FZP click is requested -- it
# 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()
#self._disable_rt_feedback()
# force_feedback_reset=True: this is the start of a fresh alignment
# run, so the interferometer reference must always be freshly
@@ -572,4 +576,351 @@ class XrayEyeAlign:
# Push to XRayEye widget: feeds waveform_x (row 1) and waveform_y (row 2)
self.gui.submit_fit_array(data)
print(f"Fit data submitted with shape {data.shape}:\n{data}")
print(f"Fit data submitted with shape {data.shape}:\n{data}")
# ------------------------------------------------------------------
# Rotation-center calibration
# ------------------------------------------------------------------
#
# Determines lsamx_center/lsamy_center (the userParameter "center" of the
# lsamx/lsamy devices) -- the stage position at which the physical
# rotation axis coincides with the FZP/beam reference. This has to be
# re-measured whenever the sample mount/thickness changes, because the
# rotation stage is tilted on top of lsamx/lsamy (see docs/user/
# ptychography/lamni.md, "Coarse axis alignment", for the manual
# procedure this automates).
#
# Math note: let the rotation axis' projected position (a function of
# the *current*, possibly-stale lsamx/lsamy) be P_axis -- this does not
# move as lsamrot rotates, only lsamx/lsamy moves it. A particle rigidly
# mounted at eccentricity vector e (expressed in the angle-0 frame)
# images at P_axis + T(e, angle), where T is exactly
# _lamni_compute_scan_center's transform (T(v,0) = v, T(v,180) = -v,
# since the transform is linear/homogeneous -- no translation term). So
# S0 = P_axis + e, S180 = P_axis - e --> (S0+S180)/2 = P_axis,
# independent of e and of the axis tilt. The needed correction
# target = FZP - P_axis is a *plain translation*, not an eccentric,
# angle-dependent quantity, so it must always be passed through
# scans.lamni_move_to_scan_center with angle=0.0 regardless of the
# physical lsamrot angle at click time -- angle=0 is the only angle for
# which the transform doesn't distort a translation-type vector.
ROTATION_CENTER_SAMPLE_TYPES = ("isolated", "extended")
def _collect_click(self, k: int, message: str, label: str = "") -> tuple[float, float]:
"""Wait for the operator to submit a click at GUI step *k*.
Returns the click position in mm (converted via pixel_calibration).
"""
print(f"[rotation-center] step {k}: waiting for submit -- {message}")
dev.omny_xray_gui.step.set(k)
self.send_message(message)
self.gui.enable_submit_button(True)
while True:
if dev.omny_xray_gui.submit.get() == 1:
raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get()
raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get()
val_x = raw_x * self.pixel_calibration
val_y = raw_y * self.pixel_calibration
dev.omny_xray_gui.submit.set(0)
self.gui.enable_submit_button(False)
tag = f" ({label})" if label else ""
print(
f"[rotation-center] received click{tag}: step={k} "
f"px=({raw_x:.1f}, {raw_y:.1f}) -> mm=({val_x:.4f}, {val_y:.4f})"
)
return (val_x, val_y)
time.sleep(0.2)
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.
No frames are captured/clicked during the sweep -- it exists purely
so the operator can watch the sample rotate live (e.g. to visually
identify the stationary rotation-center feature on an extended
sample, or to reposition their ROI selector). RT feedback is
disabled for the duration; it is re-enabled naturally by whichever
lamni_move_to_scan_center call follows the sweep.
"""
print(f"[rotation-center] starting live sweep through {angles} deg (shutter/live-view open)")
if not dev.cam_xeye.live_mode_enabled.get():
dev.cam_xeye.live_mode_enabled.put(True)
self.gui.on_live_view_enabled(True)
dev.fsh.fshopen()
self._disable_rt_feedback()
for target in angles:
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 _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
print(
f"[rotation-center] shift needed: target_x = fzp_x({fzp_x:.4f}) - center_x({center_x:.4f}) "
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=shift_x, shift_y=shift_y, angle=0.0).wait()
time.sleep(1)
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"]
rtx_after = self.device_manager.devices.rtx.readback.read()["rtx"]["value"]
rty_after = self.device_manager.devices.rty.readback.read()["rty"]["value"]
print(
f"[rotation-center] lsamx/lsamy after move: ({lx_after:.4f}, {ly_after:.4f}); "
f"interferometer rtx/rty now read ({rtx_after:.2f}, {rty_after:.2f}) um"
)
def find_rotation_center(
self, sample_type: str = "isolated", keep_shutter_open: bool = False, apply: bool = True
):
"""Determine (and optionally apply) new lsamx/lsamy rotation-center values.
Two operator-selectable strategies:
"isolated" -- for a sparse/isolated particle. Its imaged position is
submitted at 0 deg and again at 180 deg; the midpoint of the two
is the rotation axis' projected position, independent of the
axis tilt (see the module-level math note above). No visual
judgement call and no iteration.
"extended" -- for a non-isolated/textured sample where the rotation
centre can be visually identified directly. The sample is swept
0 -> 180 -> 0 deg with the live view open so the operator can
watch for the stationary point, then a single click is submitted
at 0 deg. After applying the correction, a short verification
sweep (0 -> 45 -> 0 deg) runs and the operator is asked whether
to accept the alignment or run another iteration.
Args:
sample_type: "isolated" or "extended", see above.
keep_shutter_open: passed through to update_frame(), see align().
apply: if True (default), ask for confirmation and then persist
the computed values via dev.lsamx.update_user_parameter() /
dev.lsamy.update_user_parameter().
Returns:
tuple: (new_lsamx_center, new_lsamy_center) in mm.
"""
if sample_type not in self.ROTATION_CENTER_SAMPLE_TYPES:
raise ValueError(
f"sample_type must be one of {self.ROTATION_CENTER_SAMPLE_TYPES}, got {sample_type!r}"
)
self.lamni.lamnigui_show_xeyealign()
self.gui.set_dap_params_forwarding(False)
self._reset_init_values()
self.alignment_images = []
self.movement_buttons_enabled(False, False)
self.gui.enable_submit_button(False)
dev.omny_xray_gui.mvx.set(0)
dev.omny_xray_gui.mvy.set(0)
dev.omny_xray_gui.submit.set(0)
print(f"[rotation-center] starting find_rotation_center(sample_type={sample_type!r})")
# --- FZP reference (step 0) -----------------------------------
self.send_message("Getting things ready. Please wait...")
# OSA must be confirmed out before the FZP click is requested -- it
# 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")
self.send_message("Please wait - moving sample in...")
self.lamni.loptics_out()
self._disable_rt_feedback()
self.tomo_rotate(0)
self.update_frame(keep_shutter_open)
self.gui.set_crosshair_position(fzp_x / self.pixel_calibration, fzp_y / self.pixel_calibration)
self.gui.show_crosshair()
if sample_type == "isolated":
self.movement_buttons_enabled(True, True)
p0 = self._collect_click(1, "Submit particle centre (0 deg).", label="particle @0deg")
self._disable_rt_feedback()
self.tomo_rotate(180)
self.update_frame(keep_shutter_open)
p180 = self._collect_click(
2, "Submit the SAME particle's centre (180 deg).", label="particle @180deg"
)
center_x, center_y = (p0[0] + p180[0]) / 2, (p0[1] + p180[1]) / 2
print(
f"[rotation-center] midpoint of ({p0[0]:.4f},{p0[1]:.4f}) and "
f"({p180[0]:.4f},{p180[1]:.4f}) = ({center_x:.4f},{center_y:.4f}) mm "
"-- this is the rotation axis' current projected position"
)
self._disable_rt_feedback()
self.tomo_rotate(0)
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} ===")
self.send_message(
"Watch the sample rotate and locate the stationary rotation centre..."
)
self._live_sweep([180, 0])
self.update_frame(keep_shutter_open)
self.movement_buttons_enabled(True, True)
center_x, center_y = self._collect_click(
1,
"Submit the rotation centre (0 deg).",
label=f"rotation centre, iter {iteration}",
)
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])
self.update_frame(keep_shutter_open)
answer = (
input(
"Alignment acceptable -- stop here? [Y/n] (n = run another iteration): "
)
.strip()
.lower()
)
if answer in ("", "y", "yes"):
print(
f"[rotation-center] operator accepted alignment after {iteration} iteration(s)"
)
break
print("[rotation-center] operator requested another iteration.")
self.gui.hide_crosshair()
new_lsamx = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"]
new_lsamy = self.device_manager.devices.lsamy.readback.read()["lsamy"]["value"]
old_lsamx = dev.lsamx.user_parameter.get("center")
old_lsamy = dev.lsamy.user_parameter.get("center")
print(
f"[rotation-center] RESULT: new lsamx_center={new_lsamx:.4f} mm "
f"(was {old_lsamx}), new lsamy_center={new_lsamy:.4f} mm (was {old_lsamy})"
)
if apply:
answer = (
input(
f"Update lsamx/lsamy center user parameters to "
f"({new_lsamx:.4f}, {new_lsamy:.4f})? [Y/n]: "
)
.strip()
.lower()
)
if answer in ("", "y", "yes"):
dev.lsamx.update_user_parameter({"center": float(new_lsamx)})
dev.lsamy.update_user_parameter({"center": float(new_lsamy)})
print(
f"[rotation-center] lsamx.user_parameter['center'] = "
f"{dev.lsamx.user_parameter.get('center')}, "
f"lsamy.user_parameter['center'] = {dev.lsamy.user_parameter.get('center')}"
)
else:
print(
"[rotation-center] NOT updated -- config unchanged. Apply manually with "
"dev.lsamx.update_user_parameter({'center': ...}) / same for lsamy."
)
if keep_shutter_open:
answer = input("Close the shutter now? [Y/n]: ").strip().lower()
if answer in ("", "y", "yes"):
dev.fsh.fshclose()
self.gui.on_live_view_enabled(False)
print("Shutter closed.")
else:
print("Shutter left open.")
return (new_lsamx, new_lsamy)
+18 -1
View File
@@ -18,7 +18,24 @@ Mount the new sample. The X-ray eye is already in, but the X-ray optics needs to
#### Coarse axis alignment
The effective position of the axis of rotation shifts with sample thickness or mounting position of the sample along the axis of rotation. The position of the axis of rotation is controlled by user parameters **center** of the **lsamx** and **lsamy** stages. To observe the axis of rotation obtain the position of the Fresnel zone plate on the X-ray eye, possibly in the *ueye gui* by:
The effective position of the axis of rotation shifts with sample thickness or mounting position of the sample along the axis of rotation. The position of the axis of rotation is controlled by user parameters **center** of the **lsamx** and **lsamy** stages.
The recommended way to (re-)measure `center` for a new sample is the GUI-driven procedure below. It replaces the fully manual jog-by-hand steps that used to live here; those are kept further down as a fallback for when the automated procedure or the GUI is unavailable.
**Automated rotation-center calibration**
This opens the X-ray eye widget and walks through the calibration interactively. Pick the function based on what's mounted:
- `lamni.xrayeye_rotation_center_calibration_isolated()` — for a sparse/isolated particle. You submit its centre position once at 0° and once at 180°; the midpoint of the two is the rotation axis' projected position (this works regardless of the axis tilt). No further confirmation step.
- `lamni.xrayeye_rotation_center_calibration_extended()` — for a non-isolated/textured sample where the rotation centre can be identified visually. The sample sweeps 0° → 180° → 0° with the live view left open so you can watch for the point that doesn't move, then you submit a single click at 0°. The correction is applied, a short verification sweep (0° → 45° → 0°) runs, and you're asked *"Are you happy with this alignment, or shall we do another iteration?"* — answer `n` to refine further.
At the end you're shown the computed `lsamx_center`/`lsamy_center` and asked to confirm before they're written via `dev.lsamx.update_user_parameter(...)` / `dev.lsamy.update_user_parameter(...)`.
Pass `keep_shutter_open=True` if it's hard to relocate the sample between steps, matching `xrayeye_alignment_start()` below. Interrupt with Ctrl-C to abort; the crosshair and DAP forwarding are cleaned up automatically.
**Manual fallback**
To observe the axis of rotation obtain the position of the Fresnel zone plate on the X-ray eye, possibly in the *ueye gui* by:
1. `lamni.lfzp_in()`, move the FZP in
2. `dev.rtx.controller.feedback_disable()`, disable feedback to allow lsam movements
@@ -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,257 @@ 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.losa_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_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(-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:
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_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)
# 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)
# 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()