Fix/fixes lamni beamtime 1 #331

Merged
holler merged 11 commits from fix/fixes_lamni_beamtime_1 into main 2026-10-14 12:28:35 +02:00
16 changed files with 537 additions and 277 deletions

No files matched your search

@@ -536,8 +536,9 @@ class LamNI(
Args:
keep_shutter_open: If True the shutter is left open between angle
steps so the sample remains visible in live view.
force: skip the rotation-center-calibration check below without
prompting.
force: skip the rotation-center-calibration check and the
"this will erase the existing alignment" warning below
without prompting.
"""
if self.client.get_global_var("lamni_center_found_at") is None:
if not self._confirm_sequence_override(
@@ -549,6 +550,14 @@ class LamNI(
):
print("Aborting X-ray eye alignment.")
return
existing = self._existing_alignment()
if existing and not self._confirm_sequence_override(
f"Starting a new X-ray eye alignment will erase the existing "
f"{' and '.join(existing)} for this sample.",
force,
):
print("Aborting X-ray eye alignment.")
return
aligner = XrayEyeAlignGUI(self.client, self)
try:
aligner.align(keep_shutter_open=keep_shutter_open)
@@ -1405,6 +1414,8 @@ class LamNI(
self._current_scan_list = []
start_scan_number = bec.queue.next_scan_number
projection_start = time.perf_counter()
for stitch_x in range(-self.lamni_stitch_x, self.lamni_stitch_x + 1):
for stitch_y in range(-self.lamni_stitch_y, self.lamni_stitch_y + 1):
self._current_scan_list.append(bec.queue.next_scan_number)
@@ -1441,6 +1452,22 @@ class LamNI(
corridor_size=corridor_size,
)
projection_duration = time.perf_counter() - projection_start
end_scan_number = bec.queue.next_scan_number
# Internal callers (_at_each_angle, tomo_alignment_scan) run inside a
# flow that already logs projection timing in _tomo_scan_at_angle, so
# only log here for direct CLI calls (e.g. probe/test scans) to avoid
# a duplicate record per projection -- and so that
# scilog_last_ptycho_scans() finds them. Same as Flomni.
if not _internal:
self._log_projection_timing(
angle=angle,
subtomo_number=0,
duration_s=projection_duration,
start_scan_number=start_scan_number,
end_scan_number=end_scan_number,
)
if _reconstruct:
self.tomo_reconstruct()
@@ -2756,10 +2783,13 @@ class LamNI(
except Exception:
focal_distance_str = "N/A"
# FZP focus-to-sample distance: same live z-stage-based calculation
# lfzp_info() already uses -- not a stored value.
# lfzp_info() already uses -- not a stored value. Depends on the
# mounted FZP type (30 nm sits in an extra holder further downstream).
try:
loptz_val = dev.loptz.read()["loptz"]["value"]
fzp_sample_distance_str = f"{-loptz_val + 85.6 + 52:.1f}"
fzp_sample_distance_str = (
f"{self._fzp_sample_distance(loptz_val, fzp_zone_width_nm):.1f}"
)
except Exception:
fzp_sample_distance_str = "N/A"
# Sample-to-detector distance: userParameter on loptx, defaults to
@@ -81,6 +81,17 @@ class LamNIAlignmentMixin:
self.client.delete_global_var("tomo_fit_xray_eye")
self.client.delete_global_var(XEYE_FIT_DATA_VAR)
def _existing_alignment(self) -> list[str]:
"""Names of the alignment results currently stored, i.e. what a new
X-ray eye alignment or an applied rotation-center calibration would
erase. Empty if there is nothing to lose."""
existing = []
if self.client.get_global_var("tomo_fit_xray_eye") is not None:
existing.append("X-ray eye alignment fit")
if self.corr_pos_x:
existing.append("fine-alignment correction")
return existing
# ------------------------------------------------------------------
# Additional lookup-table correction properties -- global-var-backed so
# they survive a BEC client restart, same pattern as tomo_fovx_offset/
@@ -214,21 +214,65 @@ class LamNIOpticsMixin:
return None
return param.get(var)
@property
def _sl1xs_before_eye(self):
return self.client.get_global_var("lamni_sl1xs_before_eye")
# slits opened by _EYE_SLIT_OPENING (relative) while the xray eye is in
_EYE_SLITS = ("sl1", "sl6")
_EYE_SLIT_OPENING = 1.0 # mm
@_sl1xs_before_eye.setter
def _sl1xs_before_eye(self, val):
self.client.set_global_var("lamni_sl1xs_before_eye", val)
@staticmethod
def _slit_size(name):
# sl1xs/sl1ys are EpicsMotors (user_readback), sl6xs/sl6ys are
# VirtualSlitWidth pseudo motors (readback)
device = dev[name]
if hasattr(device, "user_readback"):
return device.user_readback.get()
return device.readback.get()
@property
def _sl1ys_before_eye(self):
return self.client.get_global_var("lamni_sl1ys_before_eye")
def _slit_before_eye(self, slit):
return (
self.client.get_global_var(f"lamni_{slit}xs_before_eye"),
self.client.get_global_var(f"lamni_{slit}ys_before_eye"),
)
@_sl1ys_before_eye.setter
def _sl1ys_before_eye(self, val):
self.client.set_global_var("lamni_sl1ys_before_eye", val)
def _set_slit_before_eye(self, slit, xs, ys):
self.client.set_global_var(f"lamni_{slit}xs_before_eye", xs)
self.client.set_global_var(f"lamni_{slit}ys_before_eye", ys)
def _open_slits_for_eye(self):
for slit in self._EYE_SLITS:
xs, ys = f"{slit}xs", f"{slit}ys"
if xs not in dev or ys not in dev:
continue
self._set_slit_before_eye(slit, self._slit_size(xs), self._slit_size(ys))
scans.umv(
dev[xs], self._EYE_SLIT_OPENING, dev[ys], self._EYE_SLIT_OPENING, relative=True
)
def _restore_slits_after_eye(self):
for slit in self._EYE_SLITS:
xs, ys = f"{slit}xs", f"{slit}ys"
xs_before, ys_before = self._slit_before_eye(slit)
if xs_before is None or ys_before is None:
continue
if xs not in dev or ys not in dev:
self._set_slit_before_eye(slit, None, None)
continue
# if the slit was manually re-adjusted while the eye was in,
# offer to keep the current size instead of restoring the
# pre-eye value
tol = 0.01 # mm (10 microns)
manually_adjusted = not np.isclose(
self._slit_size(xs), xs_before + self._EYE_SLIT_OPENING, atol=tol
) or not np.isclose(self._slit_size(ys), ys_before + self._EYE_SLIT_OPENING, atol=tol)
if manually_adjusted and self.OMNYTools.yesno(
f"{xs}/{ys} appear to have been adjusted manually while the "
"eye was in. Keep the current slit size instead of "
"restoring the pre-eye value?"
):
print(f"Keeping current {slit} size, not restoring pre-eye value.")
else:
umv(dev[xs], xs_before, dev[ys], ys_before)
self._set_slit_before_eye(slit, None, None)
def leye_out(self):
self.loptics_in()
@@ -240,74 +284,46 @@ class LamNIOpticsMixin:
if self.csaxs is not None:
self.csaxs.fil_trans(1, print_only=False)
sl1xs_before = self._sl1xs_before_eye
sl1ys_before = self._sl1ys_before_eye
if sl1xs_before is not None and sl1ys_before is not None:
# if the slit was manually re-adjusted while the eye was in,
# offer to keep the current size instead of restoring the
# pre-eye value
current_sl1xs = dev.sl1xs.user_readback.get()
current_sl1ys = dev.sl1ys.user_readback.get()
expected_sl1xs = sl1xs_before + 0.3
expected_sl1ys = sl1ys_before + 0.3
tol = 0.01 # mm (10 microns)
manually_adjusted = not np.isclose(
current_sl1xs, expected_sl1xs, atol=tol
) or not np.isclose(current_sl1ys, expected_sl1ys, atol=tol)
if manually_adjusted and self.OMNYTools.yesno(
"sl1xs/sl1ys appear to have been adjusted manually while the "
"eye was in. Keep the current slit size instead of "
"restoring the pre-eye value?"
):
print("Keeping current slit size, not restoring pre-eye value.")
else:
umv(dev.sl1xs, sl1xs_before, dev.sl1ys, sl1ys_before)
self._sl1xs_before_eye = None
self._sl1ys_before_eye = None
self._restore_slits_after_eye()
leyey_out = self._get_user_param_safe("leyey", "out")
umv(dev.leyey, leyey_out)
umv(dev.lsamrot, 0)
#umv(dev.dttrz, 5854, dev.fttrz, 2395)
# DRAFT, disabled until the fttrz positions are known: move the flight tube
# in after the eye is out, like flomni's feye_out(). Needs the userParameters
# fttrz_in / fttrz_out on leyex (see ptycho_lamni.yaml).
# if "fttrz" not in dev:
# if not self.OMNYTools.yesno(
# "Device 'fttrz' does not exist in the current config, so it "
# "can't be moved into position. Continue anyway? WARNING: "
# "this could cause collisions when operating on the real "
# "beamline."
# ):
# print("Aborting leye_out(): fttrz not moved.")
# return
# else:
# fttrz_in = self._get_user_param_safe("leyex", "fttrz_in")
# umv(dev.fttrz, fttrz_in)
# flight tube (fttrz) and detector table (dttrz) move together
if "fttrz" not in dev or "dttrz" not in dev:
if not self.OMNYTools.yesno(
"Device 'fttrz' or 'dttrz' does not exist in the current config, "
"so the flight tube and detector table can't be moved into position. "
"Continue anyway? WARNING: this could cause collisions when "
"operating on the real beamline."
):
print("Aborting leye_out(): fttrz/dttrz not moved.")
return
else:
fttrz_in = self._get_user_param_safe("leyex", "fttrz_in")
dttrz_in = self._get_user_param_safe("leyex", "dttrz_in")
umv(dev.fttrz, fttrz_in, dev.dttrz, dttrz_in)
def leye_in(self):
bec.queue.next_dataset_number += 1
umv(dev.lsamrot, 0)
#umv(dev.dttrz, 6419.677, dev.fttrz, 2959.979)
# DRAFT, disabled until the fttrz positions are known: move the flight tube
# out before the eye goes in, like flomni's feye_in(). When enabled, this
# replaces the manual "Did the flight tube move out?" prompt below.
# if "fttrz" not in dev:
# if not self.OMNYTools.yesno(
# "Device 'fttrz' does not exist in the current config, so it "
# "can't be moved out of the way. Continue anyway? WARNING: "
# "this could cause collisions when operating on the real "
# "beamline."
# ):
# print("Aborting leye_in(): fttrz not moved.")
# return
# else:
# fttrz_out = self._get_user_param_safe("leyex", "fttrz_out")
# umv(dev.fttrz, fttrz_out)
# flight tube (fttrz) and detector table (dttrz) move together
if "fttrz" not in dev or "dttrz" not in dev:
if not self.OMNYTools.yesno(
"Device 'fttrz' or 'dttrz' does not exist in the current config, "
"so the flight tube and detector table can't be moved out of the way. "
"Continue anyway? WARNING: this could cause collisions when "
"operating on the real beamline."
):
print("Aborting leye_in(): fttrz/dttrz not moved.")
return
else:
fttrz_out = self._get_user_param_safe("leyex", "fttrz_out")
dttrz_out = self._get_user_param_safe("leyex", "dttrz_out")
umv(dev.fttrz, fttrz_out, dev.dttrz, dttrz_out)
if not self.OMNYTools.yesno("Did the flight tube move out?", "y"):
return
@@ -322,10 +338,7 @@ class LamNIOpticsMixin:
# both already in position -> do nothing
pass
else:
if "sl1xs" in dev and "sl1ys" in dev:
self._sl1xs_before_eye = dev.sl1xs.user_readback.get()
self._sl1ys_before_eye = dev.sl1ys.user_readback.get()
scans.umv(dev.sl1xs, 0.3, dev.sl1ys, 0.3, relative=True)
self._open_slits_for_eye()
if self.csaxs is not None:
self.csaxs.fil_trans(0.05, print_only=False)
@@ -543,6 +556,21 @@ class LamNIOpticsMixin:
else:
print("OSA is already at the OUT position.")
# the 30 nm FZP sits in an additional holder that puts the chip this
# much further downstream (closer to the sample) than the 60 nm FZP.
# Calibrated 2026-10-14 for a sample on the downstream-facing side of the
# sample holder: 250 um / 30 nm FZP at 7.9 keV (focal distance 47.79 mm),
# sample 1 mm beyond the focus at loptz 80.8548
# -> 137.6 - 80.8548 - 48.79 = 7.955 mm.
_FZP_30NM_HOLDER_OFFSET = 7.955 # mm
def _fzp_sample_distance(self, loptz_val, outermost_zone_width_nm):
"""FZP-to-sample distance in mm for the given loptz position and FZP type."""
distance = -loptz_val + 85.6 + 52
if outermost_zone_width_nm == 30:
distance -= self._FZP_30NM_HOLDER_OFFSET
return distance
def lfzp_info(self, mokev_val=-1):
if mokev_val == -1:
# The energy readback device is "ccm_energy" (same as flomni's
@@ -557,8 +585,13 @@ class LamNIOpticsMixin:
)
return
loptz_val = dev.loptz.read()["loptz"]["value"]
distance = -loptz_val + 85.6 + 52
print(f"The sample is in a distance of {distance:.1f} mm from the FZP.")
distance = self._fzp_sample_distance(loptz_val, 60)
distance_30nm = self._fzp_sample_distance(loptz_val, 30)
print(
f"The sample is in a distance of {distance:.1f} mm from a 60 nm FZP and "
f"{distance_30nm:.1f} mm from a 30 nm FZP (extra holder, "
f"{self._FZP_30NM_HOLDER_OFFSET:.2f} mm further downstream)."
)
print(f"At the current energy of {mokev_val:.4f} keV we have following options:\n")
@@ -601,7 +634,7 @@ class LamNIOpticsMixin:
outermost_zonewidth = 30e-9
focal_distance = diameter * outermost_zonewidth / wavelength
beam_size = (
-diameter / (focal_distance * 1000) * (focal_distance * 1000 - distance) * 1e6
-diameter / (focal_distance * 1000) * (focal_distance * 1000 - distance_30nm) * 1e6
)
table.add_row(
f"{diameter*1e6:.2f} microns",
@@ -611,7 +644,8 @@ class LamNIOpticsMixin:
console.print(table)
print(
"The numbers presented here are for a sample in the plane of the lamni sample holder.\n"
"The numbers presented here are for a sample facing the downstream side "
"of the lamni sample holder.\n"
)
collision_offset = self._get_user_param_optional("loptz", "collision_offset")
@@ -637,7 +671,7 @@ class LamNIOpticsMixin:
print(f" Current losaz {losaz_val:.1f}")
print(
" The OSA will collide with a normal sample holder at losaz "
f"\033[1m{(collision_offset - losaz_val):.1f}\033[0m"
f"\033[1m{(collision_offset - loptz_val):.1f}\033[0m"
)
print(f" Remaining space (right now): \033[1m{remaining_current:.1f}\033[0m")
@@ -114,6 +114,11 @@ class XrayEyeAlign:
PIXEL_CALIBRATION_DEFAULT = 0.2 / 218 # mm/pixel (0.2 mm FOV, 218 px)
PIXEL_CALIBRATION_USER_PARAM = "pixel_calibration"
# Fixed rotation (deg) of the elliptical ROI in the extended/smear
# rotation-center calibrations (set at the beamline 2026-10-14). The
# 8-angle X-ray eye alignment offers the ellipse with a free angle.
ROTATION_CENTER_ELLIPSE_ANGLE = -76.0
def __init__(self, client, lamni: LamNI) -> None:
self.client = client
self.lamni = lamni
@@ -248,7 +253,9 @@ class XrayEyeAlign:
# opened at least once -- unlike align()/find_rotation_center(), this
# is often the first X-ray-eye call of a session (see leye_in()), so
# it must open the widget itself rather than assume it already exists.
if self.gui is None:
# It can also be a stale handle if the user closed the widget, which
# would raise DeletedWidgetError on the first RPC call below.
if self.lamni._lamnigui_is_missing("xeyegui"):
self.lamni.lamnigui_show_xeyealign()
if not dev.cam_xeye.live_mode_enabled.get():
dev.cam_xeye.live_mode_enabled.put(True)
@@ -431,175 +438,184 @@ class XrayEyeAlign:
self.send_message("Submit centre of FZP.")
k = 0
while True:
if dev.omny_xray_gui.submit.get() == 1:
val_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration
val_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() * self.pixel_calibration
self.alignment_values[k] = [val_x, val_y]
print(
f"Clicked position {k}: "
f"x={self.alignment_values[k][0]:.4f} mm, "
f"y={self.alignment_values[k][1]:.4f} mm"
)
dev.omny_xray_gui.submit.set(0)
# Raw pixel position and ROI size at submit time. The
# relevant image is the last captured frame (shutter is
# closed by the time the user clicks submit).
_raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get()
_raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get()
_raw_w = getattr(dev.omny_xray_gui, f"width_x_{k}").get()
_raw_h = getattr(dev.omny_xray_gui, f"width_y_{k}").get()
_img_idx = len(self.alignment_images) - 1
print(
f" Submit k={k}: px x={_raw_x:.1f} y={_raw_y:.1f} "
f"w={_raw_w:.1f} h={_raw_h:.1f} img={_img_idx}"
)
self.roi_pixel_data.append([k, _raw_x, _raw_y, _raw_w, _raw_h, _img_idx])
# --- k=0: received FZP centre ----------------------------
if k == 0:
self.send_message("Please wait - moving sample in...")
self.movement_buttons_enabled(False, False)
self.gui.enable_submit_button(False)
self.lamni.loptics_out()
#self._disable_rt_feedback()
#time.sleep(0.3)
#self._enable_rt_feedback()
self.update_frame(keep_shutter_open)
# Mark the FZP centre on the live view: it stays visible
# as a fixed reference while the sample is aligned at
# each subsequent rotation angle below.
fzp_center_x = dev.omny_xray_gui.xval_x_0.get()
fzp_center_y = dev.omny_xray_gui.yval_y_0.get()
self.gui.set_crosshair_position(fzp_center_x, fzp_center_y)
self.gui.show_crosshair()
self.send_message("Find the sample and submit its centre.")
self.gui.enable_submit_button(True)
self.movement_buttons_enabled(True, True)
# --- k=1: sample at 0° - compute base shift --------------
elif k == 1:
# Displacement from FZP centre to sample centre gives the
# correction needed to bring the sample onto the beam axis.
# Sign conventions match the old alignment.py.
self.shift_xy[0] += (
self.alignment_values[0][0] - self.alignment_values[1][0]
) * 1000 # um
self.shift_xy[1] += (
self.alignment_values[1][1] - self.alignment_values[0][1]
) * 1000 # um
# The ellipse tool is offered (angle free) for the sample clicks only,
# switched on after the FZP click in the k == 0 branch below.
try:
while True:
if dev.omny_xray_gui.submit.get() == 1:
val_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration
val_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() * self.pixel_calibration
self.alignment_values[k] = [val_x, val_y]
print(
f"Base shift: "
f"x={self.shift_xy[0]:.2f} um, "
f"y={self.shift_xy[1]:.2f} um"
f"Clicked position {k}: "
f"x={self.alignment_values[k][0]:.4f} mm, "
f"y={self.alignment_values[k][1]:.4f} mm"
)
dev.omny_xray_gui.submit.set(0)
self.send_message("Please wait - moving to scan centre...")
self.movement_buttons_enabled(False, False)
self.gui.enable_submit_button(False)
# Raw pixel position and ROI size at submit time. The
# relevant image is the last captured frame (shutter is
# closed by the time the user clicks submit).
_raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get()
_raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get()
_raw_w = getattr(dev.omny_xray_gui, f"width_x_{k}").get()
_raw_h = getattr(dev.omny_xray_gui, f"width_y_{k}").get()
_img_idx = len(self.alignment_images) - 1
print(
f" Submit k={k}: px x={_raw_x:.1f} y={_raw_y:.1f} "
f"w={_raw_w:.1f} h={_raw_h:.1f} img={_img_idx}"
)
self.roi_pixel_data.append([k, _raw_x, _raw_y, _raw_w, _raw_h, _img_idx])
self.scans.lamni_move_to_scan_center(
shift_x=self.shift_xy[0] / 1000,
shift_y=self.shift_xy[1] / 1000,
angle=float(self.get_tomo_angle()),
).wait()
time.sleep(1)
self.scans.lamni_move_to_scan_center(
shift_x=self.shift_xy[0] / 1000,
shift_y=self.shift_xy[1] / 1000,
angle=float(self.get_tomo_angle()),
).wait()
# --- k=0: received FZP centre ----------------------------
if k == 0:
self.send_message("Please wait - moving sample in...")
self.movement_buttons_enabled(False, False)
self.gui.enable_submit_button(False)
dev.omny_xray_gui.angle.set(self.get_tomo_angle())
self.update_frame(keep_shutter_open)
self.send_message("Submit sample centre and FOV (0 deg).")
self.gui.enable_submit_button(True)
self.movement_buttons_enabled(True, False)
self.update_fov(k)
self.lamni.loptics_out()
#self._disable_rt_feedback()
#time.sleep(0.3)
#self._enable_rt_feedback()
# --- k=2..8: sample at 45, 90, ..., 315 deg -------------
elif 1 < k <= 8:
self.send_message("Please wait - rotating...")
self.gui.enable_submit_button(False)
self.movement_buttons_enabled(False, False)
self.update_frame(keep_shutter_open)
# Mark the FZP centre on the live view: it stays visible
# as a fixed reference while the sample is aligned at
# each subsequent rotation angle below.
fzp_center_x = dev.omny_xray_gui.xval_x_0.get()
fzp_center_y = dev.omny_xray_gui.yval_y_0.get()
self.gui.set_crosshair_position(fzp_center_x, fzp_center_y)
self.gui.show_crosshair()
self.gui.enable_ellipse_roi(True)
self.send_message("Find the sample and submit its centre.")
self.gui.enable_submit_button(True)
self.movement_buttons_enabled(True, True)
# --- k=1: sample at 0° - compute base shift --------------
elif k == 1:
# Displacement from FZP centre to sample centre gives the
# correction needed to bring the sample onto the beam axis.
# Sign conventions match the old alignment.py.
self.shift_xy[0] += (
self.alignment_values[0][0] - self.alignment_values[1][0]
) * 1000 # um
self.shift_xy[1] += (
self.alignment_values[1][1] - self.alignment_values[0][1]
) * 1000 # um
print(
f"Base shift: "
f"x={self.shift_xy[0]:.2f} um, "
f"y={self.shift_xy[1]:.2f} um"
)
self.send_message("Please wait - moving to scan centre...")
self.movement_buttons_enabled(False, False)
self.gui.enable_submit_button(False)
target_angle = LAMNI_ALIGNMENT_ANGLES[k - 1] # 45...315 deg
if keep_shutter_open:
# Rotate via the halfway point between previous and
# current angle so the sample is seen sweeping
# smoothly through the intermediate position in live
# view. Only worth the extra move when the shutter
# is open and someone is actually watching -- with
# the shutter closed, jump straight to target_angle
# below.
prev_angle = LAMNI_ALIGNMENT_ANGLES[k - 2]
approach_angle = prev_angle + (target_angle - prev_angle) / 2
self._disable_rt_feedback()
self.tomo_rotate(approach_angle)
self.scans.lamni_move_to_scan_center(
shift_x=self.shift_xy[0] / 1000,
shift_y=self.shift_xy[1] / 1000,
angle=float(self.get_tomo_angle()),
).wait()
self._disable_rt_feedback()
self.tomo_rotate(target_angle)
self.scans.lamni_move_to_scan_center(
shift_x=self.shift_xy[0] / 1000,
shift_y=self.shift_xy[1] / 1000,
angle=float(self.get_tomo_angle()),
).wait()
time.sleep(1)
self.scans.lamni_move_to_scan_center(
shift_x=self.shift_xy[0] / 1000,
shift_y=self.shift_xy[1] / 1000,
angle=float(self.get_tomo_angle()),
).wait()
dev.omny_xray_gui.angle.set(self.get_tomo_angle())
self.update_frame(keep_shutter_open)
self.send_message(f"Submit sample centre ({target_angle} deg).")
self.gui.enable_submit_button(True)
self.movement_buttons_enabled(True, False)
self.update_fov(k)
dev.omny_xray_gui.angle.set(self.get_tomo_angle())
self.update_frame(keep_shutter_open)
self.send_message("Submit sample centre and FOV (0 deg).")
self.gui.enable_submit_button(True)
self.movement_buttons_enabled(True, False)
self.update_fov(k)
# --- k=9: all angles collected - finish ------------------
elif k == 9:
self.send_message("All angles collected - computing fit...")
self.gui.enable_submit_button(False)
self.movement_buttons_enabled(False, False)
self.update_fov(k)
self.gui.hide_crosshair()
break
# --- k=2..8: sample at 45, 90, ..., 315 deg -------------
elif 1 < k <= 8:
self.send_message("Please wait - rotating...")
self.gui.enable_submit_button(False)
self.movement_buttons_enabled(False, False)
k += 1
dev.omny_xray_gui.step.set(k)
target_angle = LAMNI_ALIGNMENT_ANGLES[k - 1] # 45...315 deg
if keep_shutter_open:
# Rotate via the halfway point between previous and
# current angle so the sample is seen sweeping
# smoothly through the intermediate position in live
# view. Only worth the extra move when the shutter
# is open and someone is actually watching -- with
# the shutter closed, jump straight to target_angle
# below.
prev_angle = LAMNI_ALIGNMENT_ANGLES[k - 2]
approach_angle = prev_angle + (target_angle - prev_angle) / 2
self._disable_rt_feedback()
self.tomo_rotate(approach_angle)
self.scans.lamni_move_to_scan_center(
shift_x=self.shift_xy[0] / 1000,
shift_y=self.shift_xy[1] / 1000,
angle=float(self.get_tomo_angle()),
).wait()
self._disable_rt_feedback()
self.tomo_rotate(target_angle)
self.scans.lamni_move_to_scan_center(
shift_x=self.shift_xy[0] / 1000,
shift_y=self.shift_xy[1] / 1000,
angle=float(self.get_tomo_angle()),
).wait()
# Handle live motor movement buttons.
# Only active during k=0 (FZP) and k=1 (sample at 0 deg) – both
# x and y movements accumulate into shift_xy and are applied via
# lamni_move_to_scan_center so the laminography coordinate
# transformation is correctly applied. This matches the old
# alignment.py exactly.
if k < 2:
_mvx = dev.omny_xray_gui.mvx.get()
_mvy = dev.omny_xray_gui.mvy.get()
if _mvx != 0 or _mvy != 0:
self.shift_xy[0] += _mvx
self.shift_xy[1] += _mvy
self.scans.lamni_move_to_scan_center(
shift_x=self.shift_xy[0] / 1000,
shift_y=self.shift_xy[1] / 1000,
angle=float(self.get_tomo_angle()),
).wait()
print(
f"Current centre: "
f"horizontal={self.shift_xy[0]:.2f} um, "
f"vertical={self.shift_xy[1]:.2f} um"
)
dev.omny_xray_gui.mvx.set(0)
dev.omny_xray_gui.mvy.set(0)
self.update_frame(keep_shutter_open)
dev.omny_xray_gui.angle.set(self.get_tomo_angle())
self.update_frame(keep_shutter_open)
self.send_message(f"Submit sample centre ({target_angle} deg).")
self.gui.enable_submit_button(True)
self.movement_buttons_enabled(True, False)
self.update_fov(k)
time.sleep(0.2)
# --- k=9: all angles collected - finish ------------------
elif k == 9:
self.send_message("All angles collected - computing fit...")
self.gui.enable_submit_button(False)
self.movement_buttons_enabled(False, False)
self.update_fov(k)
self.gui.hide_crosshair()
break
k += 1
dev.omny_xray_gui.step.set(k)
# Handle live motor movement buttons.
# Only active during k=0 (FZP) and k=1 (sample at 0 deg) – both
# x and y movements accumulate into shift_xy and are applied via
# lamni_move_to_scan_center so the laminography coordinate
# transformation is correctly applied. This matches the old
# alignment.py exactly.
if k < 2:
_mvx = dev.omny_xray_gui.mvx.get()
_mvy = dev.omny_xray_gui.mvy.get()
if _mvx != 0 or _mvy != 0:
self.shift_xy[0] += _mvx
self.shift_xy[1] += _mvy
self.scans.lamni_move_to_scan_center(
shift_x=self.shift_xy[0] / 1000,
shift_y=self.shift_xy[1] / 1000,
angle=float(self.get_tomo_angle()),
).wait()
print(
f"Current centre: "
f"horizontal={self.shift_xy[0]:.2f} um, "
f"vertical={self.shift_xy[1]:.2f} um"
)
dev.omny_xray_gui.mvx.set(0)
dev.omny_xray_gui.mvy.set(0)
self.update_frame(keep_shutter_open)
time.sleep(0.2)
finally:
try:
self.gui.enable_ellipse_roi(False)
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Failed to disable the XRayEye ellipse ROI: {exc}")
# ------------------------------------------------------------------
# All submits received - compute offsets and push to GUI fit tab
@@ -804,15 +820,20 @@ class XrayEyeAlign:
self.update_frame(keep_shutter_open)
@contextmanager
def _ellipse_roi_enabled(self):
def _ellipse_roi_enabled(self, lock_angle: float | None = None):
"""Offer the rotatable elliptical ROI in the GUI for the enclosed clicks.
Only for the extended-sample rotation-centre calibration, and only entered after
the FZP click (step 0), so the FZP and isolated-particle clicks keep the
rectangle/circle tools. Always switched off again (which also removes the
ellipse and releases its angle lock).
Used by the extended/smear rotation-centre calibrations, always entered
after the FZP click (step 0), so the FZP and isolated-particle clicks keep
the rectangle/circle tools. Always switched off again (which also removes
the ellipse and releases its angle lock).
Args:
lock_angle: if given, the ellipse rotation is fixed to this angle (deg).
"""
self.gui.enable_ellipse_roi(True)
if lock_angle is not None:
self.gui.lock_roi_angle(lock_angle)
try:
yield
finally:
@@ -1109,7 +1130,7 @@ class XrayEyeAlign:
self.gui.set_crosshair_position(fzp_x / self.pixel_calibration, fzp_y / self.pixel_calibration)
self.gui.show_crosshair()
with self._ellipse_roi_enabled():
with self._ellipse_roi_enabled(lock_angle=self.ROTATION_CENTER_ELLIPSE_ANGLE):
iteration = 0
cumulative_shift_x, cumulative_shift_y = 0.0, 0.0
while True:
@@ -1174,7 +1195,7 @@ class XrayEyeAlign:
if apply:
if self.lamni.OMNYTools.yesno(
f"Update lsamx/lsamy center user parameters to "
f"({new_lsamx:.4f}, {new_lsamy:.4f})?",
f"({new_lsamx:.4f}, {new_lsamy:.4f})?{self._erase_alignment_warning()}",
"y",
):
dev.lsamx.update_user_parameter({"center": float(new_lsamx)})
@@ -1340,6 +1361,18 @@ class XrayEyeAlign:
f"interferometer rtx/rty now read ({rtx_after:.2f}, {rty_after:.2f}) um"
)
def _erase_alignment_warning(self) -> str:
"""Suffix for the "apply new rotation center?" prompt, naming the
stored alignment that _mark_center_found_and_invalidate_downstream()
would erase. Empty if there is none."""
existing = self.lamni._existing_alignment()
if not existing:
return ""
return (
f"\nWARNING: this will erase the existing {' and '.join(existing)} "
"for this sample."
)
def _mark_center_found_and_invalidate_downstream(self):
"""Record that the rotation centre was just (re-)established, and
invalidate any X-ray-eye/fine-alignment state calibrated around the
@@ -1483,7 +1516,7 @@ class XrayEyeAlign:
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:
with self._ellipse_roi_enabled():
with self._ellipse_roi_enabled(lock_angle=self.ROTATION_CENTER_ELLIPSE_ANGLE):
iteration = 0
cumulative_shift_x, cumulative_shift_y = 0.0, 0.0
while True:
@@ -1534,7 +1567,7 @@ class XrayEyeAlign:
if apply:
if self.lamni.OMNYTools.yesno(
f"Update lsamx/lsamy center user parameters to "
f"({new_lsamx:.4f}, {new_lsamy:.4f})?",
f"({new_lsamx:.4f}, {new_lsamy:.4f})?{self._erase_alignment_warning()}",
"y",
):
dev.lsamx.update_user_parameter({"center": float(new_lsamx)})
+4 -3
View File
@@ -301,9 +301,10 @@ class OMNY_XRayEye(RPCBase):
"""
Show/hide the elliptical ROI tool and its angle controls.
Hidden by default; the LamNI client enables it for the rotation-centre
calibration modes after the FZP centre has been submitted. Disabling removes
an existing ellipse and releases the angle lock.
Hidden by default; the LamNI client enables it after the FZP centre has been
submitted, for the extended/smear rotation-centre calibrations (angle locked)
and for the X-ray eye alignment (angle free). Disabling removes an existing
ellipse and releases the angle lock.
Args:
enable(bool): True to offer the ellipse tool.
@@ -1095,9 +1095,10 @@ class OMNY_XRayEye(BECWidget, QWidget):
def enable_ellipse_roi(self, enable: bool):
"""Show/hide the elliptical ROI tool and its angle controls.
Hidden by default; the LamNI client enables it for the rotation-centre
calibration modes after the FZP centre has been submitted. Disabling removes
an existing ellipse and releases the angle lock.
Hidden by default; the LamNI client enables it after the FZP centre has been
submitted, for the extended/smear rotation-centre calibrations (angle locked)
and for the X-ray eye alignment (angle free). Disabling removes an existing
ellipse and releases the angle lock.
Args:
enable(bool): True to offer the ellipse tool.
+13 -13
View File
@@ -21,19 +21,19 @@
# - bl_detectors
# - eiger
# eiger_1_5:
# description: Eiger 1.5M in-vacuum detector
# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M
# deviceConfig:
# detector_distance: 2200
# beam_center: [870, 1203]
# onFailure: raise
# enabled: True
# readoutPriority: async
# softwareTrigger: False
# deviceTags:
# - bl_detectors
# - eiger
eiger_1_5:
description: Eiger 1.5M in-vacuum detector
deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M
deviceConfig:
detector_distance: 2200
beam_center: [870, 1203]
onFailure: raise
enabled: True
readoutPriority: async
softwareTrigger: False
deviceTags:
- bl_detectors
- eiger
# eiger_9:
# description: Eiger 9M detector
@@ -962,6 +962,7 @@ fttrx1:
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES1-FTS1:TRX1
tolerance: 0.2
onFailure: retry
enabled: true
readoutPriority: baseline
@@ -1010,6 +1011,7 @@ fttrz:
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES1-FTS1:TRZ1
tolerance: 0.2
onFailure: retry
enabled: true
readoutPriority: baseline
@@ -1094,6 +1096,7 @@ dttrz:
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES1-DETT:TRZ1
tolerance: 0.2
onFailure: retry
enabled: true
readoutPriority: baseline
+16 -10
View File
@@ -20,10 +20,12 @@ leyex:
connectionTimeout: 20
userParameter:
in: 13.18
# flight tube positions used by leye_out()/leye_in() (draft, still commented there);
# fill in and uncomment once the fttrz positions are known
# fttrz_in:
# fttrz_out:
# flight tube / detector table positions used by leye_out() (*_in) and
# leye_in() (*_out)
fttrz_in: 1381.433
fttrz_out: 2000
dttrz_in: 12614.99
dttrz_out: 13263
deviceTags:
- ptycho_lamni
leyey:
@@ -66,7 +68,7 @@ loptx:
userParameter:
in: -0.8698
#out: -0.8665
out: -1.07
out: -0.8659
# 170 micron, 60 nm
fzp_diameter: 250 # microns
fzp_outermost_zone_width: 30 # nm
@@ -109,8 +111,8 @@ loptz:
axis_Id: D
host: mpc2680.psi.ch
limits:
- 0
- 0
- 70
- 83
port: 8081
sign: -1
enabled: true
@@ -118,6 +120,10 @@ loptz:
readOnly: false
readoutPriority: baseline
connectionTimeout: 20
userParameter:
# OSA collides with the sample holder when loptz + losaz reaches this
# value (measured 2026-10-14: loptz 77.8676, losaz 10.0029)
collision_offset: 87.87
deviceTags:
- ptycho_lamni
lsamrot:
@@ -201,7 +207,7 @@ losax:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
in: -1.582
in: -1.6437
deviceTags:
- ptycho_lamni
losay:
@@ -221,7 +227,7 @@ losay:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
in: -0.131
in: -0.2109
out: 3.8
deviceTags:
- ptycho_lamni
@@ -242,7 +248,7 @@ losaz:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
in: -1
in: 6.8288
out: -3
deviceTags:
- ptycho_lamni
+5
View File
@@ -26,6 +26,7 @@ from bec_server.scan_server.errors import ScanAbortion
from bec_server.scan_server.scans.scan_base import ScanBase, ScanType
from bec_server.scan_server.scans.scan_modifier import scan_hook
from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import TRIGGERSOURCE
from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max
from csaxs_bec.scans.lamni_components import LamNIComponents
@@ -206,6 +207,8 @@ class LamniFermatScan(ScanBase):
devices, e.g. devices that have a short timeout.
The pre-scan logic is typically implemented on the device itself.
"""
# Prepare DDG1 to be triggered externally by the RT controller
self.dev.ddg1.set_trigger(TRIGGERSOURCE.EXT_RISING_EDGE.value)
self.actions.pre_scan_all_devices()
@scan_hook
@@ -236,6 +239,7 @@ class LamniFermatScan(ScanBase):
Post-scan steps to be executed after the main scan logic.
"""
self.actions.complete_all_devices()
self.dev.ddg1.set_trigger(TRIGGERSOURCE.SINGLE_SHOT.value)
@scan_hook
def unstage(self):
@@ -257,6 +261,7 @@ class LamniFermatScan(ScanBase):
This is a good place to implement any cleanup logic that needs to be executed in case of an exception,
such as returning the devices to a safe state or moving the motors back to their starting position.
"""
self.dev.ddg1.set_trigger(TRIGGERSOURCE.SINGLE_SHOT.value)
#######################################################
######### Helper methods for the scan logic ###########
+44 -4
View File
@@ -6,13 +6,50 @@ LamNI is an instrument for 3D ptychography via ptychographic X-ray computed lami
… a step-by-step guide for *beamline staff and expert users*.
### Quick guide
Only the commands, in the order they are used. Details are in the linked sections below.
**0. Sample change** ([details](lamni.md#sample-change-and-alignment))
- `lamni.leye_in()` moves the flight tube and detector table out and the X-ray eye in, opens slits sl1 and sl6 by 1 mm and reduces the filter transmission to 5 %
- mount the sample
**1. Find the rotation center** ([details](lamni.md#coarse-axis-alignment))
- `lamni.xrayeye_rotation_center_calibration_isolated()` for an isolated particle, or
- `lamni.xrayeye_rotation_center_calibration_extended()` for an extended/textured sample
- experimental: `lamni.xrayeye_rotation_center_calibration_smear_experimental()`
Applying a new center erases an existing X-ray eye alignment and fine-alignment correction. You are warned before that happens.
**2. X-ray eye alignment** ([details](lamni.md#x-ray-eye-alignment))
- `lamni.xrayeye_alignment_start()`, or `lamni.xrayeye_alignment_start(keep_shutter_open=True)` if the sample is hard to find between angles
- `lamni.xrayeye_update_frame()` to take a single new frame
If an alignment already exists, you are asked before it is erased.
**3. Alignment scan** ([details](lamni.md#fine-alignment))
- `lamni.tomo_parameters()` to set FOV, step size and counting time
- `lamni.tomo_alignment_scan()`. This moves the X-ray eye out and the optics in by itself (it calls `lamni.leye_out()`), so no manual step is needed before it.
- run `BEC_ptycho_align` in Matlab with the printed scan numbers
- `lamni.read_additional_correction('<file>')`
- optional second iteration: `lamni.read_additional_correction_2('<file>')`
**4. Laminography scan** ([details](lamni.md#test-projections-and-laminography-scan))
- `lamni.tomo_parameters()`
- test: `lamni.tomo_scan_projection(angle)` and `lamni.tomo_reconstruct()`
- `lamni.tomo_scan()`. This does not move the eye or the optics; it only checks that the eye is out and the optics are in, and asks before continuing otherwise.
### Sample change and alignment
The access to the sample region is blocked by the flight tube during measurement. To get the flight tube out of the way use
`lamni.leye_in()`.
Mount the new sample. The X-ray eye is already in, but the X-ray optics needs to be moved out of the beam path by
`lamni.loptics_out()`. Potentially a larger area needs to be illuminated to properly see the sample, which can be done by
`open slits`.
`lamni.loptics_out()`. `lamni.leye_in()` also opens slits sl1 and sl6 by 1 mm to illuminate a larger area, and `lamni.leye_out()` restores them. The rotation-center and X-ray eye alignment routines below move the FZP and optics as they need, so `loptics_out()` is only needed for looking at the sample by hand.
#### Coarse axis alignment
@@ -27,6 +64,8 @@ This opens the X-ray eye widget and walks through the calibration interactively.
- `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. Pass `keep_shutter_open=True` if it's hard to relocate the sample between steps, matching `xrayeye_alignment_start()` below.
- `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 the rotation centre. The correction is applied, a short verification sweep (0° → 45° → 0°) runs, and you're asked *"Alignment acceptable — stop here?"* — answer `n` to run again.
In `..._extended()` and `..._smear_experimental()` the X-ray eye GUI also offers an elliptical selection, with its angle fixed at -76°. The isolated-particle mode keeps the rectangle/circle selection only.
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(...)`.
**Experimental: continuous-rotation "smear" aid**
@@ -65,6 +104,8 @@ The BEC GUI is used to obtain a coarse sample alignment. Start the alignment pro
This opens the X-ray eye widget automatically. The procedure collects the sample centre position at 8 angles (0°–315° in 45° steps, full 360° rotation). At each angle the user clicks the sample centre in the image and presses **Submit**. After all 8 angles the data is sent to the **Fit** tab of the GUI where a sinusoidal fit runs automatically in both x and y directions. The fit parameters are loaded automatically at the end of the procedure.
For the sample clicks (not the FZP click) the elliptical selection is also available, with a freely adjustable angle.
With LamNI it can be difficult to relocate the sample between rotations. To keep the shutter open throughout, pass:
`lamni.xrayeye_alignment_start(keep_shutter_open=True)`
@@ -106,8 +147,7 @@ The base shift is rotated to the angle first, so the move itself is called with
If the drift between coarse stages and interferometer found at the start of the move is larger than 150 µm, it is considered unreliable and ignored; the move then relies on the second, interferometer-based iteration.
* If slits were opened during alignment, close the slits: `slits` to around 0.3
* `lamni.leye_out()` remove the X-ray eye and move the flight tube in
* `lamni.leye_out()` removes the X-ray eye, moves the optics and the flight tube in and restores slits sl1 and sl6 to their size before `leye_in()` (it asks first if they were changed by hand in between). `lamni.tomo_alignment_scan()` calls it by itself.
* *possibly check slit1, idgap*
#### Fine alignment
@@ -183,6 +183,11 @@ def test_leye_out_closes_the_fast_shutter(monkeypatch):
fsh = FakeShutter()
leyey = object()
lsamrot = object()
fttrz = object()
dttrz = object()
def __contains__(self, name):
return hasattr(self, name)
monkeypatch.setattr(optics_mixin_module, "dev", FakeDev(), raising=False)
monkeypatch.setattr(optics_mixin_module, "umv", lambda *a: None, raising=False)
@@ -50,6 +50,7 @@ def test_update_frame(bec_client_mock):
client = bec_client_mock
align = _make_align(client)
align.lamni.xeyegui = mock.MagicMock()
align.lamni.xeyegui._is_deleted.return_value = False # GUI open
dev_mock = mock.MagicMock()
dev_mock.cam_xeye.live_mode_enabled.get.return_value = False
@@ -70,6 +71,7 @@ def test_update_frame_keeps_shutter_open(bec_client_mock):
client = bec_client_mock
align = _make_align(client)
align.lamni.xeyegui = mock.MagicMock()
align.lamni.xeyegui._is_deleted.return_value = False # GUI open
dev_mock = mock.MagicMock()
dev_mock.cam_xeye.live_mode_enabled.get.return_value = True
@@ -161,6 +163,7 @@ def _make_calibration_align(client):
"""
align = _make_align(client)
align.lamni.xeyegui = mock.MagicMock()
align.lamni.xeyegui._is_deleted.return_value = False # GUI open
align.lamni.lfzp_in = mock.MagicMock()
align.lamni.loptics_out = mock.MagicMock()
align.lamni.losa_out = mock.MagicMock()
@@ -417,6 +420,69 @@ def test_find_rotation_center_extended_iterates_until_happy(bec_client_mock):
dev_mock.lsamy.update_user_parameter.assert_called_once()
def test_find_rotation_center_extended_locks_ellipse_angle(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"):
with mock.patch(f"{OMNY_GENERAL_TOOLS}.input", side_effect=["y", "y"]):
align.find_rotation_center(sample_type="extended")
gui = align.lamni.xeyegui
gui.lock_roi_angle.assert_called_once_with(-76.0)
assert gui.enable_ellipse_roi.call_args_list == [mock.call(True), mock.call(False)]
def test_find_rotation_center_isolated_offers_no_ellipse(bec_client_mock):
client = bec_client_mock
align, dev_mock = _make_calibration_align(client)
for k in (1, 2):
getattr(dev_mock.omny_xray_gui, f"xval_x_{k}").get.return_value = 100.0 * k
getattr(dev_mock.omny_xray_gui, f"yval_y_{k}").get.return_value = 100.0 * k
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"{OMNY_GENERAL_TOOLS}.input", return_value="y"):
align.find_rotation_center(sample_type="isolated")
gui = align.lamni.xeyegui
gui.enable_ellipse_roi.assert_not_called()
gui.lock_roi_angle.assert_not_called()
def test_align_offers_free_ellipse_after_fzp_click_and_removes_it_on_abort(bec_client_mock):
"""The 8-angle alignment enables the ellipse (angle not locked) only after
the FZP click, and switches it off again even when the run is aborted."""
client = bec_client_mock
align, dev_mock = _make_calibration_align(client)
gui = align.lamni.xeyegui
sent = []
align.send_message = sent.append
for k in (0, 1):
for sig in ("xval_x", "yval_y", "width_x", "width_y"):
getattr(dev_mock.omny_xray_gui, f"{sig}_{k}").get.return_value = 10.0
# abort while handling the first sample click (k=1)
align.scans.lamni_move_to_scan_center.side_effect = KeyboardInterrupt
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 pytest.raises(KeyboardInterrupt):
align._align_impl()
assert gui.enable_ellipse_roi.call_args_list == [mock.call(True), mock.call(False)]
gui.lock_roi_angle.assert_not_called()
# not yet offered while the FZP centre was requested
assert sent.index("Submit centre of FZP.") < sent.index(
"Find the sample and submit its centre."
)
# ----------------------------------------------------------------------
# EXPERIMENTAL: rotation-center smear mode (_smear_sweep)
# ----------------------------------------------------------------------
@@ -95,3 +95,28 @@ def test_shift_and_center_are_mm_and_positions_are_um():
np.testing.assert_allclose(shifted_x - base, [[10.0, 0.0]] * len(base), atol=1e-9)
np.testing.assert_allclose(shifted_y - base, [[0.0, 5.0]] * len(base), atol=1e-9)
np.testing.assert_allclose(centered_x - base, [[10.0, 0.0]] * len(base), atol=1e-9)
def test_ddg1_armed_in_pre_scan_and_reset_after_scan():
"""ddg1 must be switched to external triggering before the devices are pre-scanned,
and back to single shot after the scan and on exceptions."""
from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import TRIGGERSOURCE
scan = mock.MagicMock()
manager = mock.MagicMock()
manager.attach_mock(scan.dev.ddg1.set_trigger, "set_trigger")
manager.attach_mock(scan.actions.pre_scan_all_devices, "pre_scan_all_devices")
LamniFermatScan.pre_scan(scan)
assert manager.mock_calls == [
mock.call.set_trigger(TRIGGERSOURCE.EXT_RISING_EDGE.value),
mock.call.pre_scan_all_devices(),
]
scan.dev.ddg1.set_trigger.reset_mock()
LamniFermatScan.post_scan(scan)
scan.dev.ddg1.set_trigger.assert_called_once_with(TRIGGERSOURCE.SINGLE_SHOT.value)
scan.dev.ddg1.set_trigger.reset_mock()
LamniFermatScan.on_exception(scan, RuntimeError("boom"))
scan.dev.ddg1.set_trigger.assert_called_once_with(TRIGGERSOURCE.SINGLE_SHOT.value)