diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/docs/FZP 30nm Tomas Abe 2026.pdf b/csaxs_bec/bec_ipython_client/plugins/LamNI/docs/FZP 30nm Tomas Abe 2026.pdf new file mode 100644 index 00000000..29231ef0 Binary files /dev/null and b/csaxs_bec/bec_ipython_client/plugins/LamNI/docs/FZP 30nm Tomas Abe 2026.pdf differ diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/docs/central_stops.pdf b/csaxs_bec/bec_ipython_client/plugins/LamNI/docs/central_stops.pdf new file mode 100644 index 00000000..e033747e Binary files /dev/null and b/csaxs_bec/bec_ipython_client/plugins/LamNI/docs/central_stops.pdf differ diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index b7000253..0e9d86a8 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -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 diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py index f829f225..bfdd8e91 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py @@ -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/ diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py index fb097d43..1c4a8ca7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py @@ -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") diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py index daa62265..25978ecf 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py @@ -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)}) diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 48d0b7d0..011e9f46 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -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. diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 03b32abc..0f355946 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -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. diff --git a/csaxs_bec/device_configs/bl_detectors.yaml b/csaxs_bec/device_configs/bl_detectors.yaml index 1df2d384..9cf6cc9c 100644 --- a/csaxs_bec/device_configs/bl_detectors.yaml +++ b/csaxs_bec/device_configs/bl_detectors.yaml @@ -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 diff --git a/csaxs_bec/device_configs/bl_endstation.yaml b/csaxs_bec/device_configs/bl_endstation.yaml index 51e78567..9e8c6419 100644 --- a/csaxs_bec/device_configs/bl_endstation.yaml +++ b/csaxs_bec/device_configs/bl_endstation.yaml @@ -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 diff --git a/csaxs_bec/device_configs/ptycho_lamni.yaml b/csaxs_bec/device_configs/ptycho_lamni.yaml index 7f097f2b..8c0169ec 100644 --- a/csaxs_bec/device_configs/ptycho_lamni.yaml +++ b/csaxs_bec/device_configs/ptycho_lamni.yaml @@ -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 diff --git a/csaxs_bec/scans/lamni_fermat_scan.py b/csaxs_bec/scans/lamni_fermat_scan.py index b95969ca..743d8f39 100644 --- a/csaxs_bec/scans/lamni_fermat_scan.py +++ b/csaxs_bec/scans/lamni_fermat_scan.py @@ -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 ########### diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index 5c911f46..38559001 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -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('')` +- optional second iteration: `lamni.read_additional_correction_2('')` + +**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 diff --git a/tests/tests_bec_ipython_client/test_lamni_golive_fixes.py b/tests/tests_bec_ipython_client/test_lamni_golive_fixes.py index 44c5064e..936a0dca 100644 --- a/tests/tests_bec_ipython_client/test_lamni_golive_fixes.py +++ b/tests/tests_bec_ipython_client/test_lamni_golive_fixes.py @@ -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) diff --git a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py index 1786544e..725088f5 100644 --- a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py +++ b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py @@ -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) # ---------------------------------------------------------------------- diff --git a/tests/tests_scans/test_lamni_fermat_scan.py b/tests/tests_scans/test_lamni_fermat_scan.py index f2da0c09..cf4fd4c0 100644 --- a/tests/tests_scans/test_lamni_fermat_scan.py +++ b/tests/tests_scans/test_lamni_fermat_scan.py @@ -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)