Feat/xray centering lamni #270
@@ -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,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()
|
||||
Reference in New Issue
Block a user