Fix/fixes lamni beamtime 1 #331
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -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)})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ###########
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user