diff --git a/AGENTS_mirko.md b/AGENTS_mirko.md index 36ad2608..074bf799 100644 --- a/AGENTS_mirko.md +++ b/AGENTS_mirko.md @@ -22,6 +22,12 @@ the repo when I ask for one. ## TODO +- 2026-10-15: reduce flOMNI scan overhead. Log analysis of the Oct 7–12 beamtime: ~3.4 s dead + time between projections plus ~30–50 ms per point; quick wins are the 1 s `status.done` + polling sleep in `scan_core`/`at_each_point` (also in lamni/omny fermat scans) and the + duplicate laser-tracker check. Findings, method and ranked ideas: + `csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/FLOMNI_SCAN_OVERHEAD.md`. + - 2026-09-21: port "always define `tomo_alignment_fit`" from Flomni to OMNY. Flomni is done (commit `5b688df`): `reset_tomo_alignment_fit()` writes zeros instead of deleting the global var, and `ensure_tomo_alignment_fit()` (called from `Flomni.__init__`) creates the zero fit diff --git a/bin/generate_simulated_configs.py b/bin/generate_simulated_configs.py index 632930ac..6277ea72 100644 --- a/bin/generate_simulated_configs.py +++ b/bin/generate_simulated_configs.py @@ -149,5 +149,5 @@ def transform(src_name: str, dst_dir: str, dst_name: str, setup: str) -> None: if __name__ == "__main__": - transform("ptycho_lamni.yaml", "simulated_omny", "simulated_lamni.yaml", "LamNI") + transform("ptycho_lamni.yaml", "simulated_omny", "simulated_bl_simulated_lamni.yaml", "LamNI") transform("ptycho_omny.yaml", "simulated_omny", "simulated_omny.yaml", "OMNY") diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 0e9d86a8..df8db921 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -22,6 +22,8 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools, PtychoReconstructor, TomoIDManager, + lamni_smallest_tile_position_count, + stitch_tile_offsets, ) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import ( AngleFileError, @@ -31,9 +33,15 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import ( from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_angle_file_mixin import ( TomoAngleFileMixin, ) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.idle_time import account_projection_gap from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import TomoQueueMixin +from csaxs_bec.scans.lamni_components import LamNIComponents -from .lamni_optics_mixin import LaMNIInitStages, LamNIOpticsMixin +from .lamni_optics_mixin import ( + LaMNIInitStages, + LamNIOpticsMixin, + compute_osa_remaining_space, +) from .x_ray_eye_align import XrayEyeAlign as XrayEyeAlignGUI logger = bec_logger.logger @@ -71,6 +79,7 @@ class _ProgressProxy: "estimated_finish_time": None, "heartbeat": None, "accumulated_idle_time": 0.0, + "recent_projection_gaps": [], } def __init__(self, client): @@ -190,12 +199,15 @@ class LamNI( "idgap": {"label": "Undulator gap", "unit": "mm"}, } - # Named-action registry for tomo-queue command jobs -- identical to - # flomni's plain "move" action, since it's beamline-wide, not - # flomni-specific hardware. flomni also has a "move_foptz_with_shift" - # action for its own FZP optics-Z stage; lamni has no equivalent - # hardware, so it isn't mirrored here. See - # TomoQueueMixin._queue_action_move() for the implementation. + # Named-action registry for tomo-queue command jobs. "move" is identical + # to flomni's plain move action (beamline-wide devices). + # "move_loptz_with_shift" is lamni's counterpart of flomni's + # move_foptz_with_shift, but sets loptz_shift_x/y (not manual_shift_x/y, + # which every queued tomo job snapshots and restores, and would thus + # overwrite). loptz is deliberately NOT in _TOMO_QUEUE_MOVE_DEVICES, so + # this is the only way to queue a loptz move and the new shift can't be + # forgotten. See TomoQueueMixin._queue_action_move() for "move". + # "set_keithley_current" is not a "move" because it carries its own sleep. _TOMO_QUEUE_ACTIONS = { "move": { "func": "_queue_action_move", @@ -203,8 +215,165 @@ class LamNI( "help": "Move device(s) to absolute position(s).", "params": {"positions": {"type": "device_positions"}}, }, + "move_loptz_with_shift": { + "func": "_queue_action_move_loptz_with_shift", + "idempotent": True, # absolute move + absolute param set, re-runs harmlessly + "help": ( + "Move loptz (FZP optics-Z) to an absolute target and set new " + "loptz_shift_x/loptz_shift_y values (replacing the current " + "ones) in the same step -- changing loptz shifts the aligned " + "scan center, so this is the only way to queue a loptz move." + ), + "params": { + "loptz_target": { + "type": "float", + "default": 77.0, + "min": 70.0, + "max": 83.0, + "unit": "mm", + "label": "loptz target (absolute)", + # Live ophyd limits are checked at add time and again at + # execution time; min/max above are only the static + # fallback if the live device can't be reached. + "device_limits_from": "loptz", + }, + # mm, non-rotating (added to lamni_fermat_scan shift_x/y) + "loptz_shift_x": { + "type": "float", + "default": 0.0, + "min": -1.0, + "max": 1.0, + "unit": "mm", + "label": "New loptz_shift_x", + }, + "loptz_shift_y": { + "type": "float", + "default": 0.0, + "min": -1.0, + "max": 1.0, + "unit": "mm", + "label": "New loptz_shift_y", + }, + }, + }, + "set_keithley_current": { + "func": "_queue_action_set_keithley_current", + "idempotent": True, # absolute level, re-runs harmlessly + "help": ( + "Set the Keithley source current, switching its output on if it is " + "off, then sleep." + ), + "params": { + "current_mA": { + "type": "float", + "default": 0.0, + "min": 0.0, + "max": 3.0, + "unit": "mA", + "label": "Current", + "device_limits_from": "keithley", + }, + "sleep_s": { + "type": "float", + "default": 0.0, + "min": 0.0, + "max": 86400.0, + "unit": "s", + "label": "Sleep afterwards", + }, + }, + }, + "sleep": { + "func": "_queue_action_sleep", + "idempotent": True, # re-running only waits again + "help": "Wait for a number of seconds.", + "params": { + "seconds": { + "type": "float", + "default": 60.0, + "min": 0.0, + "max": 86400.0, + "unit": "s", + "label": "Sleep", + }, + }, + }, } + def _queue_action_sleep(self, seconds: float) -> None: + """Wait, the queue stays on this job.""" + if seconds < 0: + raise ValueError(f"Queue sleep: seconds={seconds} is negative.") + print(f"Queue: sleeping {seconds} s.") + time.sleep(seconds) + + def _queue_action_set_keithley_current(self, current_mA: float, sleep_s: float = 0.0) -> None: + """Set the Keithley current and sleep. + + The move fails if the current isn't reached (e.g. voltage compliance), + which pauses the queue before the next job. Re-checks the live limits + at execution time. + """ + if "keithley" not in dev: + raise ValueError("Queue set_keithley_current: device 'keithley' is not loaded.") + lo, hi = dev.keithley.limits + if not (lo <= current_mA <= hi): + raise ValueError( + f"Queue set_keithley_current: current_mA={current_mA} is outside " + f"keithley's current limits ({lo}, {hi})." + ) + umv(dev.keithley, current_mA) + if sleep_s > 0: + self._queue_action_sleep(sleep_s) + + def _queue_action_move_loptz_with_shift( + self, loptz_target: float, loptz_shift_x: float, loptz_shift_y: float + ) -> None: + """Move loptz and set new loptz_shift_x/y values together. + + loptz_shift_x/loptz_shift_y (mm, non-rotating) are replaced outright + by the values given here, not adjusted relative to whatever they + were before. loptz changes the FZP-to-sample distance; the + interferometer mirrors aren't perfectly parallel to the stage + motion, so the aligned scan center shifts with it. See the + loptz_shift_x property for why this isn't manual_shift_x/y. + + loptz is movable with interferometer feedback running (the RT + controller no longer locks it read-only), so feedback is left as is. + + Re-checks loptz's live limits at execution time, and refuses the move + if the OSA at its *current* losaz position would collide with the + sample holder at the target (the OSA rides on loptz, so this is a + physical collision, not just a swap-in risk). + + FUTURE: loptz_shift_x/y could be proposed from a calibration of + shift vs. (loptz - loptz_shift_reference) instead of typed in. + """ + lo, hi = dev.loptz.limits + if not (lo <= loptz_target <= hi): + raise ValueError( + f"Queue move_loptz_with_shift: loptz_target={loptz_target} is " + f"outside loptz's current limits ({lo}, {hi})." + ) + collision_offset = self._get_user_param_optional("loptz", "collision_offset") + if collision_offset is None: + print( + "Warning: loptz has no 'collision_offset' userParameter -- " + "skipping the OSA collision check." + ) + else: + losaz_val = dev.losaz.readback.get() + clearance = compute_osa_remaining_space(loptz_target, losaz_val, collision_offset) + if clearance <= 0: + raise ValueError( + f"Queue move_loptz_with_shift: loptz_target={loptz_target} with " + f"losaz at {losaz_val:.3f} leaves {clearance:.2f} mm OSA " + "clearance (<=0 = collision with the sample holder)." + ) + umv(dev.loptz, loptz_target) + self.loptz_shift_x = loptz_shift_x + self.loptz_shift_y = loptz_shift_y + def _describe_tomo_job_line(self, job: dict) -> str: """lamni's tomo_queue_show() one-line summary: circfov instead of fov, no range= (lamni is 360-only). @@ -354,7 +523,7 @@ class LamNI( self.tomo_stitch_overlap = 0.2 self.golden_projections_at_0_deg_for_damage_estimation = 0 self.zero_deg_reference_at_each_subtomo = False - self.golden_ratio_bunch_size = 20 + self.golden_ratio_bunch_size = 100 self.frames_per_trigger = 1 self.lamni_piezo_range_x = 20 self.lamni_piezo_range_y = 20 @@ -544,7 +713,7 @@ class LamNI( if not self._confirm_sequence_override( "No rotation-center calibration has been recorded yet " "(xrayeye_rotation_center_calibration_isolated/extended/" - "smear_experimental()). X-ray-eye alignment is normally done " + "smear()). X-ray-eye alignment is normally done " "after finding the rotation centre.", force, ): @@ -586,6 +755,7 @@ class LamNI( f"Manual shifts were reset to zero after X-ray eye alignment " f"(previous values: x={mx:.3f}, y={my:.3f})." ) + self.reset_loptz_shift() def xrayeye_update_frame(self, keep_shutter_open: bool = False): """Capture a single fresh X-ray eye frame without running full alignment. @@ -702,14 +872,14 @@ class LamNI( logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {gui_exc}") raise exc - def xrayeye_rotation_center_calibration_smear_experimental( + def xrayeye_rotation_center_calibration_smear( self, keep_shutter_open: bool = False, sweep_deg: float = 360.0, projection: str = "min", opaque_level: float = 0.25, ): - """EXPERIMENTAL: extended-sample rotation-center calibration aided by + """Extended-sample rotation-center calibration aided by a continuous-rotation min- or max-projection ("star-trail") composite image, instead of a single instant of live rotation. @@ -741,7 +911,7 @@ class LamNI( """ aligner = XrayEyeAlignGUI(self.client, self) try: - return aligner.find_rotation_center_smear_experimental( + return aligner.find_rotation_center_smear( keep_shutter_open=keep_shutter_open, sweep_deg=sweep_deg, projection=projection, @@ -904,6 +1074,59 @@ class LamNI( def manual_shift_y(self, val: float): self.client.set_global_var("manual_shift_y", val) + # loptz_shift_x/y (mm, non-rotating): compensates the scan-center drift + # caused by moving loptz after alignment (interferometer mirrors not + # perfectly parallel to the loptz motion). Kept separate from + # manual_shift_x/y on purpose: NOT in _TOMO_SCAN_PARAM_NAMES, so queued + # tomo jobs don't snapshot/restore it and a queued loptz move's shift + # survives the following scan jobs. Zeroed at X-ray eye alignment, where + # the current loptz is recorded as loptz_shift_reference. + @property + def loptz_shift_x(self): + val = self.client.get_global_var("loptz_shift_x") + if val is None: + return 0.0 + return val + + @loptz_shift_x.setter + def loptz_shift_x(self, val: float): + self.client.set_global_var("loptz_shift_x", val) + + @property + def loptz_shift_y(self): + val = self.client.get_global_var("loptz_shift_y") + if val is None: + return 0.0 + return val + + @loptz_shift_y.setter + def loptz_shift_y(self, val: float): + self.client.set_global_var("loptz_shift_y", val) + + @property + def loptz_shift_reference(self): + """loptz position at which loptz_shift_x/y were last zeroed, or None.""" + return self.client.get_global_var("loptz_shift_reference") + + @loptz_shift_reference.setter + def loptz_shift_reference(self, val): + self.client.set_global_var("loptz_shift_reference", val) + + def reset_loptz_shift(self): + """Zero loptz_shift_x/y and record the current loptz as reference.""" + lx, ly = self.loptz_shift_x, self.loptz_shift_y + self.loptz_shift_x = 0.0 + self.loptz_shift_y = 0.0 + try: + self.loptz_shift_reference = float(dev.loptz.readback.get()) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Could not read loptz for loptz_shift_reference: {exc}") + self.loptz_shift_reference = None + if lx != 0.0 or ly != 0.0: + self.OMNYTools.printgreen( + f"loptz shifts were reset to zero (previous values: x={lx} mm, y={ly} mm)." + ) + @property def lamni_piezo_range_x(self): val = self.client.get_global_var("lamni_piezo_range_x") @@ -1037,13 +1260,13 @@ class LamNI( def golden_ratio_bunch_size(self): val = self.client.get_global_var("golden_ratio_bunch_size") if val is None: - return 20 + return 100 return val @golden_ratio_bunch_size.setter def golden_ratio_bunch_size(self, val: int): - if val < 20: - raise ValueError("golden_ratio_bunch_size must be at least 20.") + if not 30 <= val <= 200: + raise ValueError("golden_ratio_bunch_size must be between 30 and 200.") self.client.set_global_var("golden_ratio_bunch_size", val) @property @@ -1410,14 +1633,24 @@ class LamNI( additional_correction = self.compute_additional_correction(angle) additional_correction_2 = self.compute_additional_correction_2(angle) correction_xeye_mu = self.lamni_compute_additional_correction_xeye_mu(angle) - print(f"Constant shift: x={self.manual_shift_x}, y={self.manual_shift_y}") + # base shift (center finding / first X-ray eye click), rotated to this + # angle by lamni_fermat_scan via center_x/center_y + base_x, base_y = LamNIComponents.lamni_compute_scan_center( + self.tomo_fovx_offset, self.tomo_fovy_offset, angle + ) + print( + f"Base shift: x={self.tomo_fovx_offset:.6f} mm, y={self.tomo_fovy_offset:.6f} mm" + f" at 0 deg -> x={base_x:.6f} mm, y={base_y:.6f} mm @ angle={angle}" + ) + print(f"Constant shift: x={self.manual_shift_x} mm, y={self.manual_shift_y} mm") + print(f"loptz shift: x={self.loptz_shift_x} mm, y={self.loptz_shift_y} mm") 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): + for stitch_x in stitch_tile_offsets(self.lamni_stitch_x): + for stitch_y in stitch_tile_offsets(self.lamni_stitch_y): self._current_scan_list.append(bec.queue.next_scan_number) log_message = ( f"{str(datetime.datetime.now())}: LamNI scan projection at angle {angle}," @@ -1435,12 +1668,14 @@ class LamNI( center_y=self.tomo_fovy_offset, shift_x=( self.manual_shift_x + + self.loptz_shift_x + correction_xeye_mu[0] - additional_correction[0] - additional_correction_2[0] ), shift_y=( self.manual_shift_y + + self.loptz_shift_y + correction_xeye_mu[1] - additional_correction[1] - additional_correction_2[1] @@ -1471,46 +1706,70 @@ class LamNI( if _reconstruct: self.tomo_reconstruct() + @staticmethod + def alignment_scan_angles(number_of_projections: int = 13) -> list[float]: + """Angles of tomo_alignment_scan(): number_of_projections equally + spaced angles from 0 to 360 inclusive (default 13, i.e. every 30 + degrees). 360 is the same orientation as 0; it is measured anyway as + a last data point confirming the sample did not drift during the run.""" + return list(np.linspace(0, 360, num=number_of_projections)) + def write_alignment_scan_numbers( - self, first_scan: int, scans: list[int] | None = None + self, + first_scan: int, + scans: list[int] | None = None, + angles: list[float] | None = None, ) -> None: """Write the scan-number/angle/offset log consumed by BEC_ptycho_align. - ``scans`` (optional): the actual scan number of each alignment point, - used to rewrite the file after the scan when a retried angle consumed - extra scan numbers. Defaults to the contiguous ``first_scan + k`` - prediction written before the scan starts. + One scan number per alignment angle: the first scan of that angle's + stitch series (with stitching, each projection is + (lamni_stitch_x+1)*(lamni_stitch_y+1) scans). - Mirrors Flomni.write_alignment_scan_numbers() exactly (same 4-line - format), adapted to lamni's own alignment-scan angle set (12 points - across the full 360 degrees, see tomo_alignment_scan()) and its own - x-ray-eye-fit offset source (lamni_compute_additional_correction_xeye_mu() - instead of get_alignment_offset()). Assumes exactly one scan per - alignment point (no stitching) -- same assumption flomni's own - version makes. + ``scans`` (optional): the actual first scan number of the angles + acquired so far (possibly fewer than all angles), used to rewrite the + file during/after the scan when a retried angle consumed extra scan + numbers. Angles not yet acquired are predicted from the last known + one, stepping by the number of stitch tiles per projection. Without + ``scans`` the whole list is predicted from ``first_scan``. + + ``angles`` (optional): the alignment angles; defaults to + alignment_scan_angles(). + + File format, as read by BEC_ptycho_align (MATLAB): + line 1 scans first scan number per angle + line 2 theta angle [deg] + line 3 scan_xpos X-ray-eye x correction at that angle [um] + line 4 scan_ypos X-ray-eye y correction at that angle [um] + Lines 3/4 are only used by its sinusoid branch (lamni_fit = false); + the LaMNI ellipse fit uses just the clicks, scans and theta. + lamni_compute_additional_correction_xeye_mu() returns mm, hence *1000. """ - angles = list(np.linspace(0, 360, num=12, endpoint=False)) - if scans is None: - scans = [first_scan + k for k in range(len(angles))] + if angles is None: + angles = self.alignment_scan_angles() + scans_per_angle = (self.lamni_stitch_x + 1) * (self.lamni_stitch_y + 1) + scans = list(scans) if scans else [first_scan] + while len(scans) < len(angles): + scans.append(scans[-1] + scans_per_angle) - x_vals = [] + x_um, y_um = [], [] for angle in angles: - x, _y = self.lamni_compute_additional_correction_xeye_mu(angle, verbose=False) - x_vals.append(x) - - zeros = [0] * len(angles) + x, y = self.lamni_compute_additional_correction_xeye_mu(angle, verbose=False) + x_um.append(x * 1000) + y_um.append(y * 1000) file = os.path.expanduser("~/data/raw/logs/ptychotomoalign_scannum.txt") os.makedirs(os.path.dirname(file), exist_ok=True) with open(file, "w") as f: f.write(" ".join(map(str, scans)) + "\n") f.write(" ".join(map(str, angles)) + "\n") - f.write(" ".join(f"{x:.2f}" for x in x_vals) + "\n") - f.write(" ".join(map(str, x_vals)) + "\n") + f.write(" ".join(f"{x:.4f}" for x in x_um) + "\n") + f.write(" ".join(f"{y:.4f}" for y in y_um) + "\n") - def tomo_alignment_scan(self, force: bool = False) -> None: + def tomo_alignment_scan(self, number_of_projections: int = 13, force: bool = False) -> None: """Perform a laminogram alignment scan: a quick ptychography scan at - 12 angles evenly spaced across the full 360 degrees, using whatever + number_of_projections equally spaced angles from 0 to 360 inclusive + (default 13, every 30 degrees; see alignment_scan_angles()), using whatever tomo_parameters() are currently set (FOV/step/counting time -- tomo_type and requested projection count are ignored, same as Flomni.tomo_alignment_scan()). Collects all scan numbers acquired @@ -1521,8 +1780,16 @@ class LamNI( scan numbers. Args: + number_of_projections: number of angles, 0 and 360 included + (at least 2). force: skip the X-ray-eye-alignment check below without prompting. """ + if int(number_of_projections) != number_of_projections or number_of_projections < 2: + raise ValueError( + f"number_of_projections must be an integer >= 2, got {number_of_projections}." + ) + number_of_projections = int(number_of_projections) + if self.client.get_global_var("tomo_fit_xray_eye") is None: if not self._confirm_sequence_override( "No X-ray-eye alignment fit is loaded (or it was invalidated " @@ -1536,6 +1803,11 @@ class LamNI( bec = builtins.__dict__.get("bec") dev = builtins.__dict__.get("dev") + # leye_out() retracts the filters (csaxs.fil_trans(1)), so check only + # afterwards -- checking first warned about filters it was about to + # remove anyway. + self.leye_out() + all_out, offending = filters_out_of_beam(dev) if not all_out: warning = f"Not all filters are out of the beam: {', '.join(offending)}." @@ -1543,11 +1815,9 @@ class LamNI( print("Aborting alignment scan.") return - self.leye_out() + angles = self.alignment_scan_angles(number_of_projections) + self.write_alignment_scan_numbers(bec.queue.next_scan_number, angles=angles) - self.write_alignment_scan_numbers(bec.queue.next_scan_number) - - angles = list(np.linspace(0, 360, num=12, endpoint=False)) alignment_scan_numbers = [] self.alignment_scan_progress.reset() @@ -1580,14 +1850,15 @@ class LamNI( first_scan_per_angle.append(start_scan_number) alignment_scan_numbers.extend(range(start_scan_number, end_scan_number)) + # Keep the file current after every angle, so an interrupted run + # still leaves correct first-of-stitch-series numbers behind. + self.write_alignment_scan_numbers( + first_scan_per_angle[0], scans=first_scan_per_angle, angles=angles + ) + self.alignment_scan_progress.update(angle_index=idx + 1, angle=angle) self._lamnigui_update_alignment_progress() - # The file written up front assumes first_scan + k per angle; a retried - # angle consumes extra scan numbers, so rewrite it with what was - # actually acquired. - self.write_alignment_scan_numbers(first_scan_per_angle[0], scans=first_scan_per_angle) - umv(dev.lsamrot, 0) self.OMNYTools.printgreenbold( "\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit" @@ -1747,6 +2018,7 @@ class LamNI( "estimated_finish_time": None, "heartbeat": None, "accumulated_idle_time": 0.0, + "recent_projection_gaps": [], } ) self._lamnigui_update_progress() @@ -1916,36 +2188,14 @@ class LamNI( ): if self._min_scan_angle() <= angle < self._max_scan_angle() + 0.05: now = datetime.datetime.now() - prev_heartbeat_str = self.progress.get("heartbeat") - if prev_heartbeat_str is not None: - gap = (now - datetime.datetime.fromisoformat(prev_heartbeat_str)).total_seconds() - # Normal cadence between consecutive projections is roughly the - # acquisition time (times the number of stitch tiles per - # projection) plus motor/readout overhead. A gap well beyond - # that means something interrupted the scan in between - # (beamline-down interlock pause, a crash + manual restart, - # ...) -- attribute the excess to idle time so it doesn't drag - # down the apparent scan rate used for the ETA above. The - # 5x/60s margins are a heuristic, not a precise timing model -- - # tune if it over/under-triggers in practice. - n_tiles = (2 * self.lamni_stitch_x + 1) * (2 * self.lamni_stitch_y + 1) - normal_cadence = max( - 60.0, - 5 - * (self.tomo_countingtime if exp_time is None else exp_time) - * (self.frames_per_trigger if frames_per_trigger is None else frames_per_trigger) - * n_tiles, + # A gap well beyond this scan's measured typical gap (beamline-down + # pause, a crash + manual restart, ...) is excluded from the ETA. + idle = account_projection_gap(self.progress, now) + if idle > 0: + print( + "Detected a long gap since the last projection" + f" -- excluding {self._format_duration(idle)} from the ETA estimate." ) - if gap > normal_cadence: - idle = gap - normal_cadence - self.progress["accumulated_idle_time"] = ( - self.progress.get("accumulated_idle_time", 0.0) + idle - ) - print( - f"Detected a {self._format_duration(gap)} gap since the last projection" - f" -- excluding {self._format_duration(idle)} from the ETA estimate." - ) - self.progress["heartbeat"] = now.isoformat() print(f"Starting LamNI scan for angle {angle} in subtomo {subtomo_number}") self._print_progress() @@ -2383,49 +2633,32 @@ class LamNI( # ------------------------------------------------------------------ def _expected_fermat_position_count(self) -> int: - """Predict the number of Fermat-spiral scan positions the current - settings would produce per projection tile, using the exact same - algorithm LamniFermatScan runs at scan time - (LamniFermatScan.get_lamni_fermat_spiral_pos()) -- so a - too-few-points configuration (which the scan server would only - catch by aborting with ScanAbortion once the scan actually starts) - can be caught here instead, while just looking at tomo_parameters(). + """Predict the number of Fermat-spiral scan positions of the stitch + tile with the fewest points, using the exact same algorithm + LamniFermatScan runs at scan time (see + lamni_smallest_tile_position_count()) -- so a too-few-points + configuration (which the scan server would only catch by aborting + with ScanAbortion once the scan actually starts) can be caught here + instead. With a circular FOV the corner tiles can hold far fewer + points than the center tile; any tile below the minimum aborts the + whole projection. - center_x/center_y/shift_x/shift_y are deliberately left at their - defaults (0): they only shift the final absolute position, applied - after the FOV/circular-FOV keep-or-discard check, so they can never - affect the resulting count. Computed for the center stitch tile - (stitch_x=stitch_y=0) at angle=0, as a representative estimate -- - the real per-projection count can vary a bit with angle/stitch - tile, since the circular FOV crop (tomo_circfov) is checked against - the rotated stage position. - - The real scan also draws a fresh random sub-step offset on every - call (see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a - further small amount of per-projection count variation on top of - the angle/stitch dependence above. A fixed seed is used here only so - that this estimate itself is reproducible from one call to the next. + center_x/center_y/shift_x/shift_y don't enter: they only shift the + final absolute position, applied after the FOV/circular-FOV + keep-or-discard check. The real scan also draws a fresh random + sub-step offset on every call, so the actual count can differ by a + few points per angle. """ - from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan - - positions = LamniFermatScan.get_lamni_fermat_spiral_pos( - -abs(self.lamni_piezo_range_x / 2), - abs(self.lamni_piezo_range_x / 2), - -abs(self.lamni_piezo_range_y / 2), - abs(self.lamni_piezo_range_y / 2), - step=self.tomo_shellstep, - spiral_type=0, - center=False, - angle=0.0, - stitch_x=0, - stitch_y=0, - stitch_overlap=self.tomo_stitch_overlap, - fovx=self.lamni_piezo_range_x, - fovy=self.lamni_piezo_range_y, - fov_circular=self.tomo_circfov, - seed=0, + count, _tile = lamni_smallest_tile_position_count( + self.lamni_piezo_range_x, + self.lamni_piezo_range_y, + self.tomo_shellstep, + self.lamni_stitch_x, + self.lamni_stitch_y, + self.tomo_stitch_overlap, + self.tomo_circfov, ) - return len(positions) + return count @staticmethod def _fermat_min_positions() -> int: @@ -2435,6 +2668,50 @@ class LamNI( return LamniFermatScan.MIN_POSITIONS + def _print_fermat_position_estimate(self) -> bool: + """Print the estimated Fermat point count (smallest stitch tile); + True if it is below the scan's minimum, i.e. the scan would abort.""" + expected_positions = self._expected_fermat_position_count() + min_positions = self._fermat_min_positions() + label = "Estimated Fermat scan points" + if self.lamni_stitch_x or self.lamni_stitch_y: + label += " (smallest stitch tile)" + if expected_positions < min_positions: + print( + f"{label}: {expected_positions} (WARNING: below the minimum of" + f" {min_positions} -- the scan will abort when run)" + ) + return True + print(f"{label}: {expected_positions}") + return False + + def _prompt_scan_geometry(self): + """tomo_parameters_cli() prompts that determine the Fermat point count.""" + self.tomo_shellstep = self._get_val( + " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 + ) + self.lamni_piezo_range_x = self._get_val( + " um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9 + ) + self.lamni_piezo_range_y = self._get_val( + " um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9 + ) + # min 0: a negative stitch makes stitch_tile_offsets() empty, so a + # projection would silently run zero scans. stitch n gives n+1 tiles. + # max 50 = the GUI's range. + self.lamni_stitch_x = self._get_val( + " (n+1 tiles)", self.lamni_stitch_x, int, min_=0, max_=50 + ) + self.lamni_stitch_y = self._get_val( + " (n+1 tiles)", self.lamni_stitch_y, int, min_=0, max_=50 + ) + self.tomo_stitch_overlap = self._get_val( + " um", self.tomo_stitch_overlap, float, min_=0.0, max_=10.0 + ) + self.tomo_circfov = self._get_val( + " um", self.tomo_circfov, float, min_=0.0, max_=500.0 + ) + def tomo_parameters_cli(self): """Print and interactively update the tomo parameters on the command line. tomo_parameters() opens the GUI version.""" @@ -2445,18 +2722,13 @@ class LamNI( f"Piezo range (max 80) = {self.lamni_piezo_range_x}," f" {self.lamni_piezo_range_y}" ) - print(f"Stitching number x,y = {self.lamni_stitch_x}, {self.lamni_stitch_y}") + print( + f"Stitching number x,y = {self.lamni_stitch_x}, {self.lamni_stitch_y}" + f" ({self.lamni_stitch_x + 1} x {self.lamni_stitch_y + 1} tiles)" + ) print(f"Stitching overlap = {self.tomo_stitch_overlap}") print(f"Circular FOV diam = {self.tomo_circfov}") - expected_positions = self._expected_fermat_position_count() - min_positions = self._fermat_min_positions() - if expected_positions < min_positions: - print( - f"Estimated Fermat scan points per projection: {expected_positions} " - f"(WARNING: below the minimum of {min_positions} -- the scan will abort when run)" - ) - else: - print(f"Estimated Fermat scan points per projection: {expected_positions}") + too_few_positions = self._print_fermat_position_estimate() print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}") print(f"Frames per trigger (burst) = {self.frames_per_trigger}") print("FOV offset rotates to find the ROI; initial values determined in Xrayeye alignment.") @@ -2465,6 +2737,8 @@ class LamNI( print(f" _tomo_fovy_offset = {self.tomo_fovy_offset:.4f}") print(f" _manual_shift_x = {self.manual_shift_x:.4f}") print(f" _manual_shift_y = {self.manual_shift_y:.4f}") + print(f" _loptz_shift_x = {self.loptz_shift_x:.4f} (queue loptz moves)") + print(f" _loptz_shift_y = {self.loptz_shift_y:.4f}") if self.at_each_angle_hook: print(f"At-each-angle hook = {self._describe_active_hook()}") print("") @@ -2503,36 +2777,23 @@ class LamNI( self._print_angle_file_summary() print(f"\nSample name: {self.sample_name}\n") - if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"): + if too_few_positions: + print("Too few Fermat scan points -- the scan parameters have to be adjusted.") + elif self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"): print("OK. continue.") return self.tomo_countingtime = self._get_val( " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 ) - self.tomo_shellstep = self._get_val( - " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 - ) - self.lamni_piezo_range_x = self._get_val( - " um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9 - ) - self.lamni_piezo_range_y = self._get_val( - " um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9 - ) - # min 0: a negative stitch makes range(-n, n + 1) empty, so a - # projection would silently run zero scans. max 50 = the GUI's range. - self.lamni_stitch_x = self._get_val( - "", self.lamni_stitch_x, int, min_=0, max_=50 - ) - self.lamni_stitch_y = self._get_val( - "", self.lamni_stitch_y, int, min_=0, max_=50 - ) - self.tomo_stitch_overlap = self._get_val( - " um", self.tomo_stitch_overlap, float, min_=0.0, max_=10.0 - ) - self.tomo_circfov = self._get_val( - " um", self.tomo_circfov, float, min_=0.0, max_=500.0 - ) + while True: + self._prompt_scan_geometry() + if not self._print_fermat_position_estimate(): + break + print( + "Too few Fermat scan points: reduce the step size or the piezo range," + " or adjust stitching/circular FOV." + ) self.ptycho_reconstruct_foldername = self._get_val( "Reconstruction queue", self.ptycho_reconstruct_foldername, str ) @@ -2580,11 +2841,11 @@ class LamNI( elif self.tomo_type == 2: self.golden_ratio_bunch_size = self._get_val( - "Number of projections sorted per bunch (minimum 20)", + "Number of projections sorted per bunch (30 to 200)", self.golden_ratio_bunch_size, int, - min_=20, - max_=100, + min_=30, + max_=200, ) self.golden_max_number_of_projections = self._get_val( "Stop after number of projections (0 for endless)", 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 bfdd8e91..f4d1c9e1 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 @@ -64,10 +64,12 @@ class LamNIAlignmentMixin: # ------------------------------------------------------------------ def reset_correction(self): - """Reset the look-up-table corrections to empty (iteration 1).""" + """Reset the look-up-table corrections to empty (iterations 1 and 2), + same as FlOMNI's reset_correction().""" self.corr_pos_x = [] self.corr_pos_y = [] self.corr_angle = [] + self.reset_correction_2() def reset_correction_2(self): """Reset the second iteration look-up-table correction to empty.""" @@ -90,6 +92,8 @@ class LamNIAlignmentMixin: existing.append("X-ray eye alignment fit") if self.corr_pos_x: existing.append("fine-alignment correction") + if self.corr_pos_x_2: + existing.append("fine-alignment correction 2") return existing # ------------------------------------------------------------------ @@ -102,8 +106,8 @@ class LamNIAlignmentMixin: # to be the same data, just the same persistence pattern. # # No default correction file exists for LamNI (unlike FlOMNI's - # default_correction_file): reset_correction()/reset_correction_2() just - # clear to empty, nothing is auto-loaded. + # default_correction_file): reset_correction() clears both iterations, + # reset_correction_2() only the second, nothing is auto-loaded. # ------------------------------------------------------------------ @property 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 1c4a8ca7..2fbebba8 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 @@ -15,6 +15,19 @@ scans = builtins.__dict__.get("scans") def umv(*args): return scans.umv(*args, relative=False) + +def compute_osa_remaining_space( + loptz_val: float, losaz_val: float, collision_offset: float +) -> float: + """Clearance (mm) between the OSA and a normal LamNI sample holder for a + given loptz/losaz pair. <=0 means collision. + + Module-level so the tomo-queue GUI (tomo_params.py, RPC device access + only) and the queue action can reuse the exact formula lfzp_info() + prints. collision_offset is loptz's "collision_offset" userParameter. + """ + return collision_offset - loptz_val - losaz_val + class LamNIInitError(Exception): pass @@ -665,8 +678,8 @@ class LamNIOpticsMixin: tol_osa = 0.010 # mm, 10 microns - remaining_current = -losaz_val + (collision_offset - loptz_val) - remaining_at_in = -losaz_in + (collision_offset - loptz_val) + remaining_current = compute_osa_remaining_space(loptz_val, losaz_val, collision_offset) + remaining_at_in = compute_osa_remaining_space(loptz_val, losaz_in, collision_offset) print(f" Current losaz {losaz_val:.1f}") print( 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 25978ecf..1d9396a5 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 @@ -404,6 +404,7 @@ class XrayEyeAlign: self.lamni.reset_xray_eye_correction() self.lamni.manual_shift_x = 0 self.lamni.manual_shift_y = 0 + self.lamni.reset_loptz_shift() self.gui.enable_submit_button(False) @@ -903,11 +904,11 @@ class XrayEyeAlign: print(f"[rotation-center] at {self.get_tomo_angle():.1f} deg") print("[rotation-center] live sweep done") - # --- EXPERIMENTAL: rotation-center smear mode ------------------------- + # --- rotation-center smear mode --------------------------------------- # Min- or max-projection "star-trail" composite aid for the extended-sample # rotation-center click, built while lsamrot rotates continuously. # Operator-visual only -- no automatic circle fitting. See - # find_rotation_center_smear_experimental() below for the entry point. + # find_rotation_center_smear() below for the entry point. def _push_smear_composite(self, composite: np.ndarray): """Publish the current composite to the GUI's live view. @@ -979,7 +980,7 @@ class XrayEyeAlign: """ accumulator = SmearComposite(projection=projection, opaque_level=opaque_level) print( - f"[rotation-center][smear] EXPERIMENTAL feature: {projection}-projection composite " + f"[rotation-center][smear] {projection}-projection composite " "aid, operator-visual only -- no automatic circle fitting." ) if not dev.cam_xeye.live_mode_enabled.get(): @@ -1054,10 +1055,10 @@ class XrayEyeAlign: # Note: the GUI stays on the "smear_preview" channel (showing this # composite) until the caller has collected the operator's click -- # switching back to "image" is the caller's responsibility (see - # find_rotation_center_smear_experimental). + # find_rotation_center_smear). return composite - def find_rotation_center_smear_experimental( + def find_rotation_center_smear( self, keep_shutter_open: bool = False, apply: bool = True, @@ -1065,7 +1066,7 @@ class XrayEyeAlign: projection: str = "min", opaque_level: float = 0.25, ): - """EXPERIMENTAL: extended-sample rotation-center calibration aided + """Extended-sample rotation-center calibration aided by a continuous-rotation min- or max-projection composite (see _smear_sweep()), instead of a single instant of live rotation. @@ -1110,7 +1111,7 @@ class XrayEyeAlign: dev.omny_xray_gui.submit.set(0) print( - "[rotation-center][smear] starting find_rotation_center_smear_experimental" + "[rotation-center][smear] starting find_rotation_center_smear" f"(sweep_deg={sweep_deg!r}, projection={projection!r}, " f"opaque_level={opaque_level!r})" ) @@ -1265,7 +1266,7 @@ class XrayEyeAlign: f.attrs["opaque_level"] = opaque_level print(f"[rotation-center][smear] saved calibration record to {expanded}") - # --- end EXPERIMENTAL: rotation-center smear mode --------------------- + # --- end rotation-center smear mode ----------------------------------- def _ensure_at_configured_center(self, tol: float = 0.003): """Move lsamx/lsamy to their currently-configured center if they @@ -1379,7 +1380,7 @@ class XrayEyeAlign: previous centre. Called right after a new lsamx/lsamy centre is actually applied (by - both find_rotation_center() and find_rotation_center_smear_experimental()). + both find_rotation_center() and find_rotation_center_smear()). Reuses the LamNI alignment mixin's own reset methods rather than introducing separate invalidation logic -- see lamni.xrayeye_alignment_start()/tomo_alignment_scan()/tomo_scan(), diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/idle_time.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/idle_time.py new file mode 100644 index 00000000..7b0b255b --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/idle_time.py @@ -0,0 +1,59 @@ +"""Idle-time bookkeeping for the tomo ETA, shared by LamNI and FlOMNI. + +The ETA divides the projections done by the elapsed time minus +``accumulated_idle_time``. A gap between two projections only counts as idle +when it is much longer than the *measured* typical gap of this scan. An +earlier model based on the per-point exposure time counted most of every +normal projection (a whole ptycho scan, minutes long) as idle, which made the +ETA several times too short. +""" + +from __future__ import annotations + +import datetime +import statistics + +# number of recent normal gaps kept to estimate the typical cadence +MAX_GAP_SAMPLES = 10 +# below this many samples nothing is counted as idle yet +MIN_GAP_SAMPLES = 3 +# a gap this long at the start of a scan is a pause, not a cadence sample +STARTUP_PAUSE_S = 30 * 60 +# a gap is idle when it exceeds max(IDLE_FACTOR * typical, typical + IDLE_MARGIN_S) +IDLE_FACTOR = 3.0 +IDLE_MARGIN_S = 300.0 + + +def account_projection_gap(progress, now: datetime.datetime) -> float: + """Account the gap since the last projection and refresh the heartbeat. + + Args: + progress: the tomo progress mapping (``lamni.progress``/``flomni.progress``), + read and written via ``get``/item assignment. + now: time of the projection that is about to start. + + Returns: + float: the idle time (s) added to ``accumulated_idle_time``, 0 if none. + """ + prev_heartbeat_str = progress.get("heartbeat") + progress["heartbeat"] = now.isoformat() + if prev_heartbeat_str is None: + return 0.0 + + gap = (now - datetime.datetime.fromisoformat(prev_heartbeat_str)).total_seconds() + gaps = list(progress.get("recent_projection_gaps") or []) + + idle = 0.0 + if len(gaps) < MIN_GAP_SAMPLES: + if gap <= STARTUP_PAUSE_S: + gaps.append(gap) + else: + typical = statistics.median(gaps) + if gap > max(IDLE_FACTOR * typical, typical + IDLE_MARGIN_S): + idle = gap - typical + progress["accumulated_idle_time"] = progress.get("accumulated_idle_time", 0.0) + idle + else: + gaps.append(gap) + + progress["recent_projection_gaps"] = gaps[-MAX_GAP_SAMPLES:] + return idle diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py index 81610647..dd454927 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py @@ -35,6 +35,56 @@ def umvr(*args): return scans.umv(*args, relative=True) +def stitch_tile_offsets(n: int) -> list[float]: + """Tile offsets, in units of (fov - stitch_overlap), for a stitch + parameter n: n+1 tiles placed symmetrically around the scan center. + n=0 -> [0], n=1 -> [-0.5, 0.5], n=2 -> [-1, 0, 1].""" + return [i - n / 2 for i in range(n + 1)] + + +def lamni_smallest_tile_position_count( + piezo_range_x: float, + piezo_range_y: float, + step: float, + stitch_x: int, + stitch_y: int, + stitch_overlap: float, + fov_circular: float, +) -> tuple[int, tuple[float, float]]: + """Fermat point count of the stitch tile with the fewest points, and that + tile's offsets. With a circular FOV the corner tiles can hold far fewer + points than the center one, and any tile below the scan's minimum aborts + the whole projection. Evaluated at angle 0: the per-tile count only + varies by a few points with angle (random sub-step offset), and seed=0 + keeps the estimate reproducible.""" + from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan + + counts = {} + for tile_x in stitch_tile_offsets(stitch_x): + for tile_y in stitch_tile_offsets(stitch_y): + counts[(tile_x, tile_y)] = len( + LamniFermatScan.get_lamni_fermat_spiral_pos( + -abs(piezo_range_x / 2), + abs(piezo_range_x / 2), + -abs(piezo_range_y / 2), + abs(piezo_range_y / 2), + step=step, + spiral_type=0, + center=False, + angle=0.0, + stitch_x=tile_x, + stitch_y=tile_y, + stitch_overlap=stitch_overlap, + fovx=piezo_range_x, + fovy=piezo_range_y, + fov_circular=fov_circular, + seed=0, + ) + ) + tile = min(counts, key=counts.get) + return counts[tile], tile + + def read_escape_suffix(stream, timeout: float = 0.05) -> str | None: """The two characters after an ESC on a non-blocking stdin (arrow keys: "[A".."[D"), or None if they do not arrive within `timeout` (ESC alone, or a slow terminal). diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py index 70eec3db..829a0adc 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py @@ -168,6 +168,39 @@ class _TomoQueueProxy: return self._load()[index] +def format_command_step(step: dict, registry: dict | None = None) -> str: + """One command-job step as ``action(name=value unit, ...)``. + + Units come from the action's param schema (``unit``) or, for the + "move" action's device_positions, from the move allow-list -- so the + CLI (tomo_queue_show) and the GUI queue table never show a bare number + whose unit (mm vs. um) has to be guessed. ``registry`` is the + ``tomo_queue_actions`` dict (see _describe_tomo_queue_actions()); without + it, values are shown without units. + """ + kwargs = step.get("kwargs") or {} + if not kwargs: + return step["action"] + registry = registry or {} + schema = registry.get("actions", {}).get(step["action"], {}).get("params", {}) + move_devices = registry.get("move_devices", {}) + + def _with_unit(value, unit): + return f"{value} {unit}" if unit else f"{value}" + + parts = [] + for name, value in kwargs.items(): + field = schema.get(name, {}) + if field.get("type") == "device_positions" and isinstance(value, dict): + parts.extend( + f"{dev_name}={_with_unit(target, move_devices.get(dev_name, {}).get('unit'))}" + for dev_name, target in value.items() + ) + else: + parts.append(f"{name}={_with_unit(value, field.get('unit'))}") + return f"{step['action']}({', '.join(parts)})" + + class _GlobalVarParam: """Descriptor for a parameter backed by a BEC global variable. @@ -816,11 +849,11 @@ class TomoQueueMixin: if not jobs: print("Tomo queue is empty.") return jobs + registry = self._describe_tomo_queue_actions() for i, job in enumerate(jobs): if job.get("kind", "tomo") == "command": steps_str = " > ".join( - f"{step['action']}{step['kwargs']}" if step["kwargs"] else step["action"] - for step in job["steps"] + format_command_step(step, registry) for step in job["steps"] ) idem = "idem" if job.get("idempotent") else "NOT idem" print(f"[{i}] {job['status']:>10s} {job['label']:<20s} CMD {steps_str} [{idem}]") @@ -1175,6 +1208,7 @@ class TomoQueueMixin: self.progress["estimated_remaining_time"] = None self.progress["estimated_finish_time"] = None self.progress["accumulated_idle_time"] = 0.0 + self.progress["recent_projection_gaps"] = [] self.progress["heartbeat"] = None # flOMNI: a completed scan is not resumed (tomo_scan_resume); reopened here on purpose self.progress["tomo_completed"] = False diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py index 8b0dbf25..b460d6c7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py @@ -136,6 +136,10 @@ _MISMATCH_LOCAL_PORT = 8081 # Subfolders follow the pattern dset_NNNNN and contain S#####_*_phase.png etc. _PTYCHO_ONLINE_DIR = "~/data/raw/analysis/online/ptycho" _PTYCHO_THUMB_PX = 200 # longest edge of generated thumbnail in pixels +# The upload server (omny.psi.ch/upload.php) rejects large POSTs with HTTP 413; +# stitched reconstructions produce full-size PNGs above that. Full-size copies +# larger than this are downscaled until they fit. +_PTYCHO_FULL_MAX_BYTES = 900_000 VERBOSITY_SILENT = 0 VERBOSITY_NORMAL = 1 @@ -1324,7 +1328,7 @@ class WebpageGeneratorBase: img.save(thumb_path, "JPEG", quality=85) else: shutil.copy2(src, thumb_path) - shutil.copy2(src, full_path) + self._copy_full_capped(src, full_path) images.append({ "role": role, "thumb": thumb_name, @@ -1339,6 +1343,26 @@ class WebpageGeneratorBase: self._last_ptycho_scan = scan_id return {"scan_id": scan_id, "images": images, "folder": str(latest_dir)} + @staticmethod + def _copy_full_capped(src: Path, dst: Path, max_bytes: int = _PTYCHO_FULL_MAX_BYTES) -> None: + """Copy src to dst; if src is larger than max_bytes, save a downscaled + PNG instead (halving the pixel area each step) so the upload server + accepts it. Without Pillow, or if it can't get small enough, the + plain/smallest copy is kept.""" + if src.stat().st_size <= max_bytes or not _PIL_AVAILABLE: + shutil.copy2(src, dst) + return + img = _PILImage.open(src) + img.load() + w, h = img.size + scale = 1.0 + while True: + scale *= 0.7071 + size = (max(1, int(w * scale)), max(1, int(h * scale))) + img.resize(size, _PILImage.LANCZOS).save(dst, "PNG", optimize=True) + if dst.stat().st_size <= max_bytes or min(size) <= 64: + return + # ------------------------------------------------------------------ # Subclass hooks # ------------------------------------------------------------------ diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py index da747954..d0cc7ae1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py @@ -31,9 +31,12 @@ class cSAXS( self.device_manager = client.device_manager self.OMNYTools = OMNYTools(self.client) - self.diagnostics = cSAXSDiagnostics() + self.diagnostics = cSAXSDiagnostics(client) super().__init__(client=client) + # EPICS does not keep the slit offsets reliably; restore them from config + self.slits_apply_epics_offsets() + # ------------------------------------------------------------------ # Undulator # ------------------------------------------------------------------ @@ -98,10 +101,14 @@ class cSAXS( for access, desc in [ ("csaxs.diagnostics.show_all()", "All diagnostic device readbacks"), ( - "csaxs.diagnostics.bpm_xbox1 / .bpm_xbox2", + "csaxs.diagnostics.show_plots(*extra)", + "Plot window: XBPM3 blades + XBIM; extra e.g. 'xbpm3_position', 'beamstop'", + ), + ( + "csaxs.diagnostics.xbpm2 / .xbpm3", "BPM diagnostics — .show_all(), .gain(val)", ), - ("csaxs.diagnostics.bim", "BIM diagnostics — .show_all(), .gain(val)"), + ("csaxs.diagnostics.xbim", "XBIM diagnostics — .show_all(), .gain(val)"), ("csaxs.diagnostics.beamstop", "Beamstop diode — .show_all(), .gain(val)"), ("csaxs.diagnostics.polarization", "Polarization diodes — .show_all(), .gain(val)"), ("csaxs.OMNYTools.*", "OMNY instrument tools"), diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/diagnostics.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/diagnostics.py index c509a713..c0469d93 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/diagnostics.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/diagnostics.py @@ -1,5 +1,6 @@ from __future__ import annotations import builtins +import time # ------------------------------------------------------------ # Access to dev (as before) @@ -167,28 +168,53 @@ class BPMDiag: class cSAXSDiagnostics: """User-visible diagnostics namespace.""" - def __init__(self): + PLOT_WINDOW_NAME = "csaxs_diagnostics" + GUI_RPC_TIMEOUT = 20 + _BLADES = ["left_top", "right_top", "left_bot", "right_bot"] + # name -> (plot title, [(device, signal suffix), ...]); one waveform plot + # each, curves of devices missing from the config are skipped + PLOTS = { + "xbpm3": ("XBPM3 blades (ES hutch, XBox 2)", [("xbpm3_slowrb", b) for b in _BLADES]), + "xbim": ("XBIM (ES hutch, XBox 3)", [("xbim_slowrb", "signal")]), + "xbpm3_position": ( + "XBPM3 position", + [("xbpm3_slowrb", "pos_x"), ("xbpm3_slowrb", "pos_y")], + ), + "xbpm2": ("XBPM2 blades (OP hutch, XBox 1)", [("xbpm2_slowrb", b) for b in _BLADES]), + "beamstop": ("Beamstop diode (flight tube)", [("beamstop_intensity", "signal")]), + "polarization": ( + "Polarization diodes (OP hutch, XBox 1)", + [ + ("diode_horizontal_xbox1_slowrb", "signal"), + ("diode_vertical_xbox1_slowrb", "signal"), + ], + ), + } + DEFAULT_PLOTS = ("xbpm3", "xbim") + + def __init__(self, client=None): + self._client = client # BPMs - self.bpm_xbox1 = BPMDiag( - name="bpm_xbox1", - label="BPM XBox1 (OP hutch)", - gain_dev=_safe_dev("gain_bpm_xbox1"), - rb_dev=_safe_dev("bpm_xbox1_slowrb"), + self.xbpm2 = BPMDiag( + name="xbpm2", + label="XBPM2 (OP hutch, XBox 1)", + gain_dev=_safe_dev("xbpm2_gain"), + rb_dev=_safe_dev("xbpm2_slowrb"), ) - self.bpm_xbox2 = BPMDiag( - name="bpm_xbox2", - label="BPM XBox2 (ES hutch)", - gain_dev=_safe_dev("gain_bpm_xbox2"), - rb_dev=_safe_dev("bpm_xbox2_slowrb"), + self.xbpm3 = BPMDiag( + name="xbpm3", + label="XBPM3 (ES hutch, XBox 2)", + gain_dev=_safe_dev("xbpm3_gain"), + rb_dev=_safe_dev("xbpm3_slowrb"), ) # Single-signal diagnostics - self.bim = SingleSignalDiag( - name="bim", - label="BIM XBox3 (ES)", - gain_dev=_safe_dev("gain_bim_xbox3"), - signal_dev=_safe_dev("bim_xbox3_slowrb"), + self.xbim = SingleSignalDiag( + name="xbim", + label="XBIM (ES hutch, XBox 3)", + gain_dev=_safe_dev("xbim_gain"), + signal_dev=_safe_dev("xbim_slowrb"), ) self.beamstop = SingleSignalDiag( @@ -213,8 +239,54 @@ class cSAXSDiagnostics: print("CSAXS diagnostics") print("=================\n") - self.bpm_xbox1.show_all() - self.bpm_xbox2.show_all() - self.bim.show_all() + self.xbpm2.show_all() + self.xbpm3.show_all() + self.xbim.show_all() self.beamstop.show_all() - self.polarization.show_all() \ No newline at end of file + self.polarization.show_all() + + def show_plots(self, *extra: str, defaults: bool = True): + """Open a BEC GUI window with waveform plots of the beam diagnostics, + e.g. for an ID gap scan. By default the XBPM3 blade signals and the + XBIM signal; further plots by name, e.g. + show_plots("xbpm3_position", "beamstop"), or only the named ones with + defaults=False. Calling it again rebuilds the plots in the open window.""" + unknown = [p for p in extra if p not in self.PLOTS] + if unknown: + print(f"Unknown plot(s) {unknown}. Available: {', '.join(self.PLOTS)}") + return + gui = getattr(self._client, "gui", None) + if gui is None: + print("No BEC GUI is available in this session.") + return + names = list(self.DEFAULT_PLOTS) if defaults else [] + names += [p for p in extra if p not in names] + plots = [] + for plot_name in names: + title, curves = self.PLOTS[plot_name] + curves = [(d, s) for d, s in curves if _safe_dev(d) is not None] + if curves: + plots.append((plot_name, title, curves)) + else: + print(f"Skipping plot '{plot_name}': its devices are not in the current config.") + if not plots: + return + name = self.PLOT_WINDOW_NAME + if name in gui.windows: + window = getattr(gui, name) + window.delete_all(timeout=self.GUI_RPC_TIMEOUT) + else: + gui.new(name, timeout=self.GUI_RPC_TIMEOUT) + time.sleep(1) + window = getattr(gui, name) + for plot_name, title, curves in plots: + waveform = window.new( + "Waveform", object_name=f"{plot_name}_plot", timeout=self.GUI_RPC_TIMEOUT + ) + waveform.title = title + for device, signal in curves: + waveform.plot(device_y=device, signal_y=f"{device}_{signal}") + others = [p for p in self.PLOTS if p not in names] + if others: + options = ", ".join(repr(p) for p in others) + print(f"More plots: csaxs.diagnostics.show_plots({options})") diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py index 31246a6a..7c076e68 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py @@ -1,7 +1,9 @@ from __future__ import annotations import builtins -from typing import Optional +import datetime +import os +from typing import Any, Optional # ------------------------------------------------------------ # Make dev visible if running inside BEC @@ -29,6 +31,16 @@ class cSAXSSlits: "sl6": "Slit 6 (exposure box 2)", } + # device tags of the beamline sections recorded by the full state dump + # (endstation instruments such as LamNI/flOMNI/OMNY carry their own tags) + _BEAMLINE_TAGS = ("machine", "bl_frontend", "bl_optics_hutch", "bl_endstation") + + _STATE_DIR = "~/data/raw/logs/beamline_state" + + # userParameter holding the EPICS user_offset of a motor; the IOC does not + # keep the offsets reliably, so the client writes them at startup + _EPICS_OFFSET_PARAM = "epics_offset" + def __init__(self, client): self.client = client self.device_manager = client.device_manager @@ -83,14 +95,256 @@ class cSAXSSlits: print(f"{label}:") print( - f" x center = {self._sl_fmt(xc)} " - f"x size = {self._sl_fmt(xs)}" + f" {prefix}xc = {self._sl_fmt(xc)} " + f"{prefix}xs = {self._sl_fmt(xs)}" ) print( - f" y center = {self._sl_fmt(yc)} " - f"y size = {self._sl_fmt(ys)}" + f" {prefix}yc = {self._sl_fmt(yc)} " + f"{prefix}ys = {self._sl_fmt(ys)}" ) print("") print("==========================================") - print("") \ No newline at end of file + print("") + + def slits_apply_epics_offsets(self) -> dict: + """ + Write the configured EPICS user offsets (userParameter 'epics_offset'). + + Called at cSAXS client startup. Only devices that define + userParameter 'epics_offset' are touched; the offset is written only + if it differs from the one currently in EPICS. Nothing moves - the + user readback shifts with the offset. + + Returns: + dict: {device_name: offset} for every offset that was written. + """ + written = {} + for name, device in self.device_manager.devices.items(): + try: + param = (getattr(device, "_config", {}) or {}).get("userParameter") or {} + if self._EPICS_OFFSET_PARAM not in param: + continue + if not getattr(device, "enabled", True): + continue + offset = float(param[self._EPICS_OFFSET_PARAM]) + current = float(device.user_offset.get()) + if abs(current - offset) < 1e-9: + continue + device.user_offset.put(offset) + written[name] = offset + print(f"{name}: EPICS user offset {current:.6f} -> {offset:.6f} (from config)") + except Exception as exc: + print(f"{name}: could not apply {self._EPICS_OFFSET_PARAM}: {exc}") + return written + + # -------------------------------------------------------- + # slit calibration + # -------------------------------------------------------- + + def _sl_read_value(self, device_name: str, cached: bool = False) -> Optional[float]: + """ + Return the readback of a device (fresh read by default) or None. + """ + try: + device = self.device_manager.devices[device_name] + reading = device.read(cached=cached) + return float(reading[device_name]["value"]) + except Exception: + return None + + def _sl_offset_entry(self, name: str, target: float) -> dict: + """ + Compute the offset that makes the displayed value of slit axis + *name* equal to *target*, assuming the slit is physically where + *target* should be. + + EPICS motors (sl1-sl3): user = dir * dial + user_offset + SmarAct virtual slits (sl4-sl6): value = f(blades) + offset (config) + In both cases the displayed value shifts 1:1 with the offset, so + new_offset = offset + (target - displayed). + """ + entry = {"name": name, "target": target, "kind": None, "displayed": None, + "offset": None, "new_offset": None} + try: + device = self.device_manager.devices[name] + except Exception: + return entry + + config = getattr(device, "_config", {}) or {} + if "VirtualSlit" in str(config.get("deviceClass", "")): + entry["kind"] = "smaract" + entry["offset"] = float((config.get("deviceConfig") or {}).get("offset", 0) or 0) + else: + entry["kind"] = "epics" + try: + entry["offset"] = float(device.user_offset.get()) + except Exception: + entry["offset"] = None + configured = (config.get("userParameter") or {}).get(self._EPICS_OFFSET_PARAM) + entry["configured"] = None if configured is None else float(configured) + + entry["displayed"] = self._sl_read_value(name) + if entry["displayed"] is not None and entry["offset"] is not None: + entry["new_offset"] = entry["offset"] + (target - entry["displayed"]) + return entry + + @staticmethod + def _sl_fmt_value(value: Any) -> str: + if isinstance(value, bool) or value is None: + return str(value) + if isinstance(value, float): + return f"{value:.6g}" + text = repr(value) + return text if len(text) <= 100 else text[:97] + "..." + + def _sl_beamline_state_lines(self) -> list[str]: + """ + Dump readings and configuration signals (offsets, limits, ...) of + all enabled devices tagged with one of _BEAMLINE_TAGS. + """ + lines = [] + for tag in self._BEAMLINE_TAGS: + try: + devices = self.device_manager.devices.get_devices_with_tags(tag) + except Exception: + devices = [] + lines.append("") + lines.append(f"---------- {tag} ----------") + for device in devices: + if not getattr(device, "enabled", True): + continue + lines.append(f"{device.name}") + signals = {} + for fcn in (device.read, device.read_configuration): + try: + reading = fcn(cached=True) or fcn(cached=False) or {} + signals.update(reading) + except Exception as exc: + lines.append(f" <{fcn.__name__} failed: {exc}>") + for sig_name, sig in signals.items(): + value = sig.get("value") if isinstance(sig, dict) else sig + lines.append(f" {sig_name:40s} {self._sl_fmt_value(value)}") + return lines + + def slits_calibration_report( + self, + full: bool = True, + save: bool = False, + filename: Optional[str] = None, + target_width: float = 1.0, + ) -> dict: + """ + Slit offset calibration report (and optional full beamline state dump). + + Run with all slits physically centered on the beam and opened to + target_width (default 1 mm). Reports for every slit axis the offset + that makes the center read 0 and the size read target_width: + EPICS slits (sl1-sl3): new user_offset, plus commands to apply it. + SmarAct slits (sl4-sl6): new 'offset' for deviceConfig in bl_endstation.yaml. + Nothing is changed on the hardware or in the config files. + + Args: + full: also record all beamline devices (machine, frontend, optics + incl. mirrors/monos, endstation; not LamNI/flOMNI/OMNY). + save: write the report to a text file. + filename: output file; default ~/data/raw/logs/beamline_state/ + beamline_state_.txt + target_width: slit opening [mm] the slits are currently set to. + + Returns: + dict: {device_name: new_offset} for all slit center/size axes. + + Examples: + csaxs.slits_calibration_report() + csaxs.slits_calibration_report(save=True) + csaxs.slits_calibration_report(full=False) + """ + now = datetime.datetime.now() + lines = [ + "========== cSAXS Slit Calibration Report ==========", + f"date: {now:%Y-%m-%d %H:%M:%S}", + f"assumed state: all slits centered, opening {target_width:g} mm", + "", + f"{'axis':8s} {'type':8s} {'displayed':>11s} {'offset':>11s}" + f" {'target':>8s} {'new offset':>11s} {'change':>10s}", + ] + + entries = [] + for prefix, label in self._SLIT_LABELS.items(): + lines.append(f"{label}") + for suffix, target in (("xc", 0.0), ("xs", target_width), ("yc", 0.0), ("ys", target_width)): + entry = self._sl_offset_entry(f"{prefix}{suffix}", target) + entries.append(entry) + change = ( + None if entry["new_offset"] is None else entry["new_offset"] - entry["offset"] + ) + lines.append( + f"{entry['name']:8s} {str(entry['kind'] or '---'):8s}" + f" {self._sl_fmt(entry['displayed']):>11s} {self._sl_fmt(entry['offset']):>11s}" + f" {target:8.4f} {self._sl_fmt(entry['new_offset']):>11s}" + f" {self._sl_fmt(change):>10s}" + ) + if ( + suffix.endswith("s") + and entry["displayed"] is not None + and abs(entry["displayed"] - target) > 0.5 * target_width + ): + lines.append( + f" WARNING: {entry['name']} reads {entry['displayed']:.4f}, far" + f" from {target_width:g} mm - is this slit really set up?" + ) + lines.append("") + + missing = [e["name"] for e in entries if e["new_offset"] is None] + if missing: + lines.append(f"could not compute (device missing/unreadable): {', '.join(missing)}") + lines.append("") + + for e in entries: + configured = e.get("configured") + if configured is not None and e["offset"] is not None and abs(configured - e["offset"]) > 1e-9: + lines.append( + f"NOTE: {e['name']} EPICS offset {e['offset']:.6f} differs from configured" + f" {self._EPICS_OFFSET_PARAM} {configured:.6f}" + ) + lines.append( + f"EPICS slits - new userParameter '{self._EPICS_OFFSET_PARAM}'" + " (bl_frontend.yaml for sl1, bl_optics_hutch.yaml for sl2/sl3);" + ) + lines.append("written to EPICS at client startup or with csaxs.slits_apply_epics_offsets():") + for e in entries: + if e["kind"] == "epics" and e["new_offset"] is not None: + lines.append(f" {e['name']}: {self._EPICS_OFFSET_PARAM}: {e['new_offset']:.6f}") + lines.append("") + lines.append("EPICS slits - to apply immediately in this session:") + for e in entries: + if e["kind"] == "epics" and e["new_offset"] is not None: + lines.append(f" dev.{e['name']}.user_offset.put({e['new_offset']:.6f})") + lines.append("") + lines.append("SmarAct slits - new 'offset' in deviceConfig (bl_endstation.yaml):") + for e in entries: + if e["kind"] == "smaract" and e["new_offset"] is not None: + lines.append(f" {e['name']}: offset: {e['new_offset']:.6f}") + lines.append("") + + if full: + lines.append("========== Beamline state (cached readings) ==========") + lines.extend(self._sl_beamline_state_lines()) + lines.append("") + + text = "\n".join(lines) + print(text) + + if save: + if filename is None: + filename = os.path.join( + self._STATE_DIR, f"beamline_state_{now:%Y%m%d_%H%M%S}.txt" + ) + filename = os.path.expanduser(filename) + os.makedirs(os.path.dirname(filename) or ".", exist_ok=True) + with open(filename, "w", encoding="utf-8") as fh: + fh.write(text + "\n") + print(f"Report written to {filename}") + + return {e["name"]: e["new_offset"] for e in entries} diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/E2E_TESTING_GUIDE.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/E2E_TESTING_GUIDE.md index 7abbda22..84f042a2 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/E2E_TESTING_GUIDE.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/E2E_TESTING_GUIDE.md @@ -86,7 +86,7 @@ sim session you started by hand. **Do not use `bec_client_lib_with_demo_config` / `bec_ipython_client_with_demo_config` as they ship.** Both call `bec.config.load_demo_config(force=True)` internally. Against a running sim session, this **overwrites the loaded simulated device -config** (e.g. `simulated_flomni.yaml`) with BEC's own generic demo devices — +config** (e.g. `simulated_bl_simulated_flomni.yaml`) with BEC's own generic demo devices — silently breaking every test that assumes your plugin's real devices exist. Instead, write your own fixture that reuses the lower-level plumbing those diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/FLOMNI_SCAN_OVERHEAD.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/FLOMNI_SCAN_OVERHEAD.md new file mode 100644 index 00000000..7625e801 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/FLOMNI_SCAN_OVERHEAD.md @@ -0,0 +1,250 @@ +# flOMNI tomography: where the time goes (scan overhead analysis) + +Written 2026-10-15 from the BEC logs of the flOMNI beamtime of **2026-10-07 08:46 to +2026-10-12 ~09:30** (setup changed to LamNI on 2026-10-12). Nothing proposed here has been +implemented yet. This is a findings + ideas document to pick the work up later. + +The code analysed is the production deployment that ran during that beamtime. It is identical to +the current one: `flomni_fermat_scan.py` and `rt_flomni_ophyd.py` were diffed against +`old_production_deployments/20261012T202711` with no differences. + +Data set: 10,403 completed + 365 aborted `flomni_fermat_scan`s, 120.8 h of wall time. +Sources: `/var/log/bec/{ScanServer,DeviceServer,ScanBundler,FileWriterManager}.*.log.gz`. + +## 1. Time budget (120.8 h) + +| Bucket | Hours | Share | +|---|---|---| +| Detector exposure (num_points × exp_time × frames_per_trigger) | 67.0 | 55 % | +| Per-point dead time inside the scan | ~19.5 | 16 % | +| Fixed per-scan setup/teardown inside open→closed (~4 s/scan) | ~11 | 9 % | +| Beam/shutter problems: aborted scans (3.9 h) + waiting for beam (6.4 h) | ~10.3 | 8.5 % | +| Long pauses without beam problem (manual work, `acquire` alignment, between tomograms) | ~7 | 6 % | +| Gaps between back-to-back scans (closed → next open, ~1 s) | 2.8 | 2.5 % | +| Eiger alarms after aborts, misc | ~1 | 1 % | + +- Interlocks: 106 "abort and repeat" restarts, mostly `shutter_es_device_within_limits_state` + and `shutter_device_within_limits_state`. +- 314 `EigerError` alarms on `eiger2_si_500k`. +- Worst single gap: 3 h starting 2026-10-10 07:33 (beam/interlock). + +"Fixed per-scan cost" plus "gaps between scans" together make up the scan-to-scan dead time +analysed in §3. The split between them is only the ScanServer's open/closed bookkeeping. + +## 2. Per-point dead time (largest loss overall, cause not yet understood) + +Real time per point, measured as open→closed / num_points: + +| exp_time × frames | nominal s/pt | real s/pt | dead time/pt | relative | +|---|---|---|---|---| +| 0.01 × 20 (n=8065) | 0.20 | 0.268 | 68 ms | +34 % | +| 0.1 × 1 (n=1633) | 0.10 | 0.197 | 97 ms | +97 % | +| 0.05 × 1 (n=656) | 0.05 | 0.143 | 93 ms | +186 % | + +These numbers still include the ~4 s fixed cost per scan spread over the points. The cadence of +`Read i out of N` during the scan gives 0.229 s/pt (mean) for 0.01×20, so the pure per-point +cost is **~30–50 ms**. It is roughly independent of exposure time, which points to a fixed +step/settle/trigger dead time in the RT controller point scan (`sd` command) or the trigger +chain (ddg1 external rising edge → Eiger). The BEC logs cannot resolve it. It needs RT +controller-side timing or a scope on the trigger lines. + +## 3. Scan-to-scan dead time (detailed) + +**True dead time from the last exposed point of projection N to the first exposed point of +N+1: 3.2 s median, 3.6 s mean.** About 10 h over the beamtime. + +Mean timestamps, relative to the last `Read i out of N` of the previous scan, over 9,876 +back-to-back transitions (gap < 5 s): + +| t [s] | Event (log marker) | Step duration | What happens / code | +|---|---|---|---| +| 0.00 | last `Read i out of N` (DeviceServer) | | readout thread `read_positions_from_sampler` finishes, `rt_flomni_ophyd.py:862` | +| 0.01 | `Flomni statistics` | | status of `complete(rt_positions)` set finished | +| 0.70 | MCS `Current data index … complete` | **0.69** | ScanServer still in the `at_each_point` `time.sleep(1)` loop (§5.1), then `post_scan`: rtx/rty/rtz move to end position + `complete_all_devices` | +| 1.16 | ScanServer `closed` | 0.46 | `post_scan` ddg1 → single shot, `unstage`, `close_scan` waits baseline readout | +| 2.17 | FileWriter `Finished writing file` | (1.0, in parallel) | does **not** block the next scan | +| 1.27 | client RPC `flomni_samples.sample_names.sample0.get` | | client loop between projections | +| 1.30 | client RPC `fsamroy.read` | | | +| 1.46 | client RPC `rtx.controller.laser_tracker_check_signalstrength` (queued as device_rpc) | | `flomni.py:3661` | +| 1.62 | ScanServer `Receiving scan request: flomni_fermat_scan` | **0.46 client side** | | +| 1.71 | `Fermat spiral sub-step offset` | | `prepare_scan` positions + corridor optimizer (3 ms) | +| 1.72 | DeviceServer `flomni scan stopped and deleted` | | `prepare_setup` → `clear_trajectory_generator` | +| 1.89 | `Rotating to requested angle` | | `flomni_rotation`, starts fsamroy move without waiting | +| 2.11 | ScanServer `open` | 0.49 since request | `prepare_scan` incl. rty move (`actions.set` waits) + baseline read | +| 2.16 | MCS on_stage done (0.05 s) | | `stage_all_devices` | +| 2.36 | ddg1 on_stage done (0.22 s) | | | +| 2.39 | Eiger `staged for scan` | | Eiger stage median 0.24 s, **mean 0.60 s** (long tail) | +| 2.66 | fupr `Move finished` | rotation ~0.8 s | `prepare_setup_part_2` waits for rotation | +| 3.15 | `Laser tracker running!` | **0.5** | `laser_tracker_on` → `laser_tracker_wait_on_target`; 0.87 s when it has to wait (28 % of scans, 0.5 s poll) | +| 3.29 | `Sending N positions took …` (0.15 s, thread) | | `add_pos_to_scan`, second `laser_tracker_check_signalstrength`, `move_samx_to_scan_region` | +| ~3.4 | first point exposed | | `kickoff`: wait buffer, `start_scan`, `sleep(0.1)` | +| (4.95) | first `Read 0 out of N` logged | | logged ~6 points late, see §4 | + +Rotation vs angle step (median Δangle between projections is 1.5°): + +| Δangle | n | rotation (stopdel→Move finished) | Ra→R0 (uncorrected, includes the ~1.5 s Read-0 offset) | +|---|---|---|---| +| < 1° | 1186 | 0.63 s | 4.65 s | +| 1–2° | 4818 | 0.76 s | 4.71 s | +| 2–5° | 2130 | 0.87 s | 4.78 s | +| 5–20° | 1497 | 1.25 s | 5.34 s | +| > 20° | 245 | 4.76 s | 10.32 s | + +For typical small steps the rotation largely overlaps staging and is not the bottleneck. The +dead time is dominated by serial bookkeeping: end-of-scan polling, close, client round-trip, +prepare, open, stage, then laser tracker. + +Precise sub-second numbers taken from the log text itself: +- Eiger stage: 0.596 mean / 0.240 median (0.21 s of it JungfrauJoch communication). +- ddg1 stage: 0.221 s. +- MCS stage: 0.052 s. +- Sending positions: 0.147 s. +- FileWriter master file: 1.0 s. + +## 4. Method and pitfalls (for re-running, e.g. on LamNI) + +- **Timestamps only have 1 s resolution.** Averaging `floor(t2) - floor(t1)` over thousands of + scans is unbiased, so use means; medians of individual differences are meaningless at this + resolution. +- **`Read 0 out of N` is logged late.** `read_positions_from_sampler` only starts reading once + `current_position_in_scan > 5` (`rt_flomni_ophyd.py:885`) and lags one point behind. True + scan start ≈ t(Read i₀) − (i₀ + 6) × per-point time, with the per-point time taken from the + Read cadence. Without this correction the gap looks ~1.5 s longer. +- At the end, after the controller mode drops to 0, the remaining points are read immediately, + so the last `Read` line ≈ the real end of exposure. +- **Markers used:** + - ScanServer: `New scan status: / open|closed|aborted` (also carries `num_points`, + `exp_time`, `frames_per_trigger`, `angle`), `Receiving scan request: {'scan_type': + 'flomni_fermat_scan'`, the client device_rpc requests, `Rotating to requested angle`, + `Requesting to abort and repeat`, `Alarm (MAJOR)`, `Beamline state … out of bounds`. + - DeviceServer: `Read i out of N`, `Flomni statistics`, `Current data index`, + `flomni scan stopped and deleted`, `on_stage completed in`, `staged for scan. Time spent`, + fupr `Move finished`, `Waiting for laser tracker`, `Laser tracker running`, + `positions took`. + - ScanBundler: `Sending point 0 for`. FileWriterManager: `[SUCCESS] | Finished writing file`. +- Use only consecutive *completed* scans with closed→open < 5 s for the transition analysis. + Larger gaps are user pauses, alignment or interlocks. + +Minimal snippet that rebuilds the §3 timeline (run from `/var/log/bec`, extend the marker list +as needed): + +```python +import gzip, glob, re, bisect, statistics as st, datetime as dt +from collections import defaultdict +T = lambda s: dt.datetime.strptime(s, "%Y-%m-%d %H:%M:%S").timestamp() +files = lambda p: sorted(glob.glob(f"{p}.2026-10-0[7-9]*.gz") + glob.glob(f"{p}.2026-10-1[0-2]*.gz")) +scans, ev = {}, defaultdict(list) +for f in files("ScanServer"): + for l in gzip.open(f, "rt", errors="replace"): + if l.startswith("2026") and "New scan status:" in l and "'flomni_fermat_scan'" in l: + m = re.search(r"New scan status: (\S+) / (\w+) /", l) + scans.setdefault(m[1], {}).setdefault(m[2], T(l[:19])) +markers = {"read": " out of ", "mcs": "Current data index", "eig": "staged for scan", + "rot": "fupr_ophyd | [INFO] | Move finished", "lt": "Laser tracker running", + "send": "positions took"} +for f in files("DeviceServer"): + for l in gzip.open(f, "rt", errors="replace"): + for k, v in markers.items(): + if l.startswith("2026") and v in l: + ev[k].append(T(l[:19])); break +for k in ev: ev[k].sort() +first = lambda k, a, b: next((t for t in ev[k][bisect.bisect_left(ev[k], a):] if t <= b), None) +def last(k, a, b): + i = bisect.bisect_right(ev[k], b) - 1 + return ev[k][i] if i >= 0 and ev[k][i] >= a else None +done = sorted((s for s in scans.values() if "open" in s and "closed" in s), key=lambda s: s["open"]) +rel = defaultdict(list) +for a, b in zip(done, done[1:]): + if b["open"] - a["closed"] > 5: continue + ra = last("read", a["open"], a["closed"]); r0 = first("read", b["open"], b["closed"]) + if not (ra and r0): continue + for k, t in [("mcs", last("mcs", a["open"], a["closed"])), ("closed", a["closed"]), + ("open", b["open"]), ("eig", first("eig", a["closed"], r0)), + ("rot", first("rot", a["closed"], r0)), ("lt", last("lt", a["closed"], r0)), + ("send", first("send", a["closed"], r0)), ("read0_logged", r0)]: + if t: rel[k].append(t - ra) +for k, v in rel.items(): print(f"{k:14s} {st.mean(v):6.2f} s n={len(v)}") +``` + +## 5. Improvement ideas (ranked by expected payoff per effort) + +### 5.1 End-of-scan polling sleep (~0.5 s/scan, ~1.4 h per beamtime). Easy. +`flomni_fermat_scan.py:233-254`: +```python +status = self.actions.complete(device=self.dev.rt_positions, wait=False) +while not status.done: + self.at_each_point() # read_monitored_devices(); time.sleep(1) +``` +The ScanServer checks `status.done` only once per second, so after the RT readout finishes it +idles 0–1 s (≈0.5 s mean) before `post_scan`. The 1 s sleep also sets the ~1 Hz monitored +readout rate (`num_monitored_readouts` ≈ scan duration in s), and that rate must be kept. Fix: +keep reading monitored devices every ~1 s but poll `done` finely, e.g. +```python +def at_each_point(self): + self.actions.read_monitored_devices() + t0 = time.time() + while time.time() - t0 < 1 and not self._rt_status.done: + time.sleep(0.02) +``` +or `status.wait(timeout=1)` if `ScanStubStatus` supports it (BEC core, not checked). +**The same pattern exists in `lamni_fermat_scan.py:225-227` and `omny_fermat_scan.py:202-203`** +(there the `sleep(1)` is in `scan_core`). Fix all three. + +### 5.2 Serial setup chain, starting the rotation earlier (~0.5–1 s). Medium. +The rotation only starts ~0.7 s after the previous scan closed (client round-trip + +`prepare_scan`), and the laser tracker, position upload and checks run only after staging and +rotation are done. Ideas: +- Start the fsamroy move to the next angle as early as possible. For example, the client could + send the next angle so the move overlaps the end of the previous scan, or a combined + multi-angle scan could run several projections inside one queue item. +- Overlap `laser_tracker_on`/`add_pos_to_scan` with staging where safe. Note that + `move_samx_to_scan_region` warns that an rtx move clears the trajectory, so ordering matters. +- `prepare_setup` moves rty with a blocking `actions.set`. Check whether it can overlap the + rotation. + +### 5.3 Duplicate laser-tracker signal check (~0.15–0.45 s). Easy. +The client calls `dev.rtx.controller.laser_tracker_check_signalstrength()` before every +projection (`flomni.py:3661`, queued as a device_rpc). The scan calls it again in +`prepare_setup_part_2` (`flomni_fermat_scan.py:360`), which already raises an alarm/abort on +low signal. Drop the client-side call in the per-projection path; keep it where it is used +interactively. The `flomni_samples` and `fsamroy.read` RPCs around it also cost round-trips. +Check whether they are needed per projection or can be cached. + +### 5.4 Laser tracker wait granularity (~0.2 s average on 28 % of scans). Easy. +`laser_tracker_wait_on_target` (`rt_flomni_ophyd.py:377-393`) sleeps 0.5 s between checks. +Use e.g. 0.05 s and scale `max_repeat` and the count==10 re-enable accordingly. The function is +also called twice per scan (`laser_tracker_on` and `laser_tracker_check_signalstrength`), and +each call does `laser_tracker_galil_enable()` plus a `Ts` query. + +### 5.5 Eiger staging tail. Investigate. +Median 0.24 s, mean 0.60 s, so some stages take seconds. Extract the +`staged for scan. Time spent X s` distribution and correlate with time and JungfrauJoch state. + +### 5.6 Small fixed sleeps +- `kickoff`: `shutdown_event.wait(0.1)` after `start_scan` (`rt_flomni_ophyd.py:963`). +- `move_samx_to_scan_region`: `sleep(0.05)` (`rt_flomni_ophyd.py:185`). +- `RtFlomniReadbackSignal._socket_get` has `sleep(0.1)` (`rt_flomni_ophyd.py:553`). Check how + often rt readbacks are read during the transition (e.g. `rty` set, baseline). + +### 5.7 Per-point dead time (§2). Biggest, hardest. +~30–50 ms/pt, i.e. 16 % of beamtime. Needs RT controller timing (step/settle time per point in +the `sd` point scan) and the trigger chain. Short exposures (0.05/0.1 s × 1 frame) are hit +hardest. Using more frames at shorter exposure (0.01 × 20) already amortizes it better. + +### 5.8 Interlock / Eiger-error recovery (~10 h) +Look at how long recovery after a shutter interlock takes vs the beam actually being back, +and why `EigerError` (314 alarms) happens. + +### 5.9 Better instrumentation +- Millisecond timestamps in the BEC log format would make §3 exact per scan. This is BEC core; + write it up rather than change it here. +- Alternatively, add `logger.info` lines with `time.perf_counter()` deltas inside + `FlomniFermatScan` hooks (prepare/stage/pre_scan/scan_core/post_scan/unstage/close). That is + csaxs_bec-local and enough to time every phase directly. +- Re-run the analysis on LamNI data (from 2026-10-12 on) for comparison. + +## 6. Ownership +Items in BEC core (`ScanStubStatus.wait`, log timestamp format) belong to the BEC core team. +Write them up rather than change them (see `AGENTS_mirko.md`). Everything in +`csaxs_bec/scans/*_fermat_scan.py`, `devices/omny/rt/` and the flomni client plugin can be +changed here. diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md index c3406b6f..a345ed9f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md @@ -59,7 +59,7 @@ Protocol fidelity is grounded in the actual hardware-side sources: `fgalil.dmc`, - `devices/sim/sim_sample_storage.py` — `SimFlomniSampleStorage` (patched EPICS, seedable via `sim_samples`, default slots 0–3 populated) - `device_configs/simulated_omny/` — **all** simulated configs live here: - `simulated_flomni.yaml` (hand-maintained), `simulated_lamni.yaml` and + `simulated_bl_simulated_flomni.yaml` (hand-maintained), `simulated_bl_simulated_lamni.yaml` and `simulated_omny.yaml` (generated), `simulated_bl_endstation.yaml` (pre-existing) - `bin/generate_simulated_configs.py` — regenerates the LamNI/OMNY configs from `ptycho_lamni.yaml`/`ptycho_omny.yaml` (rerun after real-config changes) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md index 5a45ec95..3987c78d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md @@ -213,7 +213,7 @@ difference between *a reconfiguration tool* and *a remote control for the whole endstation*. **Decided with Mirko: `mokev` (energy) and `idgap` (undulator gap) — nothing else -yet.** Neither device is in the flomni sim config (`simulated_flomni.yaml`); both +yet.** Neither device is in the flomni sim config (`simulated_bl_simulated_flomni.yaml`); both live in `bl_frontend.yaml` (shared beamline front-end devices), so `move` on either is only exercisable against the real front end, not the flomni sim used for `TOMO_QUEUE_TESTING.md`. diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_GUI_TESTING.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_GUI_TESTING.md index fe37af3b..f333bfe5 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_GUI_TESTING.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_GUI_TESTING.md @@ -42,8 +42,8 @@ support on top of it: per-action fields always match whatever's actually in `flomni.py` — no hardcoded mirror. Lets you add steps in order (action + its own fields only — a device dropdown and target for `move`, three bounded spinboxes - for `move_foptz_with_shift`'s `foptz_target`/`manual_shift_x`/ - `manual_shift_y`, the first of which ranges over `foptz`'s live device + for `move_foptz_with_shift`'s `foptz_target`/`foptz_shift_x`/ + `foptz_shift_y`, the first of which ranges over `foptz`'s live device limits rather than a static number), remove a step, reorder steps within the job (move up/down), and set a label + idempotent flag (defaults to "every step idempotent" ANDed, same as the CLI, until you touch the @@ -52,8 +52,8 @@ support on top of it: 2. **Command-job-aware queue table.** `TomoQueueDialog`'s table gained a "Details" column; a command job shows `CMD` in the Type column and its step summary + idempotency marker in Details (e.g. - `move{'positions': {'ccm_energy': 6.2}} > move_foptz_with_shift{'foptz_target': - 15.0, 'manual_shift_x': 1.0, 'manual_shift_y': -2.0} [idem]` + `move(ccm_energy=6.2 keV) > move_foptz_with_shift(foptz_target=15.0 mm, + foptz_shift_x=1.0 um, foptz_shift_y=-2.0 um) [idem]` — literally the same string `flomni.tomo_queue_show()` prints). 3. **Self-describing tomo jobs.** "Add current params to queue" now stamps `"kind": "tomo"` and a fresh `"id"` on the job it writes, matching what @@ -212,10 +212,10 @@ file is exactly how a regression sneaks in. (should list `ccm_energy`, `idgap` per the current allow-list -- `foptz` is deliberately absent, see point 8) and a **numeric target field**. Pick a device, set a target, click **"Add step"**. Confirm a line appears in - the step list above, formatted like `move{'positions': {'ccm_energy': 6.2}}`. + the step list above, formatted like `move(ccm_energy=6.2 keV)`. 8. Switch the action dropdown to `move_foptz_with_shift`. Confirm the field area now shows three spinboxes: **foptz target (absolute)**, - **New manual_shift_x**, **New manual_shift_y**. Confirm + **New foptz_shift_x**, **New foptz_shift_y**. Confirm the foptz-target spinbox's range reflects `foptz`'s *live* device limits (try typing a value past the real limit, confirm the widget clamps it rather than accepting it -- this is the live-limit lookup, not a static diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_TESTING.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_TESTING.md index 8ea5ce73..4177661b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_TESTING.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_TESTING.md @@ -130,7 +130,7 @@ bec.config.load_demo_config(force=True) Against a running sim session this **overwrites the loaded simulated-flOMNI device config with BEC's demo devices**. Write our own fixture that reuses the plumbing (`bec_servers`, `bec_redis_fixture`, `bec_services_config_file_path`) but either -loads `device_configs/simulated_omny/simulated_flomni.yaml` explicitly or assumes the +loads `device_configs/simulated_omny/simulated_bl_simulated_flomni.yaml` explicitly or assumes the running session already has it. ### The fixture: `omny_e2e_tests/_bootstrap.py` + `omny_e2e_tests/conftest.py` → `flomni_sim` — BUILT @@ -280,8 +280,8 @@ interruptible mid-scan for the crash-resume tests (§4, B5). 14. **✅ Back-compat + real dispatch, combined.** `test_legacy_job_no_kind_defaults_to_tomo_and_command_moves_foptz`. A `kind`-less legacy queue entry still runs as a tomogram; in the same mixed-queue run, a - command job actually moves `foptz` and sets new `manual_shift_x`/ - `manual_shift_y` values via the real `move_foptz_with_shift` action + command job actually moves `foptz` and sets new `foptz_shift_x`/ + `foptz_shift_y` values via the real `move_foptz_with_shift` action path (the only sim-movable path now that `ftray` is no longer in the move allow-list; `foptz` is also the real device this action exists for, unlike `ftray`'s old sim-only stand-in role). @@ -357,8 +357,8 @@ still just its design. queue therefore never has to carry runtime values between steps; `kwargs` stays plain JSON. The same "bundle, don't split" idea also applies to a compound action with no runtime-dependent result but a correctness-critical pairing of writes -- - `move_foptz_with_shift` (moves `foptz` + sets new `manual_shift_x`/ - `manual_shift_y` values atomically, deliberately not reachable as two + `move_foptz_with_shift` (moves `foptz` + sets new `foptz_shift_x`/ + `foptz_shift_y` values atomically, deliberately not reachable as two separate steps) is the concrete example currently in the registry. - **Publish the registry via a global var** (`tomo_queue_actions`) on `Flomni.__init__` so the GUI builds its dropdowns/forms from it — no hardcoded mirror to drift (the diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 4083f98f..e561a3c7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -29,6 +29,7 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import ( filters_out_of_beam, warn_and_confirm, ) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.idle_time import account_projection_gap from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_angle_file_mixin import ( TomoAngleFileMixin, ) @@ -40,6 +41,7 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools, PtychoReconstructor, TomoIDManager, + stitch_tile_offsets, read_escape_suffix, ) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.ptouch_printer import PTouchLabelPrinter @@ -999,6 +1001,7 @@ class FlomniSampleTransferMixin: # The manual shifts belong to the previous sample; start the new one clean. self.manual_shift_x = 0.0 self.manual_shift_y = 0.0 + self.reset_foptz_shift() def ftransfer_modify_storage(self, position: int, used: int): if used: @@ -1652,6 +1655,7 @@ class _ProgressProxy: "estimated_finish_time": None, "heartbeat": None, "accumulated_idle_time": 0.0, + "recent_projection_gaps": [], # set when tomo_scan() reached its end: tomo_scan_resume() then has nothing to resume "tomo_completed": False, } @@ -1987,6 +1991,33 @@ class Flomni( manual_shift_y = _GlobalVarParam(0.0) + # foptz_shift_x/y (um, added like manual_shift_x/y): compensates the + # scan-center drift caused by moving foptz after alignment + # (interferometer mirrors not perfectly parallel to the foptz motion). + # Kept separate from manual_shift_x/y on purpose: NOT in + # _TOMO_SCAN_PARAM_NAMES, so queued tomo jobs don't snapshot/restore it + # and a queued foptz move's shift survives the following scan jobs. + # Zeroed at X-ray eye alignment, where the current foptz is recorded as + # foptz_shift_reference (baseline for a future calibration). + foptz_shift_x = _GlobalVarParam(0.0) + + foptz_shift_y = _GlobalVarParam(0.0) + + foptz_shift_reference = _GlobalVarParam(None) + + def reset_foptz_shift(self): + """Zero foptz_shift_x/y and record the current foptz as reference.""" + fx, fy = self.foptz_shift_x, self.foptz_shift_y + self.foptz_shift_x = 0.0 + self.foptz_shift_y = 0.0 + try: + self.foptz_shift_reference = float(dev.foptz.readback.get()) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Could not read foptz for foptz_shift_reference: {exc}") + self.foptz_shift_reference = None + if fx != 0.0 or fy != 0.0: + print(f"foptz shifts were reset to zero (previous values: x={fx} um, y={fy} um).") + # at_each_angle_hook is inherited from TomoQueueMixin: name of a # registered at_each_angle hook (see register_at_each_angle_hook()), or # None for the default per-projection behaviour. Snapshotted like any @@ -2564,28 +2595,14 @@ class Flomni( if self._min_scan_angle() <= angle < self._max_scan_angle() + 0.05: now = datetime.datetime.now() - prev_heartbeat_str = self.progress.get("heartbeat") - if prev_heartbeat_str is not None: - gap = (now - datetime.datetime.fromisoformat(prev_heartbeat_str)).total_seconds() - # Normal cadence between consecutive projections is roughly - # the acquisition time plus motor/readout overhead. A gap - # well beyond that means something interrupted the scan in - # between (beamline-down interlock pause, a crash + manual - # restart, ...) -- attribute the excess to idle time so it - # doesn't drag down the apparent scan rate used for the ETA - # below. The 5x/60s margins are a heuristic, not a precise - # timing model -- tune if it over/under-triggers in practice. - normal_cadence = max(60.0, 5 * self.tomo_countingtime * self.frames_per_trigger) - if gap > normal_cadence: - idle = gap - normal_cadence - self.progress["accumulated_idle_time"] = ( - self.progress.get("accumulated_idle_time", 0.0) + idle - ) - print( - f"Detected a {self._format_duration(gap)} gap since the last projection" - f" -- excluding {self._format_duration(idle)} from the ETA estimate." - ) - self.progress["heartbeat"] = now.isoformat() + # A gap well beyond this scan's measured typical gap (beamline-down + # pause, a crash + manual restart, ...) is excluded from the ETA. + idle = account_projection_gap(self.progress, now) + if idle > 0: + print( + "Detected a long gap since the last projection" + f" -- excluding {self._format_duration(idle)} from the ETA estimate." + ) print(f"Starting flOMNI scan for angle {angle} in subtomo {subtomo_number}") self._print_progress() @@ -2668,6 +2685,7 @@ class Flomni( - self.compute_additional_correction_y(angle) - self.compute_additional_correction_y_2(angle) + self.manual_shift_y + + self.foptz_shift_y ) # sum_offset_z = offsets[2] # Todo @@ -3620,14 +3638,16 @@ class Flomni( # correction_xeye_mu = self.align.lamni_compute_additional_correction_xeye_mu(angle) offsets = self.get_alignment_offset(angle) - sum_offset_x = offsets[0] + self.manual_shift_x + sum_offset_x = offsets[0] + self.manual_shift_x + self.foptz_shift_x sum_offset_y = ( offsets[1] - self.compute_additional_correction_y(angle) - self.compute_additional_correction_y_2(angle) + self.manual_shift_y + + self.foptz_shift_y ) - print(f"Constant shift: x={self.manual_shift_x}, y={self.manual_shift_y}") + print(f"Constant shift: x={self.manual_shift_x} um, y={self.manual_shift_y} um") + print(f"foptz shift: x={self.foptz_shift_x} um, y={self.foptz_shift_y} um") sum_offset_z = offsets[2] # TODO this fix is while the tracker z is broken probe_propagation = -sum_offset_z * 1e-6 @@ -3641,8 +3661,8 @@ class Flomni( start_scan_number = bec.queue.next_scan_number projection_start = time.perf_counter() - for stitch_x in range(-self.stitch_x, self.stitch_x + 1): - for stitch_y in range(-self.stitch_y, self.stitch_y + 1): + for stitch_x in stitch_tile_offsets(self.stitch_x): + for stitch_y in stitch_tile_offsets(self.stitch_y): # pylint: disable=undefined-variable corridor_size = self.corridor_size if self.corridor_size > 0 else None self._current_scan_list.append(bec.queue.next_scan_number) @@ -3744,15 +3764,17 @@ class Flomni( # --- alignment offset (same as tomo_scan_projection, no stitching) --- offsets = self.get_alignment_offset(angle) - sum_offset_x = offsets[0] + self.manual_shift_x + random_shift_x + sum_offset_x = offsets[0] + self.manual_shift_x + self.foptz_shift_x + random_shift_x sum_offset_y = ( offsets[1] - self.compute_additional_correction_y(angle) - self.compute_additional_correction_y_2(angle) + self.manual_shift_y + + self.foptz_shift_y + random_shift_y ) - print(f"Constant shift: x={self.manual_shift_x}, y={self.manual_shift_y}") + print(f"Constant shift: x={self.manual_shift_x} um, y={self.manual_shift_y} um") + print(f"foptz shift: x={self.foptz_shift_x} um, y={self.foptz_shift_y} um") sum_offset_z = offsets[2] # TODO this fix is while the tracker z is broken @@ -3866,7 +3888,10 @@ class Flomni( print(f"Stepsize microns = {self.tomo_shellstep}") print(f"Fermat asymmetry (x/y) = {self.fermat_asymmetry}") print(f"FOV (220/100) = {self.fovx}, {self.fovy}") - print(f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}") + print( + f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}" + f" ({self.stitch_x + 1} x {self.stitch_y + 1} tiles)" + ) print(f"Stitching overlap = {self.tomo_stitch_overlap}") if not self.single_point_instead_of_fermat_scan: expected_positions = self._expected_fermat_position_count() @@ -3882,6 +3907,8 @@ class Flomni( print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}") print(f" _manual_shift_x = {self.manual_shift_x}") print(f" _manual_shift_y = {self.manual_shift_y}") + print(f" _foptz_shift_x = {self.foptz_shift_x} (queue foptz moves)") + print(f" _foptz_shift_y = {self.foptz_shift_y}") print(f"Frames per trigger (burst) = {self.frames_per_trigger}") print(f"Single point instead of fermat = {self.single_point_instead_of_fermat_scan}") if self.single_point_instead_of_fermat_scan: @@ -3987,8 +4014,8 @@ class Flomni( self.stitch_x = 0 self.stitch_y = 0 else: - self.stitch_x = self._get_val("", self.stitch_x, int, min_=0, max_=10) - self.stitch_y = self._get_val("", self.stitch_y, int, min_=0, max_=10) + self.stitch_x = self._get_val(" (n+1 tiles)", self.stitch_x, int, min_=0, max_=10) + self.stitch_y = self._get_val(" (n+1 tiles)", self.stitch_y, int, min_=0, max_=10) self.ptycho_reconstruct_foldername = self._get_val( "Reconstruction queue ", self.ptycho_reconstruct_foldername, str ) @@ -4219,7 +4246,7 @@ class Flomni( # control for the whole endstation. ccm_energy/idgap are real front-end # devices (bl_frontend.yaml/bl_optics_hutch.yaml, not the flomni sim # config). foptz is deliberately NOT here -- it has its own dedicated - # "move_foptz_with_shift" action below so the new manual_shift_x/y + # "move_foptz_with_shift" action below so the new foptz_shift_x/y # correction can never be forgotten (see that action's docstring). _TOMO_QUEUE_MOVE_DEVICES = { "ccm_energy": {"label": "Energy", "unit": "keV"}, @@ -4250,7 +4277,7 @@ class Flomni( "idempotent": True, # absolute move + absolute param set, re-runs harmlessly "help": ( "Move foptz (FZP optics-Z) to an absolute target and set new " - "manual_shift_x/manual_shift_y values (replacing the current " + "foptz_shift_x/foptz_shift_y values (replacing the current " "ones) in the same step -- changing foptz shifts the aligned " "scan center, so this is the only way to queue a foptz move." ), @@ -4270,33 +4297,35 @@ class Flomni( # device can't be reached. "device_limits_from": "foptz", }, - "manual_shift_x": { + "foptz_shift_x": { "type": "float", "default": 0.0, "min": -10.0, "max": 10.0, "unit": "um", - "label": "New manual_shift_x", + "label": "New foptz_shift_x", }, - "manual_shift_y": { + "foptz_shift_y": { "type": "float", "default": 0.0, "min": -50.0, "max": 50.0, "unit": "um", - "label": "New manual_shift_y", + "label": "New foptz_shift_y", }, }, }, } def _queue_action_move_foptz_with_shift( - self, foptz_target: float, manual_shift_x: float, manual_shift_y: float + self, foptz_target: float, foptz_shift_x: float, foptz_shift_y: float ) -> None: - """Move foptz and set new manual_shift_x/y values together. + """Move foptz and set new foptz_shift_x/y values together. - manual_shift_x/manual_shift_y are replaced outright by the values - given here, not adjusted relative to whatever they were before. + foptz_shift_x/foptz_shift_y are replaced outright by the values + given here, not adjusted relative to whatever they were before. Not + manual_shift_x/y: every queued tomo job snapshots and restores + those, so the following scan job would overwrite them. foptz changes the FZP-to-sample distance, which shifts the aligned scan center (interferometer mirrors aren't perfectly parallel to the @@ -4304,9 +4333,9 @@ class Flomni( foptz is NOT in _TOMO_QUEUE_MOVE_DEVICES, so this is the only way to queue a foptz move, precisely so the shift can't be forgotten. - FUTURE: manual_shift_x/y could be auto-suggested from a calibration - proportional to the change in foptz, instead of typed in by hand -- - deliberately out of scope here. + FUTURE: foptz_shift_x/y could be auto-suggested from a calibration + proportional to (foptz - foptz_shift_reference), instead of typed in + by hand. Re-checks foptz's live limits at execution time too (not just add time, see TomoQueueMixin._validate_action_kwargs) -- the actual @@ -4321,8 +4350,8 @@ class Flomni( f"outside foptz's current limits ({lo}, {hi})." ) umv(dev.foptz, foptz_target) - self.manual_shift_x = manual_shift_x - self.manual_shift_y = manual_shift_y + self.foptz_shift_x = foptz_shift_x + self.foptz_shift_y = foptz_shift_y def _describe_tomo_job_line(self, job: dict) -> str: """flomni's tomo_queue_show() one-line summary: fov/type/step/ctime/range. diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index a3a94091..5d4d6939 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -262,6 +262,7 @@ class XrayEyeAlign: self.flomni.reset_tomo_alignment_fit() self.flomni.manual_shift_x = 0 self.flomni.manual_shift_y = 0 + self.flomni.reset_foptz_shift() self.flomni.lights_off() diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_centered_stitch_tiles.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_centered_stitch_tiles.md new file mode 100644 index 00000000..41d96066 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_centered_stitch_tiles.md @@ -0,0 +1,34 @@ +# TODO: centered stitch tiles (stitch n -> n+1 tiles) for omny + +Status: **not implemented for omny.** Decided and implemented for LamNI and +Flomni on 2026-10-21. + +## What was implemented elsewhere + +Stitch parameter `n` used to give `2n+1` tiles per direction (offsets +`-n .. +n`), so a single extra tile was impossible. Now `n` gives `n+1` +tiles placed symmetrically around the scan center, offsets `i - n/2` for +`i = 0..n`, in units of `fov - tomo_stitch_overlap`: + +| n | tiles | offsets | +|---|---|---| +| 0 | 1 | 0 | +| 1 | 2 | -0.5, +0.5 | +| 2 | 3 | -1, 0, +1 | + +Stored stitch values change meaning (old 1 == new 2); accepted by the user. +The reconstruction side needs no change. + +Shared helper: `stitch_tile_offsets(n)` in +`OMNY_shared/omny_general_tools.py`. + +## What to change in omny + +- `omny.py` `tomo_scan_projection`: replace + `range(-self.stitch_x, self.stitch_x + 1)` (and y) with + `stitch_tile_offsets(self.stitch_x)`. `cenx`/`ceny` already multiply the + offset by `(fov - overlap)`, so float offsets work as is. +- Anything counting scans per projection must use `(sx+1)*(sy+1)`. +- `tomo_parameters` prompt/printout: show the tile count, as in flomni. +- When documenting stitching in `omny.md` (see + `TODO_document_stitching.md`), describe the new `(n+1)` semantics. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md index 2ab11066..197a75bc 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md @@ -4,6 +4,10 @@ Status: **not implemented.** `docs/user/ptychography/omny.md` has zero mentions of stitching, same gap just fixed 2026-09-19 in `flomni.md` and `lamni.md`. +> Note 2026-10-21: LamNI/Flomni switched to `n+1` centered tiles; the +> formulas below are the old omny behaviour. See +> `TODO_centered_stitch_tiles.md` and document whichever omny then has. + ## What to add Omny's mechanism is essentially identical to Flomni's (same parameter diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_optics_z_shift.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_optics_z_shift.md new file mode 100644 index 00000000..c6b68ea9 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_optics_z_shift.md @@ -0,0 +1,47 @@ +# TODO: separate optics-Z shift (ofzpz_shift_x/y) for omny + +Status: **not implemented for omny.** Decided 2026-10-19; implemented for +LamNI (`loptz_shift_x/y`) and Flomni (`foptz_shift_x/y`) the same day. Omny +has no tomo queue yet (no `TomoQueueMixin`), so this goes in together with, +or after, a tomo-queue port. + +## What was implemented elsewhere + +Moving the FZP along the optical axis (loptz / foptz) after alignment +shifts the scan centre, because the interferometer mirrors are not +perfectly parallel to the stage motion. That drift used to be corrected +with `manual_shift_x/y`, but every queued tomo job snapshots and restores +`manual_shift_x/y` (they are in `_TOMO_SCAN_PARAM_NAMES`), so a queued +"move optics Z + set shift" command was overwritten by the next scan job. + +The fix is a second, independent set of non-rotating shifts: + +- `_shift_x/y`: global-var backed, **not** in + `_TOMO_SCAN_PARAM_NAMES`, added to the scan shift next to + `manual_shift_x/y` (`tomo_scan_projection()` and the other projection + paths), shown in `tomo_parameters()`. +- `_shift_reference`: optics-Z position at which the shifts were last + zeroed. Baseline for a future calibration (shift per mm of optics-Z + change), so a queued move can propose the shift values. +- `reset__shift()`: zeroes the shifts and records the reference; + called at the start of the X-ray eye alignment (and in Flomni also on + sample change, where `manual_shift_x/y` are reset too). +- Tomo-queue command action `move__with_shift`: moves optics Z and + sets the new `_shift_x/y` in one step. LamNI additionally refuses + the move if the OSA at its current position would collide with the + sample holder. + +LamNI: `lamni.py` (search `loptz_shift`), Flomni: `flomni.py` (search +`foptz_shift`). + +## What's needed for omny + +1. Confirm the optics-Z device (`ofzpz`?) and the units of omny's + `manual_shift_x/y` (printed as mm in `tomo_parameters()`). +2. Add `ofzpz_shift_x/y`, `ofzpz_shift_reference`, `reset_ofzpz_shift()` + and apply the shift wherever `manual_shift_x/y` are applied. +3. Call the reset at X-ray eye alignment start. +4. Once omny has a tomo queue: add `move_ofzpz_with_shift`. +5. Check whether omny's RT controller locks the optics-Z axis read-only with + feedback on (LamNI's did; removed 2026-10-19 so loptz moves with feedback + running, like flomni's foptz). diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index f6f377c3..db914045 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -52,8 +52,13 @@ from typing import Any, Optional from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints from bec_widgets import BECWidget, SafeSlot -from qtpy.QtCore import Qt, QTimer +from qtpy.QtCore import QEvent, QObject, Qt, QTimer +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import ( + format_command_step, +) from qtpy.QtWidgets import ( + QAbstractSpinBox, + QApplication, QCheckBox, QComboBox, QDialog, @@ -283,7 +288,7 @@ LAMNI_DEFAULTS: dict[str, Any] = { "tomo_type": 1, "tomo_angle_file": "", "tomo_angle_stepsize": 10.0, - "golden_ratio_bunch_size": 20, + "golden_ratio_bunch_size": 100, "golden_max_number_of_projections": 1000.0, "golden_projections_at_0_deg_for_damage_estimation": 0, "zero_deg_reference_at_each_subtomo": False, @@ -343,6 +348,51 @@ def _hline() -> QFrame: return line +class _NoWheelFilter(QObject): + """Stop the mouse wheel from changing spin/combo box values; the wheel + event goes to the scroll area instead, so scrolling the panel over a + field scrolls the panel and cannot change a value unnoticed.""" + + def __init__(self, viewport: QWidget): + super().__init__(viewport) + self._viewport = viewport + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Wheel: + QApplication.sendEvent(self._viewport, event) + return True + return super().eventFilter(obj, event) + + +def _block_wheel_edits(container: QWidget, viewport: QWidget) -> None: + """Install a _NoWheelFilter on every spin and combo box in *container*.""" + wheel_filter = _NoWheelFilter(viewport) + for w in container.findChildren(QAbstractSpinBox) + container.findChildren(QComboBox): + w.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + w.installEventFilter(wheel_filter) + + +class _SelectAllOnFocusFilter(QObject): + """Select a spin box's whole text when it gets focus, so a typed number + replaces the value. Otherwise a click leaves the cursor at the end and + the typed digits are appended: e.g. bunch size 100 + "5" -> 1005, above + the maximum, so the spin box silently rejects every keystroke.""" + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.FocusIn: + # deferred: the mouse press that gave focus places the cursor + # (and clears the selection) after this event + QTimer.singleShot(0, obj.selectAll) + return super().eventFilter(obj, event) + + +def _select_all_on_focus(container: QWidget) -> None: + """Install a _SelectAllOnFocusFilter on every spin box in *container*.""" + focus_filter = _SelectAllOnFocusFilter(container) + for w in container.findChildren(QAbstractSpinBox): + w.installEventFilter(focus_filter) + + # ── main widget ────────────────────────────────────────────────────────────── @@ -521,6 +571,8 @@ class OMNY_TomoParams(BECWidget, QWidget): params_vbox.addLayout(self._build_edit_buttons()) params_vbox.addStretch() params_scroll.setWidget(params_inner) + _block_wheel_edits(params_inner, params_scroll.viewport()) + _select_all_on_focus(params_inner) root.addWidget(params_scroll) self._btn_queue = QPushButton("☰ Queue control…") @@ -611,7 +663,9 @@ class OMNY_TomoParams(BECWidget, QWidget): # fov/step/stitch/piezo-range fields above change, flagged orange if # below the scan server's own minimum (see _update_fermat_position_preview()). self._lbl_fermat_positions = QLabel("---") - common_form.addRow("Estimated Fermat scan points:", self._lbl_fermat_positions) + common_form.addRow( + "Estimated Fermat scan points (smallest tile):", self._lbl_fermat_positions + ) for key in self._profile["fermat_position_fields"]: widget = self._pw.get(key) if widget is not None: @@ -813,7 +867,7 @@ class OMNY_TomoParams(BECWidget, QWidget): form, "_numprj_type3", "Projections per sub-tomo (type 3)", min_=1, max_=10000 ) # per-setup floor (SETUP_PROFILES[...]["golden_bunch_size_min"]): lamni's - # golden_ratio_bunch_size setter/CLI reject < 20, so a GUI-legal + # golden_ratio_bunch_size setter/CLI reject values outside 30..200, so a GUI-legal # smaller value would raise ValueError in tomo_queue_execute() (which # setattr()s every job param) and pause the queue. self._add_int( @@ -821,7 +875,7 @@ class OMNY_TomoParams(BECWidget, QWidget): "golden_ratio_bunch_size", "Bunch size (type 2 only)", min_=self._profile.get("golden_bunch_size_min", 1), - max_=100, + max_=self._profile.get("golden_bunch_size_max", 100), ) self._add_double( form, @@ -1159,7 +1213,8 @@ class OMNY_TomoParams(BECWidget, QWidget): count, min_positions = self._profile["compute_fermat_positions"](params) if count < min_positions: return ( - f"Estimated Fermat scan points ({count}) is below the minimum of " + f"Estimated Fermat scan points ({count}, smallest stitch tile) is below the " + f"minimum of " f"{min_positions} required for the scan to run -- increase FOV, " "reduce step size, or adjust stitch/piezo range before submitting." ) @@ -1836,6 +1891,7 @@ class TomoQueueDialog(QDialog): def _refresh(self) -> None: jobs = self._load_queue() registered_hooks = self._gv_get("tomo_at_each_angle_hooks") or [] + action_registry = self._gv_get("tomo_queue_actions") or {} self._table.setRowCount(len(jobs)) for row, job in enumerate(jobs): status = job.get("status", "pending") @@ -1849,7 +1905,7 @@ class TomoQueueDialog(QDialog): "", "", "", - _format_command_summary(job), + _format_command_summary(job, action_registry), ] else: params = job.get("params", {}) @@ -1863,7 +1919,7 @@ class TomoQueueDialog(QDialog): _fmt_num(params.get("tomo_shellstep")), _format_hook_detail(params, registered_hooks), ] - tooltip = _job_tooltip(job, self._profile) + tooltip = _job_tooltip(job, registry=action_registry, profile=self._profile) for col, text in enumerate(cells): item = QTableWidgetItem(text) # Label/Details are now wide, free-text columns -- left-align @@ -2317,8 +2373,7 @@ class CommandJobBuilderDialog(QDialog): action_name = step.get("action") kwargs = step.get("kwargs", {}) self._steps.append({"action": action_name, "kwargs": kwargs}) - summary = f"{action_name}{kwargs}" if kwargs else action_name - self._step_list.addItem(summary) + self._step_list.addItem(self._step_summary(action_name, kwargs)) # The job's actual saved flag, not a recomputed default -- mark # overridden so _recompute_idempotent_default() leaves it alone as # the operator edits steps. @@ -2405,6 +2460,8 @@ class CommandJobBuilderDialog(QDialog): if action_name == "move_foptz_with_shift" and "foptz_target" in self._step_field_widgets: self._build_osa_collision_warning() + if action_name == "move_loptz_with_shift" and "loptz_target" in self._step_field_widgets: + self._build_lamni_osa_collision_warning() def _build_osa_collision_warning(self) -> None: """Live, advisory-only OSA-collision warning for @@ -2460,6 +2517,66 @@ class CommandJobBuilderDialog(QDialog): foptz_widget.valueChanged.connect(_update) _update() + def _build_lamni_osa_collision_warning(self) -> None: + """LamNI counterpart of _build_osa_collision_warning() for + move_loptz_with_shift's loptz_target field: advisory warning against + losaz's *current* position (the OSA rides on loptz, so the queue + action itself refuses that case at execution time) and its defined + IN position (would collide if the OSA were swapped in later). + """ + from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin import ( + compute_osa_remaining_space, + ) + + lbl = QLabel() + lbl.setWordWrap(True) + lbl.setStyleSheet("color: #B8860B;") + lbl.setVisible(False) + self._field_form.addRow("", lbl) + + loptz_widget, _field = self._step_field_widgets["loptz_target"] + + def _update(_value=None): + devices = self._client.device_manager.devices + loptz, losaz = devices.get("loptz"), devices.get("losaz") + if loptz is None or losaz is None: + return + try: + collision_offset = (loptz.user_parameter or {}).get("collision_offset") + losaz_in = (losaz.user_parameter or {}).get("in") + losaz_now = losaz.readback.get() + except Exception: + return + if collision_offset is None: + return + target = loptz_widget.value() + msgs = [] + clearance_now = compute_osa_remaining_space(target, losaz_now, collision_offset) + if clearance_now <= 0: + msgs.append( + f"at the current losaz ({losaz_now:.2f} mm) OSA clearance would be " + f"{clearance_now:.2f} mm -- the queued move will be refused" + ) + if losaz_in is not None: + clearance_in = compute_osa_remaining_space(target, losaz_in, collision_offset) + if clearance_in <= 0: + msgs.append( + f"at losaz's defined IN position ({losaz_in:.2f} mm) OSA clearance " + f"would be {clearance_in:.2f} mm" + ) + if msgs: + lbl.setText( + "Warning: at this loptz target, " + + "; ".join(msgs) + + " (<=0 = collision risk with a normal sample holder)." + ) + lbl.setVisible(True) + else: + lbl.setVisible(False) + + loptz_widget.valueChanged.connect(_update) + _update() + def _build_field_widget(self, field: dict) -> QWidget: field_type = field.get("type") if field_type == "float": @@ -2526,10 +2643,13 @@ class CommandJobBuilderDialog(QDialog): kwargs[param_name] = value self._steps.append({"action": action_name, "kwargs": kwargs}) - summary = f"{action_name}{kwargs}" if kwargs else action_name - self._step_list.addItem(summary) + self._step_list.addItem(self._step_summary(action_name, kwargs)) self._recompute_idempotent_default() + def _step_summary(self, action_name: str, kwargs: dict) -> str: + registry = {"actions": self._actions, "move_devices": self._move_devices} + return format_command_step({"action": action_name, "kwargs": kwargs}, registry) + def _on_remove_step(self) -> None: row = self._step_list.currentRow() if row < 0: @@ -2795,47 +2915,35 @@ def _compute_fermat_positions_omny(params: dict[str, Any]) -> tuple[int, int]: def _compute_fermat_positions_lamni(params: dict[str, Any]) -> tuple[int, int]: - """Lamni sibling of _compute_fermat_positions_flomni(): calls - LamniFermatScan.get_lamni_fermat_spiral_pos() directly -- including its - rotated-stage and circular-FOV (tomo_circfov) cropping, which can matter - a lot and would be easy to get subtly wrong in a reimplementation. - Evaluated at the *currently edited* stitch tile and angle=0 (lamni has - no angle field in this widget -- the real per-projection count can vary - a bit with angle, since the circular crop is angle-dependent). - - The real scan also draws a fresh random sub-step offset on every call - (see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a further - small amount of per-projection count variation on top of the - angle/stitch dependence above. A fixed seed is used here only so this - estimate is itself reproducible. + """Lamni sibling of _compute_fermat_positions_flomni(), via + lamni_smallest_tile_position_count(): the real scan algorithm + (LamniFermatScan.get_lamni_fermat_spiral_pos(), including its + rotated-stage and circular-FOV cropping) run for every stitch tile, and + the smallest count returned. With a circular FOV the corner tiles can + fall below the minimum while the center tile is fine, and any such tile + aborts the projection, so submit is blocked on the smallest one. Returns: (estimated_count, minimum_required) """ + from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import ( + lamni_smallest_tile_position_count, + ) from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan - piezo_x = params.get("lamni_piezo_range_x", 0.0) - piezo_y = params.get("lamni_piezo_range_y", 0.0) step = params.get("tomo_shellstep", 0.0) if step <= 0: return 0, LamniFermatScan.MIN_POSITIONS - positions = LamniFermatScan.get_lamni_fermat_spiral_pos( - -abs(piezo_x / 2), - abs(piezo_x / 2), - -abs(piezo_y / 2), - abs(piezo_y / 2), - step=step, - spiral_type=0, - angle=0.0, - stitch_x=params.get("lamni_stitch_x", 0), - stitch_y=params.get("lamni_stitch_y", 0), - stitch_overlap=params.get("tomo_stitch_overlap", 1.0), - fovx=piezo_x, - fovy=piezo_y, - fov_circular=params.get("tomo_circfov", 0.0), - seed=0, + count, _tile = lamni_smallest_tile_position_count( + params.get("lamni_piezo_range_x", 0.0), + params.get("lamni_piezo_range_y", 0.0), + step, + params.get("lamni_stitch_x", 0), + params.get("lamni_stitch_y", 0), + params.get("tomo_stitch_overlap", 1.0), + params.get("tomo_circfov", 0.0), ) - return len(positions), LamniFermatScan.MIN_POSITIONS + return count, LamniFermatScan.MIN_POSITIONS def _lamni_get_tomo_fov_offset(client, axis: str) -> float: @@ -2945,9 +3053,10 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { # laminography: 180-deg-opposite angles are not redundant "angle_file_opposite_warning": False, "has_zero_deg_reference": True, - # lamni.golden_ratio_bunch_size setter/CLI require >= 20 (see + # lamni.golden_ratio_bunch_size setter/CLI require 30..200 (see # _build_params_panel's bunch-size spinbox) - "golden_bunch_size_min": 20, + "golden_bunch_size_min": 30, + "golden_bunch_size_max": 200, "fov_fields": [("tomo_circfov", "Circular FOV (µm, 0 = disabled)", 0.0, 500.0, 2)], "stitch_fields": [ ("lamni_stitch_x", "Stitch x", 0, 50), @@ -3145,14 +3254,12 @@ def _job_kind(job: dict) -> str: return job.get("kind", "tomo") -def _format_command_summary(job: dict) -> str: - """Mirrors flomni.py's tomo_queue_show() command-job rendering exactly, - so the CLI and this table read as the same list.""" +def _format_command_summary(job: dict, registry: dict | None = None) -> str: + """Mirrors tomo_queue_show()'s command-job rendering exactly (same + format_command_step(), with units), so the CLI and this table read as + the same list.""" steps = job.get("steps", []) - parts = [ - f"{step['action']}{step['kwargs']}" if step.get("kwargs") else step["action"] - for step in steps - ] + parts = [format_command_step(step, registry) for step in steps] idem = "idem" if job.get("idempotent") else "NOT idem" return " > ".join(parts) + f" [{idem}]" @@ -3170,7 +3277,7 @@ def _format_hook_detail(params: dict, registered_hooks: list) -> str: return f"hook: {hook_name} (not registered)" -def _job_tooltip(job: dict, profile: Optional[dict] = None) -> str: +def _job_tooltip(job: dict, registry: dict | None = None, profile: Optional[dict] = None) -> str: """Full-detail multi-line description of a queue job, applied to every cell in its row so hovering anywhere on the row shows what the Label and Details columns truncate at typical dialog widths.""" @@ -3179,9 +3286,7 @@ def _job_tooltip(job: dict, profile: Optional[dict] = None) -> str: lines.append(f"Idempotent: {'yes' if job.get('idempotent') else 'no'}") lines.append("Steps:") for i, step in enumerate(job.get("steps", []), start=1): - kwargs = step.get("kwargs") or {} - step_str = f"{step['action']}({kwargs})" if kwargs else step["action"] - lines.append(f" {i}. {step_str}") + lines.append(f" {i}. {format_command_step(step, registry)}") else: lines.append("Params:") params = job.get("params", {}) diff --git a/csaxs_bec/device_configs/bl_endstation.yaml b/csaxs_bec/device_configs/bl_endstation.yaml index 9e8c6419..d064ca80 100644 --- a/csaxs_bec/device_configs/bl_endstation.yaml +++ b/csaxs_bec/device_configs/bl_endstation.yaml @@ -85,7 +85,7 @@ shutter_es: ################## XBOX 1 ES ##################### xbpm3x: - description: X-ray beam position x monitor 1 in ESbox1 + description: XBPM3 (first XBox in ES hutch, beamline XBox 2) stage x deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor deviceConfig: axis_Id: A @@ -111,7 +111,7 @@ xbpm3x: - bl_endstation xbpm3y: - description: X-ray beam position y monitor 1 in ESbox1 + description: XBPM3 (first XBox in ES hutch, beamline XBox 2) stage y deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor deviceConfig: axis_Id: B @@ -242,7 +242,7 @@ sl4xc: deviceConfig: left_slit: sl4xi right_slit: sl4xo - offset: 0 + offset: 0.356025 enabled: true onFailure: retry readOnly: false @@ -260,7 +260,7 @@ sl4xs: deviceConfig: left_slit: sl4xi right_slit: sl4xo - offset: 0 + offset: -0.358556 enabled: true onFailure: retry readOnly: false @@ -277,7 +277,7 @@ sl4yc: deviceConfig: left_slit: sl4yb right_slit: sl4yt - offset: 0 + offset: 1.049211 enabled: true onFailure: retry readOnly: false @@ -295,7 +295,7 @@ sl4ys: deviceConfig: left_slit: sl4yb right_slit: sl4yt - offset: 0 + offset: -0.260001 enabled: true onFailure: retry readOnly: false @@ -589,7 +589,7 @@ sl5xc: deviceConfig: left_slit: sl5xi right_slit: sl5xo - offset: 0 + offset: 0.515760 enabled: true onFailure: retry readOnly: false @@ -607,7 +607,7 @@ sl5xs: deviceConfig: left_slit: sl5xi right_slit: sl5xo - offset: 0 + offset: -0.709994 enabled: true onFailure: retry readOnly: false @@ -625,7 +625,7 @@ sl5yc: deviceConfig: left_slit: sl5yb right_slit: sl5yt - offset: 0 + offset: 1.200745 enabled: true onFailure: retry readOnly: false @@ -643,7 +643,7 @@ sl5ys: deviceConfig: left_slit: sl5yb right_slit: sl5yt - offset: 0 + offset: -0.609914 enabled: true onFailure: retry readOnly: false @@ -763,7 +763,7 @@ sl6xc: deviceConfig: left_slit: sl6xi right_slit: sl6xo - offset: 0 + offset: -1.796317 enabled: true onFailure: retry readOnly: false @@ -780,7 +780,7 @@ sl6xs: deviceConfig: left_slit: sl6xi right_slit: sl6xo - offset: 0 + offset: -0.499822 enabled: true onFailure: retry readOnly: false @@ -797,7 +797,7 @@ sl6yc: deviceConfig: left_slit: sl6yb right_slit: sl6yt - offset: 0 + offset: 0.903523 enabled: true onFailure: retry readOnly: false @@ -814,7 +814,7 @@ sl6ys: deviceConfig: left_slit: sl6yb right_slit: sl6yt - offset: 0 + offset: -0.110837 enabled: true onFailure: retry readOnly: false @@ -1121,7 +1121,7 @@ dettrx: # #################### -# ### BPM XBox2 (first Xbox in ES) and Xbox3 BIM +# ### XBPM3 (first XBox in ES hutch, beamline XBox 2) and XBIM (beamline XBox 3) # ### This requires galilrioesxbox device. On top of that the gain control devices and slow readback devices are built. # #################### @@ -1138,8 +1138,8 @@ galilrioesxbox: deviceTags: - bl_endstation -gain_bpm_xbox2: - description: Gain control for BPM XBox2 +xbpm3_gain: + description: XBPM3 (first XBox in ES hutch, beamline XBox 2) gain control deviceClass: csaxs_bec.devices.pseudo_devices.bpm_control.BPMControl deviceConfig: gain_lsb: galilrioesxbox.digital_out.ch0 # Pin 10 -> Galil ch0 @@ -1155,14 +1155,15 @@ gain_bpm_xbox2: deviceTags: - bl_endstation -bpm_xbox2_slowrb: - description: BPM Xbox 2 (First Xbox in ES hutch) readback (recommended gain 1e8) +xbpm3_slowrb: + description: XBPM3 (first XBox in ES hutch, beamline XBox 2) Galil slow readback (recommended gain 1e8) deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM deviceConfig: left_top_ref: galilrioesxbox.analog_in.ch0 right_top_ref: galilrioesxbox.analog_in.ch1 right_bot_ref: galilrioesxbox.analog_in.ch2 left_bot_ref: galilrioesxbox.analog_in.ch3 + invert: true # amplifier output is negative; positive values = more beam enabled: true readoutPriority: monitored onFailure: retry @@ -1171,8 +1172,8 @@ bpm_xbox2_slowrb: deviceTags: - bl_endstation -gain_bim_xbox3: - description: Gain control for beam intensity monitor ES Xbox3 +xbim_gain: + description: XBIM beam intensity monitor (ES hutch, beamline XBox 3) gain control deviceClass: csaxs_bec.devices.pseudo_devices.bpm_control.BPMControl deviceConfig: gain_lsb: galilrioesxbox.digital_out.ch6 # Pin 10 -> Galil ch0 @@ -1188,8 +1189,8 @@ gain_bim_xbox3: deviceTags: - bl_endstation -bim_xbox3_slowrb: - description: Beam intensity slow readback ES XBox3 +xbim_slowrb: + description: XBIM beam intensity monitor (ES hutch, beamline XBox 3) Galil slow readback deviceClass: csaxs_bec.devices.pseudo_devices.signal_forwarder.SignalForwarder deviceConfig: signal_ref: galilrioesxbox.analog_in.ch6 diff --git a/csaxs_bec/device_configs/bl_frontend.yaml b/csaxs_bec/device_configs/bl_frontend.yaml index 7a251bb9..029fb1a4 100644 --- a/csaxs_bec/device_configs/bl_frontend.yaml +++ b/csaxs_bec/device_configs/bl_frontend.yaml @@ -99,6 +99,8 @@ sl1xc: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: 0.122368 deviceTags: - bl_frontend @@ -112,6 +114,8 @@ sl1xs: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: -0.0021 deviceTags: - bl_frontend @@ -125,6 +129,8 @@ sl1yc: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: -0.046220 deviceTags: - bl_frontend @@ -138,14 +144,15 @@ sl1ys: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: -0.101010 deviceTags: - bl_frontend ##################################### #### XBPM ########################### ##################################### -# Note: The following device may not be relevant anymore -# and can be fully replaced by the combined device "xbpm1", see below +# The single current channels of XBPM1; the combined device is "xbpm1", see below xbpm1c1: description: 'XBPM1 (frontend) current 1' @@ -199,27 +206,6 @@ xbpm1c4: deviceTags: - bl_frontend -bpm1: - description: 'XBPM1 (frontend)' - deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM - deviceConfig: - left_top_ref: xbpm1c1 - right_top_ref: xbpm1c2 - right_bot_ref: xbpm1c3 - left_bot_ref: xbpm1c4 - onFailure: raise - enabled: true - readoutPriority: monitored - readOnly: true - softwareTrigger: false - needs: - - xbpm1c1 - - xbpm1c2 - - xbpm1c3 - - xbpm1c4 - deviceTags: - - bl_frontend - ############################################ ######### End of xbpm sub devices ########## ############################################ diff --git a/csaxs_bec/device_configs/bl_optics_hutch.yaml b/csaxs_bec/device_configs/bl_optics_hutch.yaml index 411b809c..3808bd3a 100644 --- a/csaxs_bec/device_configs/bl_optics_hutch.yaml +++ b/csaxs_bec/device_configs/bl_optics_hutch.yaml @@ -60,6 +60,8 @@ sl2xc: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: 0.050061 deviceTags: - bl_optics_hutch @@ -73,6 +75,8 @@ sl2xs: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: -0.851018 deviceTags: - bl_optics_hutch @@ -86,6 +90,8 @@ sl2yc: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: 0.6703 deviceTags: - bl_optics_hutch @@ -99,6 +105,8 @@ sl2ys: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: -0.8545 deviceTags: - bl_optics_hutch @@ -191,7 +199,7 @@ ccm_energy: ########################################################################## xbpm2x: - description: X-ray beam position monitor 1 in OPbox + description: XBPM2 (optics hutch, XBox 1) stage x deviceClass: csaxs_bec.devices.mcs2.mcs2_ophyd.Mcs2Motor deviceConfig: axis_Id: A @@ -216,7 +224,7 @@ xbpm2x: - bl_optics_hutch xbpm2y: - description: X-ray beam position monitor 1 in OPbox + description: XBPM2 (optics hutch, XBox 1) stage y deviceClass: csaxs_bec.devices.mcs2.mcs2_ophyd.Mcs2Motor deviceConfig: axis_Id: B @@ -355,8 +363,8 @@ galilrioop: deviceTags: - bl_optics_hutch -# gain_bpm_xbox1: -# description: Gain control for BPM XBox1 (OP hutch) +# xbpm2_gain: +# description: XBPM2 (optics hutch, XBox 1) gain control # deviceClass: csaxs_bec.devices.pseudo_devices.bpm_control.BPMControl # deviceConfig: # gain_lsb: galilrioop.digital_out.ch0 # Pin 10 -> Galil ch0 @@ -372,8 +380,8 @@ galilrioop: # deviceTags: # - bl_optics_hutch -# bpm_xbox1_slowrb: -# description: BPM Xbox 1 (OP hutch) readback +# xbpm2_slowrb: +# description: XBPM2 (optics hutch, XBox 1) Galil slow readback # deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM # deviceConfig: # left_top_ref: galilrioop.analog_in.ch0 @@ -493,6 +501,8 @@ sl3xc: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: -0.029090 deviceTags: - bl_optics_hutch @@ -506,6 +516,8 @@ sl3xs: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: 2.749977 deviceTags: - bl_optics_hutch @@ -519,6 +531,8 @@ sl3yc: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: 0.290816 deviceTags: - bl_optics_hutch @@ -532,6 +546,8 @@ sl3ys: readoutPriority: baseline readOnly: false softwareTrigger: false + userParameter: + epics_offset: -2.640883 deviceTags: - bl_optics_hutch diff --git a/csaxs_bec/device_configs/ptycho_lamni.yaml b/csaxs_bec/device_configs/ptycho_lamni.yaml index 8c0169ec..8ae80981 100644 --- a/csaxs_bec/device_configs/ptycho_lamni.yaml +++ b/csaxs_bec/device_configs/ptycho_lamni.yaml @@ -133,8 +133,8 @@ lsamrot: axis_Id: C host: mpc2680.psi.ch limits: - - 0 - - 0 + - -2 + - 362 port: 8081 sign: 1 tolerance: 1 @@ -349,6 +349,36 @@ rt_positions: deviceTags: - ptycho_lamni +############################################################ +################ Keithley 2400 current source ############## +############################################################ +# RS-232 through the TCP-serial bridge on mpc2680 (service lamni-serial-keithley, TCP 3336). +# The Keithley is not always connected; onFailure: buffer keeps the session running without it. +# compliance_voltage: the output never drives more than this, also with the load disconnected. +# remote_sense: true needs the sense leads connected at the load. +keithley: + description: Keithley 2400 current source + deviceClass: csaxs_bec.devices.keithley.keithley_2400.Keithley2400 + deviceConfig: + host: mpc2680.psi.ch + port: 3336 + limits: + - 0 + - 3 + tolerance: 0.01 + compliance_voltage: 5 + source_range_ma: 10 + remote_sense: false + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + softwareTrigger: false + connectionTimeout: 20 + deviceTags: + - ptycho_lamni + - keithley + ############################################################ ######################### Cameras ########################## ############################################################ diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_flomni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_real_flomni.yaml similarity index 90% rename from csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_flomni.yaml rename to csaxs_bec/device_configs/simulated_omny/simulated_bl_real_flomni.yaml index 2019acf7..02b2f8de 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_flomni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_bl_real_flomni.yaml @@ -9,7 +9,7 @@ ddg1: readoutPriority: baseline softwareTrigger: true deviceTags: - - simulated_bl_endstation_flomni + - simulated_bl_real_flomni fsh: description: Simulated fast shutter manual control and readback deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.cSAXSSimulatedFastShutter @@ -19,7 +19,7 @@ fsh: enabled: true readoutPriority: monitored deviceTags: - - simulated_bl_endstation_flomni + - simulated_bl_real_flomni flomni: - !include ../ptycho_flomni.yaml diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_real_lamni.yaml similarity index 89% rename from csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_lamni.yaml rename to csaxs_bec/device_configs/simulated_omny/simulated_bl_real_lamni.yaml index 2217767c..f4adf475 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_lamni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_bl_real_lamni.yaml @@ -9,7 +9,7 @@ ddg1: readoutPriority: baseline softwareTrigger: true deviceTags: - - simulated_bl_endstation_lamni + - simulated_bl_real_lamni fsh: description: Simulated fast shutter manual control and readback deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.cSAXSSimulatedFastShutter @@ -19,9 +19,9 @@ fsh: enabled: true readoutPriority: monitored deviceTags: - - simulated_bl_endstation_lamni + - simulated_bl_real_lamni -flomni: +lamni: - !include ../ptycho_lamni.yaml # machine: diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_flomni.yaml similarity index 93% rename from csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml rename to csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_flomni.yaml index e60f63ab..ec4d2bac 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_flomni.yaml @@ -28,7 +28,7 @@ omny_xray_gui: readOnly: false readoutPriority: on_request deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ############################################################ #################### flOMNI Galil motors ################### @@ -59,7 +59,7 @@ feyex: fttrx_in: 2.3 fttrx_out: -24 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni feyey: description: Xray eye Y (simulated) @@ -83,7 +83,7 @@ feyey: userParameter: in: -10.09 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni fheater: description: Heater Y (simulated) @@ -108,7 +108,7 @@ fheater: in: -0.5 enabled: 1 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni foptx: description: Optics X (simulated) @@ -138,7 +138,7 @@ foptx: detector_distance: -1 # mm, sample-to-detector; unknown for now fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni fopty: description: Optics Y (simulated) @@ -164,7 +164,7 @@ fopty: in: 0.42 out: 0.57 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni foptz: description: Optics Z (simulated) @@ -188,7 +188,7 @@ foptz: userParameter: in: 23 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni fsamroy: description: Sample rotation (simulated) @@ -209,7 +209,7 @@ fsamroy: readoutPriority: baseline connectionTimeout: 20 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni fsamx: description: Sample coarse X (simulated) @@ -233,7 +233,7 @@ fsamx: userParameter: in: -1.14 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni fsamy: description: Sample coarse Y (simulated) @@ -257,7 +257,7 @@ fsamy: userParameter: in: 3 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ftracky: description: Laser Tracker coarse Y (simulated) @@ -279,7 +279,7 @@ ftracky: readoutPriority: baseline connectionTimeout: 20 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ftrackz: description: Laser Tracker coarse Z (simulated) @@ -301,7 +301,7 @@ ftrackz: readoutPriority: baseline connectionTimeout: 20 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ftransx: description: Sample transer X (simulated) @@ -323,7 +323,7 @@ ftransx: readoutPriority: baseline connectionTimeout: 20 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ftransy: description: Sample transer Y (simulated) @@ -349,7 +349,7 @@ ftransy: userParameter: sensor_voltage: -1.6 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ftransz: description: Sample transer Z (simulated) @@ -371,7 +371,7 @@ ftransz: readoutPriority: baseline connectionTimeout: 20 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ftray: description: Sample transfer tray (simulated) @@ -395,7 +395,7 @@ ftray: readoutPriority: baseline connectionTimeout: 20 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ############################################################ #################### flOMNI Smaract motors ################# @@ -424,7 +424,7 @@ fosax: in: 8.727079 out: 5.1 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni fosay: description: OSA Y (simulated) @@ -448,7 +448,7 @@ fosay: #170 micron, 60 nm, 7.9 kev in: -0.04603 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni fosaz: description: OSA Z (simulated) @@ -473,7 +473,7 @@ fosaz: in: 13.1 out: 6 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ############################################################ #################### flOMNI RT motors ###################### @@ -500,7 +500,7 @@ rtx: min_signal: 8000 rt_pid_voltage: -0.06219 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni rty: description: flomni rt (simulated) @@ -521,7 +521,7 @@ rty: userParameter: tomo_additional_offsety: 0 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni rtz: description: flomni rt (simulated) @@ -540,7 +540,7 @@ rtz: readoutPriority: on_request connectionTimeout: 20 deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni rt_positions: deviceClass: csaxs_bec.devices.sim.sim_rt_flomni.SimRtFlomniFlyer @@ -554,7 +554,7 @@ rt_positions: enabled: true readOnly: False deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ############################################################ ####################### Cameras ############################ @@ -574,7 +574,7 @@ cam_flomni_gripper: readOnly: false readoutPriority: on_request deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni cam_flomni_overview: description: Camera flomni overview (simulated) @@ -590,7 +590,7 @@ cam_flomni_overview: readOnly: false readoutPriority: on_request deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni cam_xeye: description: Camera flOMNI Xray eye ID1 (simulated) @@ -611,7 +611,7 @@ cam_xeye: userParameter: pixel_calibration: 0.00044247787610619477 # mm/pixel (= 0.05 / 113) deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ############################################################ #################### Sample storage ######################## @@ -632,7 +632,7 @@ flomni_samples: readOnly: false readoutPriority: baseline deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ############################################################ #################### Computed signal ####################### @@ -647,7 +647,7 @@ calculated_signal: readOnly: false readoutPriority: baseline deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni ############################################################ ######### Simulated beamline devices (shutter, DDG) ######## @@ -664,7 +664,7 @@ ddg1: readoutPriority: baseline softwareTrigger: true deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni fsh: description: Simulated fast shutter manual control and readback @@ -675,4 +675,4 @@ fsh: enabled: true readoutPriority: monitored deviceTags: - - simulated_flomni + - simulated_bl_simulated_flomni diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_lamni.yaml similarity index 93% rename from csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml rename to csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_lamni.yaml index 63600cb4..2d88d3ab 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_lamni.yaml @@ -26,7 +26,7 @@ leyex: userParameter: in: 14.117 deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni leyey: description: Xray eye Y deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor @@ -50,7 +50,7 @@ leyey: in: 48.069 out: 0.5 deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni loptx: description: Optics X deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor @@ -80,7 +80,7 @@ loptx: detector_distance: -1 # mm, sample-to-detector; unknown for now fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni lopty: description: Optics Y deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor @@ -105,7 +105,7 @@ lopty: in: 3.724 out: 3.53 deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni loptz: description: Optics Z deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor @@ -127,7 +127,7 @@ loptz: readoutPriority: baseline connectionTimeout: 20 deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni lsamrot: description: Sample rotation deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor @@ -150,7 +150,7 @@ lsamrot: readoutPriority: baseline connectionTimeout: 20 deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni lsamx: description: Sample coarse X deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor @@ -174,7 +174,7 @@ lsamx: userParameter: center: 8.768 deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni lsamy: description: Sample coarse Y deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor @@ -198,7 +198,7 @@ lsamy: userParameter: center: 10.041 deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni losax: description: OSA X deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor @@ -219,7 +219,7 @@ losax: userParameter: in: -1.442 deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni losay: description: OSA Y deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor @@ -241,7 +241,7 @@ losay: in: -0.171 out: 3.8 deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni losaz: description: OSA Z deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor @@ -263,7 +263,7 @@ losaz: in: -1 out: -3 deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni rtx: deviceClass: csaxs_bec.devices.sim.sim_lamni.SimRtLamniMotor deviceConfig: @@ -289,7 +289,7 @@ rtx: enabled: true readOnly: false deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni rty: deviceClass: csaxs_bec.devices.sim.sim_lamni.SimRtLamniMotor deviceConfig: @@ -308,7 +308,7 @@ rty: enabled: true readOnly: false deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni rt_positions: deviceClass: csaxs_bec.devices.sim.sim_lamni.SimRtLamniFlyer deviceConfig: @@ -321,7 +321,7 @@ rt_positions: enabled: true readOnly: false deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni cam_xeye: description: Camera LamNI Xray eye ID15 deviceClass: csaxs_bec.devices.sim.sim_cameras.SimIDSCamera @@ -354,7 +354,7 @@ cam_xeye: userParameter: pixel_calibration: 0.0009174311926605505 # mm/pixel (= 0.2 / 218) deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni ddg1: description: Simulated main delay generator for triggering deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.SimulatedDDG1 @@ -366,7 +366,7 @@ ddg1: readoutPriority: baseline softwareTrigger: true deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni fsh: description: Simulated fast shutter manual control and readback deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.cSAXSSimulatedFastShutter @@ -376,7 +376,7 @@ fsh: enabled: true readoutPriority: monitored deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni ############################################################ #################### GUI Signals ########################### @@ -390,4 +390,4 @@ omny_xray_gui: readOnly: false readoutPriority: on_request deviceTags: - - simulated_lamni + - simulated_bl_simulated_lamni diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_lamni_keithley.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_lamni_keithley.yaml new file mode 100644 index 00000000..90e22ade --- /dev/null +++ b/csaxs_bec/device_configs/simulated_omny/simulated_bl_simulated_lamni_keithley.yaml @@ -0,0 +1,27 @@ +# Simulated LamNI endstation with the real Keithley 2400. +# The keithley entry mirrors the one in ptycho_lamni.yaml (bridge on mpc2680, TCP 3336); +# keep both in sync. +lamni: + - !include ./simulated_bl_simulated_lamni.yaml + +keithley: + description: Keithley 2400 current source + deviceClass: csaxs_bec.devices.keithley.keithley_2400.Keithley2400 + deviceConfig: + host: mpc2680.psi.ch + port: 3336 + limits: + - 0 + - 3 + tolerance: 0.01 + compliance_voltage: 5 + source_range_ma: 10 + remote_sense: false + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + softwareTrigger: false + connectionTimeout: 20 + deviceTags: + - keithley diff --git a/csaxs_bec/devices/keithley/__init__.py b/csaxs_bec/devices/keithley/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/csaxs_bec/devices/keithley/keithley_2400.py b/csaxs_bec/devices/keithley/keithley_2400.py new file mode 100644 index 00000000..024bb4c5 --- /dev/null +++ b/csaxs_bec/devices/keithley/keithley_2400.py @@ -0,0 +1,423 @@ +""" +Keithley 2400 SourceMeter used as a current source, RS-232 reached through a TCP-serial bridge. + +The device is a positioner in mA, so ``umv(dev.keithley, 1.5)`` sources 1.5 mA. Voltage and +current are measured concurrently while the output is on. + +Output handling, deliberate because a load hangs on this instrument: +- ``:MEAS?`` switches the output on by itself and is therefore never sent. Readbacks use ``:READ?`` + and only while the output is on (it fails with the output off), reporting 0 otherwise. +- ``:SOUR:CLE:AUTO`` is kept off, otherwise ``:READ?`` would toggle the output. +- Connecting, disconnecting, stopping or a BEC restart leave the output and the level untouched. If + the output is on at connect, the source settings are not changed at all. +- A move programs the level first and only then switches the output on, so a stale level is + never sourced. The output is only switched off by ``output_off()``. + +Commands end in CR. Replies end in CR LF, set by the RS-232 terminator on the front panel. +""" + +from __future__ import annotations + +import threading +import time + +import numpy as np +from bec_lib.logger import bec_logger +from ophyd import Component as Cpt +from ophyd import Device, PositionerBase, Signal, SignalRO +from ophyd.status import MoveStatus +from ophyd.utils import LimitError, ReadOnlyError +from ophyd.utils.errors import InvalidState +from ophyd_devices.utils.controller import Controller, ControllerCommunicationError, threadlocked +from ophyd_devices.utils.socket import SocketIO, SocketSignal, raise_if_disconnected +from prettytable import PrettyTable + +logger = bec_logger.logger + + +class Keithley2400Error(Exception): + """Instrument in an unexpected state, or the requested current could not be reached.""" + + +class Keithley2400Controller(Controller): + """SCPI communication with the Keithley 2400.""" + + _axes_per_controller = 1 + READBACK_MAX_AGE = 0.1 # s, the voltage and current signals share one :READ? + + def _initialize(self): + super()._initialize() + self._reading = (0.0, 0.0) + self._reading_time = 0.0 + + @threadlocked + def socket_put(self, val: str) -> None: + self.command_history.append(f"[PUT]: {val}") + self.sock.put(f"{val}\r".encode()) + + @threadlocked + def socket_get(self) -> str: + """Read one reply. At 9600 baud a reply can arrive in several TCP segments.""" + buffer = b"" + while not buffer.endswith(b"\n"): + chunk = self.sock.receive() + if not chunk: + raise ConnectionError("Connection closed while waiting for a reply.") + buffer += chunk + response = buffer.decode().strip() + self.command_history.append(f"[GET]: {response}") + return response + + @threadlocked + def socket_put_and_receive(self, val: str, remove_trailing_chars: bool = True) -> str: + """Query, not retried: a late reply to a repeated query would shift all following replies.""" + try: + self.socket_put(val) + return self.socket_get() + except Exception as exc: + logger.error(f"Keithley query {val} failed: {exc}. {list(self.command_history)}") + raise ControllerCommunicationError(f"Keithley query {val} failed: {exc}") from exc + + @threadlocked + def configure( + self, compliance_voltage: float, source_range_ma: float, remote_sense: bool + ) -> None: + """Set up the current source. With the output on only harmless settings are sent.""" + idn = self.socket_put_and_receive("*IDN?") + if "MODEL 2400" not in idn: + raise Keithley2400Error(f"Unexpected instrument identification: {idn}") + self.socket_put(":SOUR:CLE:AUTO OFF") + self.socket_put(":FORM:ELEM VOLT,CURR") + + if self.output_enabled(): + source_function = self.socket_put_and_receive(":SOUR:FUNC?") + if source_function != "CURR": + raise Keithley2400Error( + f"Output is on with source function {source_function}, not CURR. " + "Check the instrument and switch the output off." + ) + actual = self.get_compliance_voltage() + if not np.isclose(actual, compliance_voltage): + logger.warning( + f"Keithley output is on, compliance stays at {actual} V " + f"(configured: {compliance_voltage} V)." + ) + else: + for command in ( + ":SOUR:FUNC CURR", + ":SOUR:CURR:MODE FIX", + ":SOUR:CURR:RANG:AUTO OFF", + f":SOUR:CURR:RANG {source_range_ma * 1e-3:.6E}", + ":SENS:FUNC:CONC ON", + ':SENS:FUNC:ON "VOLT","CURR"', + f":SYST:RSEN {'ON' if remote_sense else 'OFF'}", + ":ARM:COUN 1", + ":TRIG:COUN 1", + ): + self.socket_put(command) + self.set_compliance_voltage(compliance_voltage) + actual = self.get_compliance_voltage() + if not np.isclose(actual, compliance_voltage): + raise Keithley2400Error( + f"Compliance is {actual} V, expected {compliance_voltage} V." + ) + + error = self.socket_put_and_receive(":SYST:ERR?") + if not error.startswith("0,"): + raise Keithley2400Error(f"Instrument error after setup: {error}") + + def output_enabled(self) -> bool: + return self.socket_put_and_receive(":OUTP?") == "1" + + def set_output(self, enabled: bool) -> None: + self.socket_put(f":OUTP {'ON' if enabled else 'OFF'}") + self._reading_time = 0.0 + + def get_level(self) -> float: + """Programmed source level in A.""" + return float(self.socket_put_and_receive(":SOUR:CURR?")) + + def set_level(self, amps: float) -> None: + self.socket_put(f":SOUR:CURR {amps:.6E}") + self._reading_time = 0.0 + + def get_compliance_voltage(self) -> float: + return float(self.socket_put_and_receive(":SENS:VOLT:PROT?")) + + def set_compliance_voltage(self, volts: float) -> None: + self.socket_put(f":SENS:VOLT:PROT {volts:.6E}") + + def in_compliance(self) -> bool: + return self.socket_put_and_receive(":SENS:VOLT:PROT:TRIP?") == "1" + + @threadlocked + def read_output(self, max_age: float | None = None) -> tuple[float, float]: + """Measured (volts, amps), (0, 0) with the output off.""" + max_age = self.READBACK_MAX_AGE if max_age is None else max_age + if time.monotonic() - self._reading_time > max_age: + if self.output_enabled(): + volts, amps = self.socket_put_and_receive(":READ?").split(",")[:2] + self._reading = (float(volts), float(amps)) + else: + self._reading = (0.0, 0.0) + self._reading_time = time.monotonic() + return self._reading + + +class Keithley2400SignalBase(SocketSignal): + def __init__(self, signal_name, **kwargs): + self.signal_name = signal_name + super().__init__(**kwargs) + self.controller: Keithley2400Controller = self.parent.controller + + +class Keithley2400SignalRO(Keithley2400SignalBase): + def __init__(self, signal_name, **kwargs): + super().__init__(signal_name, **kwargs) + self._metadata["write_access"] = False + + def _socket_set(self, val): + raise ReadOnlyError("Read-only signals cannot be set") + + +class Keithley2400ReadbackSignal(Keithley2400SignalRO): + """Measured current in mA.""" + + def _socket_get(self): + return self.controller.read_output()[1] * 1e3 + + +class Keithley2400VoltageSignal(Keithley2400SignalRO): + """Measured voltage in V.""" + + def _socket_get(self): + return self.controller.read_output()[0] + + +class Keithley2400OutputSignal(Keithley2400SignalRO): + def _socket_get(self): + return int(self.controller.output_enabled()) + + +class Keithley2400ComplianceTrippedSignal(Keithley2400SignalRO): + def _socket_get(self): + return int(self.controller.in_compliance()) + + +class Keithley2400SetpointSignal(Keithley2400SignalBase): + """Programmed source level in mA.""" + + def _socket_get(self): + return self.controller.get_level() * 1e3 + + def _socket_set(self, val): + self.parent.check_value(val) + self.controller.set_level(val * 1e-3) + + +class Keithley2400ComplianceVoltageSignal(Keithley2400SignalBase): + """Voltage limit in V.""" + + def _socket_get(self): + return self.controller.get_compliance_voltage() + + def _socket_set(self, val): + self.controller.set_compliance_voltage(val) + + +class Keithley2400IsMoving(SignalRO): + def set_is_moving(self, value: int) -> None: + self._readback = value + + +class Keithley2400(Device, PositionerBase): + """ + Keithley 2400 as a current source, positioner in mA. + + Args: + limits: Allowed source current in mA. + tolerance: Allowed difference between measured and requested current in mA. + compliance_voltage: Voltage limit in V. + source_range_ma: Fixed source range in mA, fixed so level changes cause no range glitch. + remote_sense: True for 4-wire sensing, needs the sense leads connected at the load. + """ + + USER_ACCESS = ["output_on", "output_off", "status"] + + readback = Cpt(Keithley2400ReadbackSignal, signal_name="readback", kind="hinted") + user_setpoint = Cpt(Keithley2400SetpointSignal, signal_name="setpoint") + voltage = Cpt(Keithley2400VoltageSignal, signal_name="voltage", kind="normal") + output_enabled = Cpt(Keithley2400OutputSignal, signal_name="output_enabled", kind="normal") + compliance_tripped = Cpt( + Keithley2400ComplianceTrippedSignal, signal_name="compliance_tripped", kind="normal" + ) + compliance_voltage = Cpt( + Keithley2400ComplianceVoltageSignal, signal_name="compliance_voltage", kind="config" + ) + motor_is_moving = Cpt(Keithley2400IsMoving, value=0, kind="normal") + high_limit_travel = Cpt(Signal, value=0, kind="omitted") + low_limit_travel = Cpt(Signal, value=0, kind="omitted") + + SUB_READBACK = "readback" + SUB_CONNECTION_CHANGE = "connection_change" + _default_sub = SUB_READBACK + + SETTLE_TIMEOUT = 2.0 # s + + def __init__( + self, + prefix="", + *, + name, + kind=None, + read_attrs=None, + configuration_attrs=None, + parent=None, + host="mpc2680.psi.ch", + port=3336, + limits=(0.0, 3.0), + tolerance: float = 0.01, + compliance_voltage: float = 5.0, + source_range_ma: float = 10.0, + remote_sense: bool = False, + socket_cls=SocketIO, + device_manager=None, + **kwargs, + ): + self.controller = Keithley2400Controller( + socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager + ) + self.controller.set_axis(axis=self, axis_nr=0) + self.tolerance = tolerance + self._compliance_voltage = compliance_voltage + self._source_range_ma = source_range_ma + self._remote_sense = remote_sense + + super().__init__( + prefix, + name=name, + kind=kind, + read_attrs=read_attrs, + configuration_attrs=configuration_attrs, + parent=parent, + **kwargs, + ) + self.readback.name = self.name + self.controller.subscribe( + self._update_connection_state, event_type=self.SUB_CONNECTION_CHANGE + ) + self._update_connection_state() + self.low_limit_travel.put(limits[0]) + self.high_limit_travel.put(limits[1]) + + def wait_for_connection(self, timeout: float = 30.0) -> bool: + self.controller.on(timeout=timeout) + self.controller.configure( + self._compliance_voltage, self._source_range_ma, self._remote_sense + ) + + def destroy(self): + """Close the socket. The output stays as it is.""" + self.controller.off(update_config=False) + return super().destroy() + + @property + def limits(self): + return (self.low_limit_travel.get(), self.high_limit_travel.get()) + + @property + def low_limit(self): + return self.limits[0] + + @property + def high_limit(self): + return self.limits[1] + + def check_value(self, pos): + low_limit, high_limit = self.limits + if not low_limit <= pos <= high_limit: + raise LimitError(f"current={pos} mA not within limits {self.limits}") + + def _update_connection_state(self, **kwargs): + for walk in self.walk_signals(): + walk.item._metadata["connected"] = self.controller.connected + + @raise_if_disconnected + def move(self, position, wait=True, **kwargs) -> MoveStatus: + """Source ``position`` mA, switching the output on if it is off.""" + timeout = kwargs.pop("timeout", 10) + status = super().move(position, timeout=timeout, **kwargs) + self.user_setpoint.put(position, wait=False) # level first, then the output + + def apply_and_finish(): + self.motor_is_moving.set_is_moving(1) + error = None + try: + self.controller.set_output(True) + self._wait_for_current(position) + except Exception as exc: # pylint: disable=broad-except + error = exc + self.motor_is_moving.set_is_moving(0) + val = self.readback.read() + self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time()) + if error is None: + self._done_moving(success=True) + return + # BEC forwards this exception to the client, _done_moving would lose the reason. + logger.error(f"{self.name}: {error}") + try: + status.set_exception(error) + except InvalidState: + pass # stopped or timed out in the meantime + self._reset_sub(self._SUB_REQ_DONE) + + threading.Thread(target=apply_and_finish, daemon=True).start() + if wait: + status.wait() + return status + + def _wait_for_current(self, position: float) -> None: + deadline = time.monotonic() + self.SETTLE_TIMEOUT + while True: + volts, amps = self.controller.read_output(max_age=0) + if np.isclose(amps * 1e3, position, atol=self.tolerance, rtol=0): + return + if time.monotonic() > deadline: + break + time.sleep(0.05) + hint = ( + f" Voltage compliance reached at {volts} V: check the wiring, or the load needs " + "more than the compliance voltage." + if self.controller.in_compliance() + else "" + ) + raise Keithley2400Error(f"Requested {position} mA, measured {amps * 1e3} mA.{hint}") + + def output_on(self) -> None: + """Switch the output on at the programmed level. Use ``umv`` to set a new level.""" + self.controller.set_output(True) + + def output_off(self) -> None: + """Switch the output off. The programmed level is kept.""" + self.controller.set_output(False) + + def status(self) -> None: + """Print the state of the instrument.""" + volts, amps = self.controller.read_output(max_age=0) + table = PrettyTable() + table.header = False + table.add_rows( + [ + ["Output", "on" if self.controller.output_enabled() else "off"], + ["Programmed current", f"{self.controller.get_level() * 1e3:.4f} mA"], + ["Measured current", f"{amps * 1e3:.4f} mA"], + ["Measured voltage", f"{volts:.4f} V"], + ["Compliance voltage", f"{self.controller.get_compliance_voltage()} V"], + ["In compliance", "YES" if self.controller.in_compliance() else "no"], + ] + ) + print(table) + + @property + def egu(self): + return "mA" diff --git a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py index 09164982..f1e13e14 100644 --- a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py @@ -441,7 +441,10 @@ class RtFlomniController(Controller): print(f"min signal: {min_signal}") print(f"signal: {signal}") if signal < min_signal: + # second chance: re-read after a short wait (the old code + # compared the same stale value again) time.sleep(1) + signal = self.read_ssi_interferometer(1) if signal < min_signal: print( f"\x1b[91mThe signal of the tracker {signal} is below the minimum required signal of {min_signal}. Readjustment requred!\x1b[0m" diff --git a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py index 19aa1660..38f3a97b 100644 --- a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py @@ -152,7 +152,6 @@ class RtLamniController(Controller): self.set_device_read_write("lsamy", False) self.set_device_read_write("loptx", False) self.set_device_read_write("lopty", False) - self.set_device_read_write("loptz", False) @threadlocked def feedback_disable_and_even_reset_lamni_angle_interferometer(self): @@ -381,7 +380,6 @@ class RtLamniController(Controller): self.set_device_read_write("lsamy", False) self.set_device_read_write("loptx", False) self.set_device_read_write("lopty", False) - self.set_device_read_write("loptz", False) if interferometer_feedback_not_running == 1: logger.error( diff --git a/csaxs_bec/devices/pseudo_devices/bpm.py b/csaxs_bec/devices/pseudo_devices/bpm.py index 74aad13e..4dbd009f 100644 --- a/csaxs_bec/devices/pseudo_devices/bpm.py +++ b/csaxs_bec/devices/pseudo_devices/bpm.py @@ -7,7 +7,12 @@ from ophyd_devices.utils.bec_processed_signal import BECProcessedSignal class BPM(PSIPseudoDeviceBase): - """BPM positioner pseudo device.""" + """BPM positioner pseudo device. + + invert=True negates the four blade signals, e.g. for an amplifier whose + output current is negative. pos_x, pos_y and diagonal are ratios and do + not change; intensity changes sign. + """ # Blade signals, a,b,c,d left_top = Cpt( @@ -76,11 +81,13 @@ class BPM(PSIPseudoDeviceBase): right_top_ref: str, right_bot_ref: str, left_bot_ref: str, + invert: bool = False, device_manager=None, scan_info=None, **kwargs, ): super().__init__(name=name, device_manager=device_manager, scan_info=scan_info, **kwargs) + self._sign = -1 if invert else 1 # Get all blade signal objects from utility method signal_t = self.left_top.get_device_object_from_bec( object_name=left_top_ref, signal_name=self.name, device_manager=device_manager @@ -131,7 +138,7 @@ class BPM(PSIPseudoDeviceBase): ) def _compute_blade_signal(self, signal: Signal) -> float: - return signal.get() + return self._sign * signal.get() def _compute_intensity( self, left_top: Signal, right_top: Signal, right_bot: Signal, left_bot: Signal diff --git a/csaxs_bec/devices/pseudo_devices/signal_forwarder.py b/csaxs_bec/devices/pseudo_devices/signal_forwarder.py index 69ad94ee..48fa0b9c 100644 --- a/csaxs_bec/devices/pseudo_devices/signal_forwarder.py +++ b/csaxs_bec/devices/pseudo_devices/signal_forwarder.py @@ -9,7 +9,7 @@ from ophyd_devices.utils.bec_processed_signal import BECProcessedSignal class SignalForwarder(PSIPseudoDeviceBase): - """Forward one signal unchanged.""" + """Forward one signal unchanged, or negated with invert=True.""" signal = Cpt( BECProcessedSignal, @@ -20,8 +20,17 @@ class SignalForwarder(PSIPseudoDeviceBase): doc="Forwarded signal", ) - def __init__(self, name, signal_ref: str, device_manager=None, scan_info=None, **kwargs): + def __init__( + self, + name, + signal_ref: str, + invert: bool = False, + device_manager=None, + scan_info=None, + **kwargs, + ): super().__init__(name=name, device_manager=device_manager, scan_info=scan_info, **kwargs) + self._sign = -1 if invert else 1 src = self.signal.get_device_object_from_bec( object_name=signal_ref, signal_name=self.name, device_manager=device_manager @@ -30,4 +39,4 @@ class SignalForwarder(PSIPseudoDeviceBase): self.signal.set_compute_method(self._compute_signal, signal=src) def _compute_signal(self, signal: Signal) -> float: - return signal.get() + return self._sign * signal.get() diff --git a/docs/developer/lamni_smear_architecture.md b/docs/developer/lamni_smear_architecture.md index 2304a007..cd7f9a1b 100644 --- a/docs/developer/lamni_smear_architecture.md +++ b/docs/developer/lamni_smear_architecture.md @@ -1,6 +1,6 @@ # LamNI smear rotation-center aid: architecture notes -The experimental continuous-rotation "smear" calibration aid (see +The continuous-rotation "smear" calibration aid (see [the user-facing description](../user/ptychography/lamni.md)) touches all three layers of a typical BEC GUI feature — a **device**, an **ipython-client plugin**, and a **GUI widget** — running as three separate processes talking @@ -106,7 +106,7 @@ at the start and pushes composite updates there as the rotation progresses — but **deliberately does not switch back to `"image"` itself** on a successful return. The composite has to stay on screen until the operator's click has actually been collected -(`find_rotation_center_smear_experimental` switches back right after +(`find_rotation_center_smear` switches back right after `_collect_click()` returns); switching back inside `_smear_sweep()` would show the operator a live raw frame instead of the composite at exactly the moment they need to click on it. On `KeyboardInterrupt` mid-sweep there's diff --git a/docs/plans/flomni-batched-single-point-tomo.md b/docs/plans/flomni-batched-single-point-tomo.md new file mode 100644 index 00000000..900a86bb --- /dev/null +++ b/docs/plans/flomni-batched-single-point-tomo.md @@ -0,0 +1,168 @@ +# Plan: Batched single-point tomography for flOMNI (one armed scan for many angles) + +Status: **not implemented** (planned 2026-10-15 during the LamNI beamtime; the +only code change made alongside it is the laser-tracker re-read bug fix, see +§6). + +## Goal + +flOMNI's single-point tomo mode (`single_point_instead_of_fermat_scan`) takes +one BEC scan per angle: `Flomni.tomo_acquire_at_angle()` rotates `fsamroy`, +moves `rtx/rty/rtz`, checks the laser tracker and then calls `scans.acquire()`. +Every angle therefore pays the full scan open/stage/unstage/close cycle plus +the client round-trip. + +Instead: arm the detectors **once per batch of angles**, and inside that one +scan, for each point: rotate, move to the target position, trigger one +acquisition, read out, continue with the next angle. + +## Expected saving + +From `csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/FLOMNI_SCAN_OVERHEAD.md` +(flOMNI beamtime 2026-10-07…12, 10,403 fermat scans): + +- Scan-to-scan dead time **3.2 s median**, dominated by serial bookkeeping + (end-of-scan polling, close, client round-trip, prepare, open, detector + staging, laser tracker on), not by the rotation (0.6–0.9 s for 1–2° steps). +- In a batched scan a point should cost roughly rotation + rt move + tracker + on-target + exposure. Estimated saving **~2–2.5 s per angle** at small + angular steps (e.g. ~1.5 h on a 2000-angle tomogram). +- The overhead document measured `flomni_fermat_scan`, not `scans.acquire`. + **First step:** measure the current single-point mode per angle (same log + markers as in that document) to confirm the baseline. + +## Decisions (agreed 2026-10-15) + +1. **Analysis:** one scan containing many angles is fine for the data + analysis. The scan must record, per point, the angle and the rtx/rty/rtz + target positions. +2. **Batching:** configurable batch size; one BEC scan per batch. Limits what + has to be repeated after a failure or an interlock "abort and repeat". +3. **Laser tracker:** checked regularly inside the scan depending on the + angular step: after a rotation once the accumulated rotation since the last + check exceeds a parameter (degrees), and once at the start of each batch. + "toolow" aborts (→ batch repeated), "low" warns, as in the fermat scan. +4. **Shutter:** no extra handling. The trigger via the delay generator (ddg1) + already opens the shutter for each exposure, so it stays closed during + rotation and moves. +5. **Trigger source: the RT system (preferred).** The RT controller fires + the trigger only once the target position is truly reached (in its own + position loop), and the scheme does not depend on beamline trigger + hardware or BEC software-trigger timing, so it also runs independently of + the beamline. This is the same chain the fermat scan already uses: ddg1 on + `EXT_RISING_EDGE`, RT controller → ddg1 → detectors/shutter + (`flomni_fermat_scan.py` `prepare_setup_part_2`). + - Possible reuse of the existing trajectory path: per angle, load a + one-point trajectory (`add_pos_to_scan([[x, y, z]])`) and start it; the + RT controller moves there and triggers when in position, exactly as for + each fermat point. To check with the RT controller code: whether a + one-point trajectory is accepted, its start/stop overhead, and whether + the `rt_positions` flyer readout can stay armed across many short + trajectories in one BEC scan. Otherwise a dedicated RT command + "move to x/y/z, trigger once in position" is needed (to be provided on + the RT side). + - **Fallback without RT changes:** BEC's software-triggered step-scan path + already arms once for many points: Eiger stages + `num_points × frames_per_trigger` triggers + (`devices/jungfraujoch/eiger.py:490`); ddg1 in `SINGLE_SHOT` sends one + software trigger per BEC `trigger` (`ddg_1.py:679`); the MCS card + handles `software_triggered` scans (`mcs_card_csaxs.py:383`). Here + "in position" relies on the rt move's completion status instead of the + RT loop. The scan plugin can be built so that only the trigger step + differs between the two variants. + +## Design + +### Scan plugin `flomni_point_tomo_scan` (new, `csaxs_bec/scans/`) + +Software-triggered step scan (`ScanBase`), modelled on `flomni_fermat_scan.py`. + +- **Inputs:** `angles` (list), `positions` (list of `[x, y, z]` per angle, + computed on the client), `exp_time`, `frames_per_trigger`, + `tracker_check_every_deg`. +- **prepare_scan:** validate lengths; `update_scan_info(num_points=len(angles), + positions=...)`; store angles/positions in the scan metadata for the + analysis. +- **stage / pre_scan:** `stage_all_devices()` (arms Eiger/MCS once), ddg1 → + `SINGLE_SHOT`, `laser_tracker_on()`, `move_samx_to_scan_region()` for the + first point. +- **scan_core, per point:** + 1. rotate `fsamroy` (reuse `flomni_rotation()` incl. its progress report); + skip when already at the angle; + 2. (fallback variant) move `rtx/rty/rtz` to the target; in the RT + variant the RT controller does this move itself in step 4; call `move_samx_to_scan_region()` when + x leaves the current coarse region; + 3. if the accumulated rotation exceeds `tracker_check_every_deg`: + **wait for tracker on target** (must happen before the exposure, the + interferometer positions depend on it); + 4. trigger **without waiting**: RT variant = start the RT move/trigger + (ddg1 on `EXT_RISING_EDGE`), the RT fires when in position; fallback = + BEC software trigger of ddg1 (`SINGLE_SHOT`). While the exposure runs, + **read and judge the tracker signal strength** (read-only; in the RT + variant check that the controller accepts a query during the move/ + trigger sequence); + 5. wait for the trigger status, `read_monitored_devices()`; + 6. "toolow" → raise `ScanAbortion` (the point was taken with a bad signal; + batch is repeated); "low" → alarm WARNING. +- **post_scan / on_exception:** `complete_all_devices()`, ddg1 back to + `SINGLE_SHOT` default, same as the fermat scan. + +### Laser tracker changes (`devices/omny/rt/rt_flomni_ophyd.py`) + +- Split `laser_tracker_check_signalstrength()` into + `laser_tracker_wait_on_target()` (already separate) and a new read-only + `laser_tracker_signal_status()` that only reads and classifies the signal, + so the scan can call them at different times (steps 3 and 4 above). +- **Polling period of `laser_tracker_wait_on_target()` 0.5 s → 50 ms** + (agreed). Its loop counts iterations (`count == 10` re-enables tracking, + `count > 100` raises), so convert both thresholds to elapsed time + (currently 5 s and 50 s) to keep the behaviour. Benefits every flOMNI scan: + the overhead document measured 0.5 s, or 0.87 s when it has to wait, here. + +### Client (`bec_ipython_client/plugins/flomni/flomni.py`) + +- New switch on the existing single-point mode, e.g. + `single_point_batched` + `single_point_batch_size`, rather than a new tomo + type. The angle plan (tomo types 1/2/3, sub-tomograms, golden ratio) is + unchanged; only the execution changes. +- Compute per-angle positions exactly as `tomo_acquire_at_angle()` does today + (`get_alignment_offset`, `compute_additional_correction_y(_2)`, + `manual_shift_x/y`, random shift), then submit one + `flomni_point_tomo_scan` per batch. +- **Progress / resume:** advance per completed batch; resume starts at the + first angle of the interrupted batch. Interlock "restart_scan" repeats the + current batch. +- **Projection logs, scilog, reconstruction queue:** one scan number now + covers a batch of angles; record the scan number with its angle range / + angle list. +- Webpage/GUI progress: update per point from the scan's progress report if + possible, else per batch. + +## Implementation order + +1. Measure the current single-point overhead per angle (baseline). +2. Tracker: `laser_tracker_signal_status()` split + 50 ms polling with + time-based thresholds; tests. +3. RT side: one-point trajectory test or new "move + trigger in + position" command. +4. Scan plugin with tests (simulated devices, see + `flomni/AI_docs/SIMULATED_ENDSTATIONS.md`). +5. Client batching, progress/resume, logging; tests. +6. Hardware test: short batch (e.g. 10 angles), check data, tracker + behaviour, shutter timing; then compare per-angle time with step 1. + +## Open points + +- Eiger: confirm no trigger timeout while armed and waiting during a large + rotation (> 20° took up to ~4.8 s in the overhead data). +- Whether a tracker check before *every* exposure is needed at very small + steps, or only on the angular threshold. +- Per-point dead time inside scans (~30–50 ms, overhead document §2) is a + separate issue and not addressed here. + +## Done alongside this plan + +- `laser_tracker_check_signalstrength()`: the "too low" branch slept 1 s and + then compared the **same** value again, so the intended second chance did + nothing. It now re-reads the signal (`rt_flomni_ophyd.py`), with a test in + `tests/tests_devices/test_rt_flomni.py`. diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index ad39c6fd..1e7fa45b 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -121,8 +121,6 @@ During operation the BEC GUI will show the relevant cameras or progress informat ## How to setup flOMNI (software) -This part of the manual is intended for beamline staff and expert users - The nano-positioning is controlled by a feedback loop running on a real-time linux based computer. With all related hardware connected, this loop has to be started manually. 1. Login to the computer by `ssh control@mpc2680`. The password is written on the physical machine. @@ -408,7 +406,7 @@ For the "8 sub-tomograms" mode, `flomni.tomo_parameters()` also offers a `zero_d By default odd sub-tomograms sweep 0→180° and even ones 180→0°, so the reference is taken before every second sub-tomogram. With the `tomo_type1_always_forward` option ("Run all sub-tomos forward", 180° range only), every sub-tomogram sweeps 0→180°; the rotation returns to 0° between sub-tomograms and a 0° reference is taken before each of the 8 (plus one at the end). The angle set of every sub-tomogram, and thus the combined angular grid, is unchanged. -`flomni.tomo_parameters()` also offers `stitch_x`/`stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single Fermat scan can cover: when nonzero, every projection angle is acquired as a `(2*stitch_x+1) × (2*stitch_y+1)` grid of separate Fermat scans instead of a single one, tiled around the normal alignment-corrected center and spaced by `fovx`/`fovy` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(2*stitch_x+1) × fovx` by `(2*stitch_y+1) × fovy`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. Stitching is forced to 0 (disabled) whenever `single_point_instead_of_fermat_scan` is enabled. +`flomni.tomo_parameters()` also offers `stitch_x`/`stitch_y` (integers, default 0, up to 10 each) for a field of view larger than a single Fermat scan can cover: when nonzero, every projection angle is acquired as a `(stitch_x+1) × (stitch_y+1)` grid of separate Fermat scans instead of a single one, placed symmetrically around the normal alignment-corrected center and spaced by `fovx`/`fovy` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(stitch_x+1) × fovx` by `(stitch_y+1) × fovy`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. For example, a value of 1 gives two tiles, each shifted by half a step from the center; 2 gives three tiles (one centered, one on each side). Stitching is forced to 0 (disabled) whenever `single_point_instead_of_fermat_scan` is enabled. The parameters above can be used to __restart an interrupted acquisition__ manually, or - more conveniently - by running `flomni.tomo_scan_resume()` diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index 38559001..31248dd8 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -4,8 +4,6 @@ LamNI is an instrument for 3D ptychography via ptychographic X-ray computed lami ## How to LamNI -… 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. @@ -19,7 +17,7 @@ Only the commands, in the order they are used. Details are in the linked section - `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()` +- `lamni.xrayeye_rotation_center_calibration_smear()` for an extended sample, using a continuous-rotation composite Applying a new center erases an existing X-ray eye alignment and fine-alignment correction. You are warned before that happens. @@ -64,13 +62,13 @@ 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. +In `..._extended()` and `..._smear()` 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** +**Continuous-rotation "smear" aid** -`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0, projection="min", opaque_level=0.25)` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` while accumulating a "star-trail" composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. The composite builds up live on screen as the sweep progresses and stays frozen once the sweep ends, until you submit your click. `sweep_deg` can be reduced below 360 if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`. +`lamni.xrayeye_rotation_center_calibration_smear(sweep_deg=360.0, projection="min", opaque_level=0.25)` is an alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` while accumulating a "star-trail" composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. The composite builds up live on screen as the sweep progresses and stays frozen once the sweep ends, until you submit your click. `sweep_deg` can be reduced below 360 if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`. `projection` selects how the frames are combined: @@ -143,7 +141,7 @@ sx, sy = LamNIComponents.lamni_compute_scan_center(lamni.tomo_fovx_offset, lamni lamni.lamni_move_to_scan_center(sx + cx, sy + cy, 0).wait() ``` -The base shift is rotated to the angle first, so the move itself is called with angle 0 to pass the target through unchanged. This matches a projection without stitching, manual shifts (`lamni.manual_shift_x/y`, add them) and the correction tables from fine alignment (`lamni.compute_additional_correction(angle)` and `..._2(angle)`, subtract them). The positions are relative to the `center` user parameters of `lsamx`/`lsamy`, which must be set. Check the result with `lamni.show_interferometer_positions()`. +The base shift is rotated to the angle first, so the move itself is called with angle 0 to pass the target through unchanged. This matches a projection without stitching, manual shifts (`lamni.manual_shift_x/y` and `lamni.loptz_shift_x/y`, add them) and the correction tables from fine alignment (`lamni.compute_additional_correction(angle)` and `..._2(angle)`, subtract them). The positions are relative to the `center` user parameters of `lsamx`/`lsamy`, which must be set. Check the result with `lamni.show_interferometer_positions()`. 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. @@ -161,17 +159,18 @@ The sample fine alignment can be obtained using ptychography. For this a short l | Base shift | first click (0°) of the x-ray eye alignment, stored as `lamni.tomo_fovx_offset` / `tomo_fovy_offset` | scan centre (`center_x`, `center_y`), rotated with the angle | | X-ray eye correction | sinusoidal fit `A·sin(angle + B) + C` of the 8 clicked positions, `lamni.lamni_compute_additional_correction_xeye_mu(angle)` | added to the shift | | Manual shift | `lamni.manual_shift_x` / `manual_shift_y`, constant for all angles | added to the shift | +| loptz shift | `lamni.loptz_shift_x` / `loptz_shift_y`, constant for all angles; compensates moving loptz after alignment, reset to zero at x-ray eye alignment | added to the shift | | Additional correction 1 | lookup table from the fine alignment, `lamni.read_additional_correction(file)` | subtracted from the shift | | Additional correction 2 | lookup table of the second iteration, `lamni.read_additional_correction_2(file)` | subtracted from the shift | -The shift is therefore `manual + x-ray eye − correction 1 − correction 2`, on top of the rotated base shift. The x-ray eye fit was measured after the base shift was applied, so the two do not count twice. +The shift is therefore `manual + loptz shift + x-ray eye − correction 1 − correction 2`, on top of the rotated base shift. The x-ray eye fit was measured after the base shift was applied, so the two do not count twice. The correction tables are read from the files written by `BEC_ptycho_align`, with one shift per alignment angle. There is no interpolation: for each projection the entry with the closest angle is used. If a table has no data, it is skipped and `Not applying additional correction` is printed. Without an x-ray eye fit, `Not applying any X-ray eye correction` is printed. Each correction can be removed again with `lamni.reset_correction()`, `lamni.reset_correction_2()` and `lamni.reset_xray_eye_correction()`. The alignment scan itself already uses the x-ray eye correction and any correction tables that are loaded. So the ptychographic alignment measures what is left over after those corrections, which is why a second iteration with `read_additional_correction_2()` is useful. -* `lamni.tomo_parameters()` adjust the ptychographic scan parameters for the alignment scan (FOV/step size/counting time) — `tomo_type` and number of projections are ignored, since the alignment scan always runs its own fixed 12 points spread evenly across the full 360 degrees, independent of the main tomogram's settings. -* `lamni.tomo_alignment_scan()` perform the alignment scan. Requires x-ray-eye alignment to have already been done — it will abort with a message otherwise. Scan numbers, angles and the existing x-ray-eye-fit offset at each angle are written to `~/data/raw/logs/ptychotomoalign_scannum.txt` and also printed at the end. +* `lamni.tomo_parameters()` adjust the ptychographic scan parameters for the alignment scan (FOV/step size/counting time) — `tomo_type` and number of projections are ignored, since the alignment scan runs its own set of equally spaced angles from 0 to 360 inclusive (360 repeats 0 as a drift check), independent of the main tomogram's settings. By default this is 13 points, every 30 degrees; see `number_of_projections` below. +* `lamni.tomo_alignment_scan()` perform the alignment scan. Optional argument `number_of_projections` (default 13) sets the number of angles, 0 and 360 both counted, e.g. `lamni.tomo_alignment_scan(number_of_projections=25)` for every 15 degrees. It must be at least 2. Requires x-ray-eye alignment to have already been done — it will abort with a message otherwise. Scan numbers, angles and the existing x-ray-eye-fit offset at each angle are written to `~/data/raw/logs/ptychotomoalign_scannum.txt` and also printed at the end. * Run `BEC_ptycho_align` (in Matlab, **force ptycho=1**, and **correct scan numbers**) using the printed/logged scan numbers. * Click the sample position in the Matlab GUI and then load the generated file by, for example `lamni.read_additional_correction('/sls/X12SA/data/e20632/Data10/cxs_software/ptycho/correction_lamni_um_S05389_lamni_fit.txt')` @@ -209,7 +208,7 @@ which reads the last recorded progress and resumes automatically at the exact po In case of eight equally spaced sub-tomograms, an individual sub-tomogram can be scanned by `lamni.sub_tomo_scan(subtomo_number, start_angle)`. If the start angle is not specified, it is computed depending on `subtomo_number`, ranging from 1 to 8. -`lamni.tomo_parameters()` also offers `lamni_stitch_x`/`lamni_stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single scan can cover: when nonzero, every projection angle is acquired as a `(2*lamni_stitch_x+1) × (2*lamni_stitch_y+1)` grid of separate Fermat scans instead of a single one, tiled around the normal alignment-corrected center and spaced by `lamni_piezo_range_x`/`lamni_piezo_range_y` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(2*lamni_stitch_x+1) × lamni_piezo_range_x` by `(2*lamni_stitch_y+1) × lamni_piezo_range_y`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. Same mechanism as flomni's `stitch_x`/`stitch_y`, just under lamni's own parameter names (see `lamni.tomo_scan_projection()`, which passes `stitch_x`/`stitch_y`/`stitch_overlap` straight through to `scans.lamni_fermat_scan()`). +`lamni.tomo_parameters()` also offers `lamni_stitch_x`/`lamni_stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single scan can cover: when nonzero, every projection angle is acquired as a `(lamni_stitch_x+1) × (lamni_stitch_y+1)` grid of separate Fermat scans instead of a single one, placed symmetrically around the normal alignment-corrected center and spaced by `lamni_piezo_range_x`/`lamni_piezo_range_y` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(lamni_stitch_x+1) × lamni_piezo_range_x` by `(lamni_stitch_y+1) × lamni_piezo_range_y`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. For example, a value of 1 gives two tiles, each shifted by half a step from the center; 2 gives three tiles (one centered, one on each side). With a circular FOV (`tomo_circfov`) the corner tiles can contain far fewer scan points than the center one; the estimated point count shown by `tomo_parameters()` is that of the smallest tile, and the parameters cannot be submitted while it is below the scan minimum (20). Reduce the piezo range or the step size, or adjust the stitching or circular FOV. Same mechanism as flomni's `stitch_x`/`stitch_y`, just under lamni's own parameter names (see `lamni.tomo_scan_projection()`, which passes `stitch_x`/`stitch_y`/`stitch_overlap` straight through to `scans.lamni_fermat_scan()`). #### Angles from a file (tomo_type 4) @@ -334,6 +333,8 @@ Only actions from a fixed, reviewed registry can be queued — not arbitrary cod | --- | --- | --- | | `move` | Move device(s) to absolute position(s). Only devices on the allow-list below can be targeted. | `positions`: `{device: target}`, e.g. `{"mokev": 6.2}` | | `optimize_idgap` | Scan the undulator gap over a range and move to the peak. **Not yet implemented (no-op stub).** | `search_range`: mm, default 0.5, range 0–2 | +| `set_keithley_current` | Set the Keithley 2400 current, switching its output on if it is off, then sleep. The queue pauses if the current is not reached, e.g. in voltage compliance. | `current_mA`: mA, range from the device limits; `sleep_s`: s, default 0 | +| `sleep` | Wait. | `seconds`: s, default 60 | Devices allowed for `move`: `mokev` (energy, keV), `idgap` (undulator gap, mm) — the same beamline-wide devices as flOMNI's queue. A `move` naming any other device is @@ -348,6 +349,15 @@ lamni.tomo_queue_add_command( ) ``` +Example — change the sample current and wait for it to act, then wait further: +```python +lamni.tomo_queue_add_command( + {"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 120}}, + label="1.5 mA", +) +lamni.tomo_queue_add_command({"action": "sleep", "kwargs": {"seconds": 600}}, label="wait 10 min") +``` + `tomo_queue_show()` lists command jobs alongside tomogram jobs, e.g.: ```text [2] pending reconfigure to 6.2 keV CMD move{'positions': {'mokev': 6.2}} > optimize_idgap{'search_range': 0.5} [idem] @@ -501,10 +511,11 @@ if you want to actually clear the selection, not just remove the hook it points If the beam size needs to be changed with feedback running, e.g. to switch from near-field to far-field ptychography, following steps can be taken: -1. `dev.loptz.enable_set=True` to enable loptz movements with feedback running -2. `umvr(dev.lopz,_value_)` move loptz to the desired position. -3. `lamni._manual_shift_x/y = _value_` correct the stage run out from motion along the optical axis (units of um). The exact value can be checked by comparing feature positions in projections before/after adjusting loptz. -4. Potentially correct the alignment of the OSA +1. `umv(dev.loptz,_value_)` move loptz to the desired position. loptz stays movable with feedback running. Check the OSA clearance with `lamni.lfzp_info()` first. +2. `lamni.loptz_shift_x/y = _value_` correct the stage run out from motion along the optical axis (units of mm, not rotated with the angle). The exact value can be checked by comparing feature positions in projections before/after adjusting loptz. These shifts are reset to zero at the x-ray eye alignment, where the loptz position is stored as `lamni.loptz_shift_reference`. Unlike `manual_shift_x/y` they are not saved with tomo queue jobs. +3. Potentially correct the alignment of the OSA + +Both steps can also be queued between tomograms with the tomo queue command action `move_loptz_with_shift` (loptz target and new `loptz_shift_x/y`). It refuses the move if the OSA at its current losaz position would collide with the sample holder. #### BEC tips diff --git a/docs/user/ptychography/omny.md b/docs/user/ptychography/omny.md index dcb17b4a..d1b19e19 100644 --- a/docs/user/ptychography/omny.md +++ b/docs/user/ptychography/omny.md @@ -4,8 +4,6 @@ OMNY is a microscope setup for 3D mesurements via ptychographic X-ray computed t ## HowTo OMNY -… a step-by-step guide for _beamline staff and expert users_. - ### Change to a new sample The sample storage, shuttles and parking positions are described in detail [here](omny.md#sample-storage-and-transfer). diff --git a/omny_e2e_tests/_bootstrap.py b/omny_e2e_tests/_bootstrap.py index 08517bf1..c6b3d15b 100644 --- a/omny_e2e_tests/_bootstrap.py +++ b/omny_e2e_tests/_bootstrap.py @@ -8,7 +8,7 @@ neutralization is needed. See TOMO_QUEUE_TESTING.md section 3. Deliberately does NOT call bec.config.load_demo_config()/ update_session_with_file(): assumes the running sim session already has -device_configs/simulated_omny/simulated_flomni.yaml loaded. +device_configs/simulated_omny/simulated_bl_simulated_flomni.yaml loaded. """ from __future__ import annotations diff --git a/omny_e2e_tests/test_tomo_queue_command_jobs.py b/omny_e2e_tests/test_tomo_queue_command_jobs.py index ea2e8554..f74a557f 100644 --- a/omny_e2e_tests/test_tomo_queue_command_jobs.py +++ b/omny_e2e_tests/test_tomo_queue_command_jobs.py @@ -1,7 +1,7 @@ """Step 2 -- command jobs. See AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md. ccm_energy/idgap (the plain "move" allow-list) aren't in the flomni sim -config (simulated_flomni.yaml) -- only on the real front end +config (simulated_bl_simulated_flomni.yaml) -- only on the real front end (bl_frontend.yaml/bl_optics_hutch.yaml). Tests that only exercise add-time validation/rendering (no tomo_queue_execute()) reference idgap for that reason, while tests that actually execute the queue use the dedicated diff --git a/tests/tests_bec_ipython_client/test_cSAXS_slits.py b/tests/tests_bec_ipython_client/test_cSAXS_slits.py index b381bfbd..20b38d5b 100644 --- a/tests/tests_bec_ipython_client/test_cSAXS_slits.py +++ b/tests/tests_bec_ipython_client/test_cSAXS_slits.py @@ -43,3 +43,158 @@ def test_slits_show_all_reads_every_slit_device_cached(capsys): for dev in devices.values(): dev.read.assert_called_once_with(cached=True) assert "cSAXS Slits Overview" in capsys.readouterr().out + + +def _slit_devices(displayed: dict, epics_offset=0.1, smaract_offset=0.2): + devices = {} + for prefix in cSAXSSlits._SLIT_LABELS: + smaract = prefix in ("sl4", "sl5", "sl6") + for suffix in ("xc", "xs", "yc", "ys"): + name = f"{prefix}{suffix}" + dev = mock.MagicMock() + dev.read.return_value = {name: {"value": displayed[suffix]}} + if smaract: + cls = "VirtualSlitWidth" if suffix.endswith("s") else "VirtualSlitCenter" + dev._config = { + "deviceClass": f"ophyd_devices.devices.virtual_slit.{cls}", + "deviceConfig": {"offset": smaract_offset}, + } + else: + dev._config = {"deviceClass": "ophyd_devices.EpicsMotorEC", "deviceConfig": {}} + dev.user_offset.get.return_value = epics_offset + devices[name] = dev + return devices + + +def test_slits_calibration_report_offsets(capsys): + displayed = {"xc": 0.03, "xs": 1.05, "yc": -0.02, "ys": 0.9} + slits = _make_slits(_slit_devices(displayed)) + + result = slits.slits_calibration_report(full=False) + + assert len(result) == 24 + # EPICS: new user_offset = old + (target - displayed) + assert abs(result["sl1xc"] - (0.1 - 0.03)) < 1e-12 + assert abs(result["sl2xs"] - (0.1 + 1.0 - 1.05)) < 1e-12 + assert abs(result["sl3yc"] - (0.1 + 0.02)) < 1e-12 + # SmarAct: new config offset = old + (target - displayed) + assert abs(result["sl4ys"] - (0.2 + 1.0 - 0.9)) < 1e-12 + assert abs(result["sl6xc"] - (0.2 - 0.03)) < 1e-12 + + out = capsys.readouterr().out + assert "dev.sl1xc.user_offset.put(0.070000)" in out + assert "sl4ys: offset: 0.300000" in out + assert "dev.sl4" not in out + assert "Beamline state" not in out + + +def test_slits_calibration_report_uses_fresh_reads_and_target_width(): + displayed = {"xc": 0.0, "xs": 2.0, "yc": 0.0, "ys": 2.0} + devices = _slit_devices(displayed, epics_offset=0.0, smaract_offset=0.0) + slits = _make_slits(devices) + + result = slits.slits_calibration_report(full=False, target_width=2.0) + + assert all(abs(v) < 1e-12 for v in result.values()) + devices["sl1xc"].read.assert_called_with(cached=False) + + +def test_slits_calibration_report_missing_device_and_save(tmp_path): + displayed = {"xc": 0.0, "xs": 1.0, "yc": 0.0, "ys": 1.0} + devices = _slit_devices(displayed) + del devices["sl5xc"] + slits = _make_slits(devices) + fname = tmp_path / "sub" / "state.txt" + + result = slits.slits_calibration_report(full=False, save=True, filename=str(fname)) + + assert result["sl5xc"] is None + text = fname.read_text() + assert "could not compute" in text and "sl5xc" in text + assert "Slit Calibration Report" in text + + +def test_slits_calibration_report_full_dump(capsys): + displayed = {"xc": 0.0, "xs": 1.0, "yc": 0.0, "ys": 1.0} + slit_devs = _slit_devices(displayed) + devices = mock.MagicMock() + devices.__getitem__.side_effect = slit_devs.__getitem__ + + kb = mock.MagicMock() + kb.name = "kbvpitch" + kb.enabled = True + kb.read.return_value = {"kbvpitch": {"value": 3.25}} + kb.read_configuration.return_value = {"kbvpitch_user_offset": {"value": -0.5}} + disabled = mock.MagicMock() + disabled.name = "disabled_dev" + disabled.enabled = False + devices.get_devices_with_tags.side_effect = lambda tag: ( + [kb, disabled] if tag == "bl_optics_hutch" else [] + ) + client = mock.MagicMock() + client.device_manager.devices = devices + slits = cSAXSSlits(client) + + slits.slits_calibration_report(full=True) + + out = capsys.readouterr().out + assert "---------- bl_optics_hutch ----------" in out + assert "kbvpitch_user_offset" in out and "-0.5" in out + assert "3.25" in out + assert "disabled_dev" not in out + kb.read.assert_called_once_with(cached=True) + tags = [c.args[0] for c in devices.get_devices_with_tags.call_args_list] + assert tags == list(cSAXSSlits._BEAMLINE_TAGS) + + +def _epics_motor(user_parameter: dict | None, live_offset: float, enabled=True): + dev = mock.MagicMock() + dev.enabled = enabled + dev._config = {"deviceClass": "ophyd_devices.EpicsMotorEC", "deviceConfig": {}} + if user_parameter is not None: + dev._config["userParameter"] = user_parameter + dev.user_offset.get.return_value = live_offset + return dev + + +def test_slits_apply_epics_offsets_only_where_configured_and_different(): + devices = { + "sl1xc": _epics_motor({"epics_offset": 0.12}, live_offset=0.0), + "sl1xs": _epics_motor({"epics_offset": 0.5}, live_offset=0.5), # already right + "sl2xc": _epics_motor({"other": 1}, live_offset=0.3), # no epics_offset + "sl2xs": _epics_motor(None, live_offset=0.3), # no userParameter + "sl3xc": _epics_motor({"epics_offset": -1.0}, live_offset=0.0, enabled=False), + } + slits = _make_slits(devices) + + written = slits.slits_apply_epics_offsets() + + assert written == {"sl1xc": 0.12} + devices["sl1xc"].user_offset.put.assert_called_once_with(0.12) + for name in ("sl1xs", "sl2xc", "sl2xs", "sl3xc"): + devices[name].user_offset.put.assert_not_called() + + +def test_slits_apply_epics_offsets_survives_device_errors(capsys): + bad = _epics_motor({"epics_offset": 0.1}, live_offset=0.0) + bad.user_offset.get.side_effect = RuntimeError("no connection") + good = _epics_motor({"epics_offset": 0.2}, live_offset=0.0) + slits = _make_slits({"sl1xc": bad, "sl1yc": good}) + + written = slits.slits_apply_epics_offsets() + + assert written == {"sl1yc": 0.2} + assert "sl1xc: could not apply epics_offset" in capsys.readouterr().out + + +def test_slits_calibration_report_prints_epics_offset_param_and_mismatch(capsys): + displayed = {"xc": 0.03, "xs": 1.0, "yc": 0.0, "ys": 1.0} + devices = _slit_devices(displayed, epics_offset=0.1) + devices["sl1xc"]._config["userParameter"] = {"epics_offset": 0.25} + slits = _make_slits(devices) + + slits.slits_calibration_report(full=False) + + out = capsys.readouterr().out + assert "sl1xc: epics_offset: 0.070000" in out + assert "NOTE: sl1xc EPICS offset 0.100000 differs from configured epics_offset 0.250000" in out diff --git a/tests/tests_bec_ipython_client/test_csaxs_diagnostics_plots.py b/tests/tests_bec_ipython_client/test_csaxs_diagnostics_plots.py new file mode 100644 index 00000000..1edb4fe2 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_csaxs_diagnostics_plots.py @@ -0,0 +1,127 @@ +"""csaxs.diagnostics.show_plots(): one waveform per available diagnostic +device, in its own BEC GUI window.""" + +import types +import csaxs_bec.bec_ipython_client.plugins.cSAXS.diagnostics as diag_module +from csaxs_bec.bec_ipython_client.plugins.cSAXS.diagnostics import cSAXSDiagnostics + + +class _Waveform: + def __init__(self): + self.title = None + self.curves = [] + + def plot(self, device_y, signal_y): + self.curves.append((device_y, signal_y)) + + +class _Window: + def __init__(self): + self.widgets = {} + self.deleted = 0 + + def new(self, widget, object_name, timeout): + assert widget == "Waveform" + self.widgets[object_name] = _Waveform() + return self.widgets[object_name] + + def delete_all(self, timeout): + self.deleted += 1 + self.widgets = {} + + +class _Gui: + def __init__(self): + self.windows = {} + + def new(self, name, timeout): + self.windows[name] = _Window() + setattr(self, name, self.windows[name]) + + +def _diagnostics(monkeypatch, devices): + fake_dev = types.SimpleNamespace(**{d: object() for d in devices}) + monkeypatch.setattr(diag_module, "dev", fake_dev, raising=False) + monkeypatch.setattr(diag_module.time, "sleep", lambda _s: None) + gui = _Gui() + return cSAXSDiagnostics(types.SimpleNamespace(gui=gui)), gui + + +_ALL = [ + "xbpm2_slowrb", + "xbpm3_slowrb", + "xbim_slowrb", + "beamstop_intensity", + "diode_horizontal_xbox1_slowrb", + "diode_vertical_xbox1_slowrb", +] + + +def test_show_plots_default_is_xbpm3_blades_and_xbim(monkeypatch): + diagnostics, gui = _diagnostics(monkeypatch, _ALL) + + diagnostics.show_plots() + + window = gui.windows["csaxs_diagnostics"] + assert list(window.widgets) == ["xbpm3_plot", "xbim_plot"] + assert window.widgets["xbpm3_plot"].curves == [ + ("xbpm3_slowrb", "xbpm3_slowrb_left_top"), + ("xbpm3_slowrb", "xbpm3_slowrb_right_top"), + ("xbpm3_slowrb", "xbpm3_slowrb_left_bot"), + ("xbpm3_slowrb", "xbpm3_slowrb_right_bot"), + ] + assert window.widgets["xbim_plot"].curves == [("xbim_slowrb", "xbim_slowrb_signal")] + assert window.widgets["xbim_plot"].title.startswith("XBIM") + + +def test_show_plots_extra_and_only(monkeypatch): + diagnostics, gui = _diagnostics(monkeypatch, _ALL) + + diagnostics.show_plots("xbpm3_position", "polarization") + window = gui.windows["csaxs_diagnostics"] + assert list(window.widgets) == [ + "xbpm3_plot", + "xbim_plot", + "xbpm3_position_plot", + "polarization_plot", + ] + assert window.widgets["polarization_plot"].curves == [ + ("diode_horizontal_xbox1_slowrb", "diode_horizontal_xbox1_slowrb_signal"), + ("diode_vertical_xbox1_slowrb", "diode_vertical_xbox1_slowrb_signal"), + ] + + diagnostics.show_plots("beamstop", defaults=False) + assert list(window.widgets) == ["beamstop_plot"] + + +def test_show_plots_skips_devices_not_in_config(monkeypatch, capsys): + diagnostics, gui = _diagnostics(monkeypatch, ["xbpm3_slowrb"]) + + diagnostics.show_plots("xbpm2") + + assert list(gui.windows["csaxs_diagnostics"].widgets) == ["xbpm3_plot"] + out = capsys.readouterr().out + assert "Skipping plot 'xbim'" in out and "Skipping plot 'xbpm2'" in out + + +def test_show_plots_unknown_name(monkeypatch, capsys): + diagnostics, gui = _diagnostics(monkeypatch, _ALL) + diagnostics.show_plots("nonsense") + assert gui.windows == {} + assert "Unknown plot(s)" in capsys.readouterr().out + + +def test_show_plots_again_rebuilds_in_same_window(monkeypatch): + diagnostics, gui = _diagnostics(monkeypatch, _ALL) + diagnostics.show_plots() + diagnostics.show_plots() + + window = gui.windows["csaxs_diagnostics"] + assert window.deleted == 1 + assert list(window.widgets) == ["xbpm3_plot", "xbim_plot"] + + +def test_show_plots_without_gui(monkeypatch, capsys): + monkeypatch.setattr(diag_module, "dev", types.SimpleNamespace(), raising=False) + cSAXSDiagnostics(types.SimpleNamespace(gui=None)).show_plots() + assert "No BEC GUI" in capsys.readouterr().out diff --git a/tests/tests_bec_ipython_client/test_lamni_get_val_range.py b/tests/tests_bec_ipython_client/test_lamni_get_val_range.py index 2227eb63..c24d8ae3 100644 --- a/tests/tests_bec_ipython_client/test_lamni_get_val_range.py +++ b/tests/tests_bec_ipython_client/test_lamni_get_val_range.py @@ -7,6 +7,7 @@ range, same mechanism as Flomni's. """ import builtins +import types from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI @@ -103,7 +104,7 @@ def _make_cli_lamni(monkeypatch, answers: dict): def test_cli_rejects_negative_stitch(monkeypatch, capsys): - lamni = _make_cli_lamni(monkeypatch, {"": ["-1", "2"], "": ["-3", "0"]}) + lamni = _make_cli_lamni(monkeypatch, {" (n+1 tiles)": ["-1", "2"], " (n+1 tiles)": ["-3", "0"]}) lamni.tomo_parameters_cli() @@ -130,23 +131,23 @@ def test_cli_type1_rejects_fewer_than_8_projections(monkeypatch): assert lamni._tomo_type1_actual_grid()[2] == 144 -def test_cli_type2_enforces_bunch_size_of_at_least_20(monkeypatch, capsys): +def test_cli_type2_enforces_bunch_size_30_to_200(monkeypatch, capsys): lamni = _make_cli_lamni( monkeypatch, { "Tomography type": ["2"], - "sorted per bunch": ["5", "19", "150", "25"], + "sorted per bunch": ["5", "29", "250", "150"], "Stop after": ["-1", "2000000", "800"], }, ) lamni.tomo_parameters_cli() - assert lamni.golden_ratio_bunch_size == 25 + assert lamni.golden_ratio_bunch_size == 150 assert lamni.golden_max_number_of_projections == 800 out = capsys.readouterr().out - assert "must be >= 20" in out - assert "must be <= 100" in out + assert "must be >= 30" in out + assert "must be <= 200" in out assert "must be <= 1000000" in out @@ -166,3 +167,22 @@ def test_cli_type3_rejects_zero_projections_per_subtomogram(monkeypatch): lamni.tomo_parameters_cli() assert lamni.tomo_angle_stepsize == 10.0 # 360 / 36 + + +def test_cli_too_few_points_skips_confirm_and_reprompts_geometry(monkeypatch, capsys): + """Too few Fermat points (e.g. a starved corner stitch tile): the + parameters cannot be confirmed as they are, and the scan-geometry prompts + repeat until the estimate reaches the minimum.""" + lamni = _make_cli_lamni(monkeypatch, {"20 um -> too few points + lamni._expected_fermat_position_count = lambda: 5 if lamni.lamni_piezo_range_x > 20 else 50 + lamni.lamni_piezo_range_x = 25.0 + + lamni.tomo_parameters_cli() + + assert lamni.lamni_piezo_range_x == 10.0 + assert not any("correctly set" in str(a) for a in asked) + out = capsys.readouterr().out + assert "Too few Fermat scan points" in out 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 936a0dca..9033b1f8 100644 --- a/tests/tests_bec_ipython_client/test_lamni_golive_fixes.py +++ b/tests/tests_bec_ipython_client/test_lamni_golive_fixes.py @@ -421,13 +421,49 @@ def test_tomo_scan_projection_reconstructs_exactly_once(projection_env): def test_tomo_scan_projection_reconstructs_once_per_projection_not_per_tile(projection_env): """With stitching several Fermat scans make up one projection; the reconstruction entry is written once, after all tiles.""" - projection_env.lamni_stitch_x = 1 # 3 x 1 tiles + projection_env.lamni_stitch_x = 1 # 2 x 1 tiles projection_env.tomo_scan_projection(30.0, _internal=True) - assert len(projection_env.fermat_calls) == 3 + assert len(projection_env.fermat_calls) == 2 assert projection_env.reconstruct_calls == [1] +@pytest.mark.parametrize( + "stitch_x, stitch_y, expected", + [ + (0, 0, [(0, 0)]), + (1, 0, [(-0.5, 0), (0.5, 0)]), + (2, 0, [(-1, 0), (0, 0), (1, 0)]), + (1, 1, [(-0.5, -0.5), (-0.5, 0.5), (0.5, -0.5), (0.5, 0.5)]), + ], +) +def test_tomo_scan_projection_stitch_n_gives_n_plus_1_centered_tiles( + projection_env, stitch_x, stitch_y, expected +): + """stitch n -> n+1 tiles per direction, symmetric around the scan center.""" + projection_env.lamni_stitch_x = stitch_x + projection_env.lamni_stitch_y = stitch_y + projection_env.tomo_scan_projection(30.0, _internal=True) + + tiles = [(kw["stitch_x"], kw["stitch_y"]) for kw in projection_env.fermat_calls] + assert tiles == expected + + +def test_tomo_scan_projection_prints_base_shift(projection_env, capsys): + from csaxs_bec.scans.lamni_components import LamNIComponents + + # stored in um (X-ray eye shift_xy); tomo_fovx/y_offset return mm + projection_env.client.set_global_var("tomo_fov_offset", [100.0, 200.0]) + projection_env.tomo_scan_projection(30.0, _internal=True) + + rot_x, rot_y = LamNIComponents.lamni_compute_scan_center(0.1, 0.2, 30.0) + out = capsys.readouterr().out + assert ( + f"Base shift: x=0.100000 mm, y=0.200000 mm at 0 deg -> " + f"x={rot_x:.6f} mm, y={rot_y:.6f} mm @ angle=30.0" + ) in out + + def test_tomo_scan_projection_can_skip_reconstruction(projection_env): projection_env.tomo_scan_projection(30.0, _internal=True, _reconstruct=False) diff --git a/tests/tests_bec_ipython_client/test_lamni_pdf_report.py b/tests/tests_bec_ipython_client/test_lamni_pdf_report.py index f790a818..e77eaf12 100644 --- a/tests/tests_bec_ipython_client/test_lamni_pdf_report.py +++ b/tests/tests_bec_ipython_client/test_lamni_pdf_report.py @@ -131,7 +131,7 @@ def test_golden_ratio_report_does_not_use_the_stale_type1_grid(report_lamni): def test_golden_ratio_endless_scan_reports_manual_stop(report_lamni): report_lamni.tomo_type = 2 report_lamni.golden_max_number_of_projections = 0 - report_lamni.golden_ratio_bunch_size = 20 + report_lamni.golden_ratio_bunch_size = 30 report = _report(report_lamni) diff --git a/tests/tests_bec_ipython_client/test_lamni_reset_correction.py b/tests/tests_bec_ipython_client/test_lamni_reset_correction.py new file mode 100644 index 00000000..aaa6ce60 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_reset_correction.py @@ -0,0 +1,55 @@ +"""lamni.reset_correction() must clear both lookup-table iterations, so a +correction 2 from the previous sample is not applied to the next one.""" + +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI + + +class FakeClient: + """Minimal in-memory stand-in for BEC's global-var store.""" + + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +def make_lamni(): + obj = object.__new__(LamNI) + obj.client = FakeClient() + return obj + + +def _fill(lamni): + lamni.corr_pos_x, lamni.corr_pos_y, lamni.corr_angle = [0.1], [0.2], [0.0] + lamni.corr_pos_x_2, lamni.corr_pos_y_2, lamni.corr_angle_2 = [0.3], [0.4], [90.0] + + +def test_reset_correction_clears_both_iterations(): + lamni = make_lamni() + _fill(lamni) + + lamni.reset_correction() + + assert lamni.corr_pos_x == lamni.corr_pos_y == lamni.corr_angle == [] + assert lamni.corr_pos_x_2 == lamni.corr_pos_y_2 == lamni.corr_angle_2 == [] + assert lamni.compute_additional_correction_2(90.0) == (0, 0) + + +def test_reset_correction_2_keeps_iteration_1(): + lamni = make_lamni() + _fill(lamni) + + lamni.reset_correction_2() + + assert lamni.corr_pos_x == [0.1] + assert lamni.corr_pos_x_2 == [] + + +def test_existing_alignment_reports_correction_2(): + lamni = make_lamni() + lamni.corr_pos_x_2 = [0.3] + assert "fine-alignment correction 2" in lamni._existing_alignment() diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py index a3d45ab8..c6624736 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py @@ -93,7 +93,7 @@ def test_tomo_alignment_scan_aborts_without_xray_eye_fit(monkeypatch): assert lamni._scilog_calls == [] -def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch): +def test_tomo_alignment_scan_runs_13_projections_0_to_360(monkeypatch): lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) calls = [] @@ -105,12 +105,12 @@ def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch): lamni.tomo_alignment_scan() - expected_angles = list(np.linspace(0, 360, num=12, endpoint=False)) + expected_angles = list(np.linspace(0, 360, num=13)) assert calls == expected_angles assert len(lamni._scilog_calls) == 1 content, tags = lamni._scilog_calls[0] assert tags == ["alignmentscan"] - assert "Number of alignment scans: 12" in content, content + assert "Number of alignment scans: 13" in content, content def test_tomo_alignment_scan_rotates_back_to_zero(monkeypatch): @@ -138,10 +138,10 @@ def test_write_alignment_scan_numbers_format(tmp_path, monkeypatch): assert len(lines) == 4 scans = [int(s) for s in lines[0].split()] angles = [float(a) for a in lines[1].split()] - assert scans == list(range(100, 112)) - assert angles == list(np.linspace(0, 360, num=12, endpoint=False)) - assert len(lines[2].split()) == 12 - assert len(lines[3].split()) == 12 + assert scans == list(range(100, 113)) + assert angles == list(np.linspace(0, 360, num=13)) + assert len(lines[2].split()) == 13 + assert len(lines[3].split()) == 13 def _timeout_alarm(): @@ -176,8 +176,8 @@ def test_tomo_alignment_scan_retries_timed_out_angle_and_records_real_scan_numbe lamni.tomo_alignment_scan() - expected_angles = list(np.linspace(0, 360, num=12, endpoint=False)) - assert len(calls) == 13, "the timed-out angle must be re-acquired" + expected_angles = list(np.linspace(0, 360, num=13)) + assert len(calls) == 14, "the timed-out angle must be re-acquired" assert calls[2] == calls[3] == expected_angles[2] log_file = tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt" @@ -185,10 +185,10 @@ def test_tomo_alignment_scan_retries_timed_out_angle_and_records_real_scan_numbe scans = [int(s) for s in lines[0].split()] # scans 100, 101 = angles 0, 1; 102 = the failed attempt at angle 2 (must be # skipped); 103.. = the retry and the remaining angles - assert scans == [100, 101] + list(range(103, 113)) + assert scans == [100, 101] + list(range(103, 114)) assert [float(a) for a in lines[1].split()] == expected_angles content, _tags = lamni._scilog_calls[0] - assert "Number of alignment scans: 12" in content + assert "Number of alignment scans: 13" in content def test_tomo_alignment_scan_projections_are_not_queued_for_reconstruction(monkeypatch): @@ -201,5 +201,96 @@ def test_tomo_alignment_scan_projections_are_not_queued_for_reconstruction(monke lamni.tomo_alignment_scan() - assert len(kwargs_seen) == 12 + assert len(kwargs_seen) == 13 assert all(kw.get("_reconstruct") is False for kw in kwargs_seen) + + +def test_write_alignment_scan_numbers_steps_by_stitch_tiles(tmp_path, monkeypatch): + """With stitching each angle is (sx+1)*(sy+1) scans; the file must list + the first scan of each angle's stitch series.""" + lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + lamni.client.set_global_var("lamni_stitch_x", 1) + lamni.client.set_global_var("lamni_stitch_y", 1) + monkeypatch.setattr( + "os.path.expanduser", + lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p, + ) + log_file = tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt" + + lamni.write_alignment_scan_numbers(100) + assert [int(s) for s in log_file.read_text().split("\n")[0].split()] == list( + range(100, 152, 4) + ) + + # partial actual list (angle 1 retried, +1 scan): rest predicted from it + lamni.write_alignment_scan_numbers(100, scans=[100, 110]) + assert [int(s) for s in log_file.read_text().split("\n")[0].split()] == [100] + list( + range(110, 158, 4) + ) + + +def test_tomo_alignment_scan_with_stitching_records_first_scan_per_angle(tmp_path, monkeypatch): + lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + lamni.client.set_global_var("lamni_stitch_x", 1) + lamni.client.set_global_var("lamni_stitch_y", 1) + monkeypatch.setattr( + "os.path.expanduser", + lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p, + ) + + def _projection(angle, _internal=False, **kw): + builtins.__dict__["bec"].queue.next_scan_number += 4 + + lamni.tomo_scan_projection = _projection + + lamni.tomo_alignment_scan() + + log_file = tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt" + scans = [int(s) for s in log_file.read_text().splitlines()[0].split()] + assert scans == list(range(100, 152, 4)) + + +def test_write_alignment_scan_numbers_lines_3_4_are_x_and_y_in_um(tmp_path, monkeypatch): + """BEC_ptycho_align reads line 3 as scan_xpos and line 4 as scan_ypos, in + microns; the X-ray eye fit is stored in microns ([[Ax,Bx,Cx],[Ay,By,Cy]]).""" + lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 12.5], [0.0, 0.0, -3.25]]) + monkeypatch.setattr( + "os.path.expanduser", + lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p, + ) + + lamni.write_alignment_scan_numbers(100) + + lines = (tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt").read_text().splitlines() + assert [float(v) for v in lines[2].split()] == pytest.approx([12.5] * 13) + assert [float(v) for v in lines[3].split()] == pytest.approx([-3.25] * 13) + + +def test_tomo_alignment_scan_number_of_projections(tmp_path, monkeypatch): + lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + monkeypatch.setattr( + "os.path.expanduser", + lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p, + ) + calls = [] + + def _fake_projection(angle, _internal=False, **kw): + calls.append(angle) + builtins.__dict__["bec"].queue.next_scan_number += 1 + + lamni.tomo_scan_projection = _fake_projection + + lamni.tomo_alignment_scan(number_of_projections=5) + + assert calls == [0.0, 90.0, 180.0, 270.0, 360.0] + lines = (tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt").read_text().splitlines() + assert [int(s) for s in lines[0].split()] == [100, 101, 102, 103, 104] + assert [float(a) for a in lines[1].split()] == [0.0, 90.0, 180.0, 270.0, 360.0] + + +@pytest.mark.parametrize("n", [0, 1, 2.5]) +def test_tomo_alignment_scan_rejects_invalid_number_of_projections(monkeypatch, n): + lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + lamni.tomo_scan_projection = lambda angle, **kw: pytest.fail("must not scan") + with pytest.raises(ValueError): + lamni.tomo_alignment_scan(number_of_projections=n) diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py index 5363044b..fa4e3bdb 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py @@ -537,7 +537,7 @@ def _fake_golden(ii, bunch_size, maxangle=360, reverse=True): def make_lamni_for_golden_resume(monkeypatch, tomo_type: int) -> LamNI: lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="") lamni.tomo_type = tomo_type - lamni.golden_ratio_bunch_size = 20 + lamni.golden_ratio_bunch_size = 30 lamni.golden_projections_at_0_deg_for_damage_estimation = 1 lamni.golden_max_number_of_projections = 21 lamni.add_sample_database = lambda *a, **k: 0 diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py index 8f777712..5a0c7fff 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py @@ -219,10 +219,8 @@ def test_compute_fermat_positions_lamni_matches_scan_class_above_threshold(): def test_compute_fermat_positions_lamni_uses_live_stitch_tile(): - """Unlike the CLI's tomo_parameters() (which always estimates the - center tile as a quick summary), the GUI has the actual stitch_x/y - fields right there, so it should reflect whichever tile is currently - being edited, not always stitch=0.""" + """The estimate is the smallest stitch tile's count, so it depends on + the stitch_x/y being edited, not always the center tile.""" base_params = { "lamni_piezo_range_x": 20.0, "lamni_piezo_range_y": 20.0, @@ -253,7 +251,7 @@ def test_compute_fermat_positions_lamni_zero_step_is_safe(): # ── golden bunch-size floor + manual-shift units ──────────────────────────── # -# lamni's golden_ratio_bunch_size setter/CLI reject < 20, but the shared GUI +# lamni's golden_ratio_bunch_size setter/CLI reject values outside 30..200 (was < 20), but the shared GUI # spinbox allowed 1..100: a GUI-queued job with a smaller value made # tomo_queue_execute()'s setattr() raise ValueError and pause the queue. # The manual-shift fields were labelled um (+-1000) but lamni's manual shift is @@ -272,12 +270,12 @@ def _bare_widget_for(setup: str): return widget -def test_lamni_bunch_size_spinbox_floor_is_20(): +def test_lamni_bunch_size_spinbox_range_is_30_to_200(): widget = _bare_widget_for("lamni") box = widget._build_type23_section() # keep a reference: it owns the spinboxes assert box is not None - assert widget._pw["golden_ratio_bunch_size"].minimum() == 20 - assert widget._pw["golden_ratio_bunch_size"].maximum() == 100 + assert widget._pw["golden_ratio_bunch_size"].minimum() == 30 + assert widget._pw["golden_ratio_bunch_size"].maximum() == 200 def test_flomni_bunch_size_spinbox_is_unchanged(): @@ -285,6 +283,7 @@ def test_flomni_bunch_size_spinbox_is_unchanged(): box = widget._build_type23_section() # keep a reference: it owns the spinboxes assert box is not None assert widget._pw["golden_ratio_bunch_size"].minimum() == 1 + assert widget._pw["golden_ratio_bunch_size"].maximum() == 100 def test_lamni_gui_bunch_size_floor_matches_the_setter(): @@ -304,9 +303,14 @@ def test_lamni_gui_bunch_size_floor_matches_the_setter(): lamni.client = FakeClient() floor = SETUP_PROFILES["lamni"]["golden_bunch_size_min"] + ceiling = SETUP_PROFILES["lamni"]["golden_bunch_size_max"] + lamni.golden_ratio_bunch_size = floor # must not raise + lamni.golden_ratio_bunch_size = ceiling # must not raise with pytest.raises(ValueError): lamni.golden_ratio_bunch_size = floor - 1 + with pytest.raises(ValueError): + lamni.golden_ratio_bunch_size = ceiling + 1 def test_lamni_manual_shift_fields_are_in_mm(): @@ -326,3 +330,25 @@ def test_flomni_manual_shift_fields_are_in_um(): assert "µm" in label assert by_key["manual_shift_x"] == (-10.0, 10.0) assert by_key["manual_shift_y"] == (-50.0, 50.0) + + +def test_compute_fermat_positions_lamni_blocks_on_starved_corner_tile(): + """Center tile fine, corner tiles below the minimum (3x3 tiles, 36 um + circular FOV): the estimate must be the corner count, so submit is + blocked as in the non-stitched case.""" + params = { + "lamni_piezo_range_x": 20.0, + "lamni_piezo_range_y": 20.0, + "tomo_shellstep": 1.0, + "tomo_stitch_overlap": 0.2, + "tomo_circfov": 36.0, + "lamni_stitch_x": 2, + "lamni_stitch_y": 2, + } + count, min_positions = _compute_fermat_positions_lamni(params) + assert count < min_positions + + count, min_positions = _compute_fermat_positions_lamni( + dict(params, lamni_piezo_range_x=10.0, lamni_piezo_range_y=10.0) + ) + assert count >= min_positions diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py index fdc43c3e..854fe751 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py @@ -9,6 +9,8 @@ tomo-queue backend actually needs: self.client + _init_tomo_queue(). import types +import pytest + import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module import csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin as tomo_queue_mixin from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, LamNIError, _ProgressProxy @@ -193,6 +195,85 @@ def test_tomo_queue_add_command_rejects_non_movable_device(): assert "not queue-movable" in str(exc) +class _FakeLoptzDev: + """dev stand-in for move_loptz_with_shift: loptz with limits and the + OSA collision_offset, losaz at a given readback.""" + + def __init__(self, losaz_pos=0.0, collision_offset=87.87): + self.loptz = types.SimpleNamespace( + name="loptz", + limits=(70.0, 83.0), + user_parameter={"collision_offset": collision_offset}, + ) + self.losaz = types.SimpleNamespace( + name="losaz", + user_parameter={"in": 5.0}, + readback=types.SimpleNamespace(get=lambda: losaz_pos), + ) + + def __getitem__(self, name): + return getattr(self, name) + + +def _setup_loptz_action(monkeypatch, fake_dev): + import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin as optics_module + + lamni = make_lamni() + lamni.device_manager = types.SimpleNamespace(devices={"loptz": fake_dev.loptz}) + moved = [] + monkeypatch.setattr(lamni_module, "dev", fake_dev, raising=False) + monkeypatch.setattr(optics_module, "dev", fake_dev, raising=False) + monkeypatch.setattr(lamni_module, "umv", lambda *a: moved.append(a), raising=False) + return lamni, moved + + +def test_tomo_queue_move_loptz_with_shift_moves_and_sets_shift(monkeypatch): + fake_dev = _FakeLoptzDev(losaz_pos=0.0) + lamni, moved = _setup_loptz_action(monkeypatch, fake_dev) + + lamni.tomo_queue_add_command( + { + "action": "move_loptz_with_shift", + "kwargs": {"loptz_target": 78.0, "loptz_shift_x": 0.002, "loptz_shift_y": -0.003}, + } + ) + lamni.tomo_queue_execute() + + assert moved == [(fake_dev.loptz, 78.0)] + assert lamni.loptz_shift_x == 0.002 + assert lamni.loptz_shift_y == -0.003 + # manual shift untouched -- tomo jobs snapshot it, so the queue action must not use it + assert lamni.manual_shift_x == 0.0 + assert lamni._tomo_queue_proxy.as_list()[0]["status"] == "done" + + +def test_tomo_queue_move_loptz_with_shift_rejects_target_outside_limits(monkeypatch): + lamni, _moved = _setup_loptz_action(monkeypatch, _FakeLoptzDev()) + try: + lamni.tomo_queue_add_command( + { + "action": "move_loptz_with_shift", + "kwargs": {"loptz_target": 90.0, "loptz_shift_x": 0.0, "loptz_shift_y": 0.0}, + } + ) + assert False, "expected ValueError for a loptz target above the limit" + except ValueError as exc: + assert "above the maximum" in str(exc) + + +def test_queue_action_move_loptz_refuses_osa_collision(monkeypatch): + # losaz 10 + loptz 78 > collision_offset 87.87 -> collision + lamni, moved = _setup_loptz_action(monkeypatch, _FakeLoptzDev(losaz_pos=10.0)) + lamni.loptz_shift_x = 0.5 + try: + lamni._queue_action_move_loptz_with_shift(78.0, 0.0, 0.0) + assert False, "expected ValueError for an OSA collision" + except ValueError as exc: + assert "collision" in str(exc) + assert moved == [] + assert lamni.loptz_shift_x == 0.5 + + def test_register_at_each_angle_hook_dispatches_from_at_each_angle(): lamni = make_lamni() seen = [] @@ -343,3 +424,91 @@ def test_tomo_scan_resume_from_queue_never_prompts(): lamni, calls = _resumable_lamni(status="running", answer=False) lamni.tomo_scan_resume(from_queue=True) assert calls == [("scan", (), {"projection_number": 5})] + + +def test_format_command_step_shows_units(): + lamni = make_lamni() + registry = lamni._describe_tomo_queue_actions() + shift_step = { + "action": "move_loptz_with_shift", + "kwargs": {"loptz_target": 78.0, "loptz_shift_x": 0.002, "loptz_shift_y": -0.003}, + } + assert tomo_queue_mixin.format_command_step(shift_step, registry) == ( + "move_loptz_with_shift(loptz_target=78.0 mm, loptz_shift_x=0.002 mm, " + "loptz_shift_y=-0.003 mm)" + ) + move_step = {"action": "move", "kwargs": {"positions": {"ccm_energy": 6.2}}} + assert tomo_queue_mixin.format_command_step(move_step, registry) == "move(ccm_energy=6.2 keV)" + assert tomo_queue_mixin.format_command_step({"action": "noop", "kwargs": {}}) == "noop" + + +def _setup_keithley_action(monkeypatch): + fake_dev = _FakeDev() + fake_dev.keithley = types.SimpleNamespace(limits=(0.0, 3.0)) + lamni = make_lamni() + lamni.device_manager = types.SimpleNamespace(devices={"keithley": fake_dev.keithley}) + calls = [] + monkeypatch.setattr(lamni_module, "dev", fake_dev, raising=False) + monkeypatch.setattr(lamni_module, "umv", lambda *a: calls.append(("umv", a)), raising=False) + monkeypatch.setattr(lamni_module.time, "sleep", lambda s: calls.append(("sleep", s))) + return lamni, fake_dev, calls + + +def test_tomo_queue_set_keithley_current_sets_then_sleeps(monkeypatch): + lamni, fake_dev, calls = _setup_keithley_action(monkeypatch) + lamni.tomo_queue_add_command( + {"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 30}} + ) + lamni.tomo_queue_execute() + assert calls == [("umv", (fake_dev.keithley, 1.5)), ("sleep", 30)] + assert lamni._tomo_queue_proxy[0]["status"] == "done" + assert lamni._tomo_queue_proxy[0]["idempotent"] is True + + +def test_tomo_queue_set_keithley_current_without_sleep(monkeypatch): + lamni, fake_dev, calls = _setup_keithley_action(monkeypatch) + lamni._queue_action_set_keithley_current(0.0) + assert calls == [("umv", (fake_dev.keithley, 0.0))] + + +def test_tomo_queue_set_keithley_current_rejects_out_of_limit(monkeypatch): + lamni, _fake_dev, calls = _setup_keithley_action(monkeypatch) + with pytest.raises(ValueError, match="above the maximum"): + lamni.tomo_queue_add_command( + {"action": "set_keithley_current", "kwargs": {"current_mA": 3.5}} + ) + with pytest.raises(ValueError, match="current limits"): + lamni._queue_action_set_keithley_current(3.5) + assert calls == [] + + +def test_tomo_queue_set_keithley_current_without_device_pauses_the_queue(monkeypatch): + lamni, fake_dev, calls = _setup_keithley_action(monkeypatch) + del fake_dev.keithley + lamni.device_manager.devices.clear() + lamni.tomo_queue_add_command( + {"action": "set_keithley_current", "kwargs": {"current_mA": 1.0}} + ) + with pytest.raises(ValueError, match="not loaded"): + lamni.tomo_queue_execute() + assert lamni._tomo_queue_proxy[0]["status"] == "incomplete" + assert calls == [] + + +def test_tomo_queue_sleep_action(monkeypatch): + lamni, _fake_dev, calls = _setup_keithley_action(monkeypatch) + lamni.tomo_queue_add_command({"action": "sleep", "kwargs": {"seconds": 5}}) + lamni.tomo_queue_execute() + assert calls == [("sleep", 5)] + with pytest.raises(ValueError, match="below the minimum"): + lamni.tomo_queue_add_command({"action": "sleep", "kwargs": {"seconds": -1}}) + + +def test_format_command_step_keithley_units(): + lamni = make_lamni() + registry = lamni._describe_tomo_queue_actions() + step = {"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 30}} + assert ( + tomo_queue_mixin.format_command_step(step, registry) + == "set_keithley_current(current_mA=1.5 mA, sleep_s=30 s)" + ) diff --git a/tests/tests_bec_ipython_client/test_projection_idle_time.py b/tests/tests_bec_ipython_client/test_projection_idle_time.py new file mode 100644 index 00000000..20626e38 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_projection_idle_time.py @@ -0,0 +1,119 @@ +"""ETA idle-time accounting: normal projections (minutes-long ptycho scans) +must not count as idle; real pauses must; a fresh tomo scan starts clean.""" + +import datetime + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import _ProgressProxy as FlomniProgress +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import _ProgressProxy as LamniProgress +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.idle_time import account_projection_gap + +T0 = datetime.datetime(2026, 10, 19, 5, 3) + + +class FakeClient: + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +def _run(progress, gaps_s): + """Feed projections separated by *gaps_s*; return the total idle added.""" + t = T0 + total = account_projection_gap(progress, t) + for gap in gaps_s: + t += datetime.timedelta(seconds=gap) + total += account_projection_gap(progress, t) + return total + + +def test_steady_minutes_long_projections_are_not_idle(): + # the beamtime case: ~4 min per projection, 139 projections + progress = {"heartbeat": None, "accumulated_idle_time": 0.0} + assert _run(progress, [235, 240, 238, 245] * 35) == 0 + assert progress["accumulated_idle_time"] == 0 + + +def test_long_pause_is_idle(): + progress = {"heartbeat": None, "accumulated_idle_time": 0.0} + _run(progress, [240] * 10) + gaps_before = list(progress["recent_projection_gaps"]) + idle = _run_from_heartbeat(progress, [2 * 3600]) + assert idle == pytest.approx(2 * 3600 - 240) + assert progress["accumulated_idle_time"] == pytest.approx(idle) + assert progress["recent_projection_gaps"] == gaps_before + + +def _run_from_heartbeat(progress, gaps_s): + t = datetime.datetime.fromisoformat(progress["heartbeat"]) + total = 0.0 + for gap in gaps_s: + t += datetime.timedelta(seconds=gap) + total += account_projection_gap(progress, t) + return total + + +def test_zero_deg_reference_and_repeats_are_not_idle(): + progress = {"heartbeat": None, "accumulated_idle_time": 0.0} + assert _run(progress, [240] * 5 + [480, 240, 700, 240]) == 0 + + +def test_startup_gaps_never_idle_and_startup_pause_not_sampled(): + progress = {"heartbeat": None, "accumulated_idle_time": 0.0} + assert _run(progress, [45 * 60, 240, 240]) == 0 + assert progress["recent_projection_gaps"] == [240, 240] + + +def test_no_heartbeat_sets_it(): + progress = {"heartbeat": None} + assert account_projection_gap(progress, T0) == 0 + assert progress["heartbeat"] == T0.isoformat() + + +def test_gap_samples_are_capped(): + progress = {"heartbeat": None, "accumulated_idle_time": 0.0} + _run(progress, [240] * 30) + assert len(progress["recent_projection_gaps"]) == 10 + + +# ── fresh tomo scan resets the bookkeeping ────────────────────────────────── + + +def test_lamni_new_scan_resets_idle_bookkeeping(monkeypatch): + lamni = object.__new__(LamNI) + lamni.client = FakeClient() + lamni._progress_proxy = LamniProgress(lamni.client) + lamni.progress["accumulated_idle_time"] = 24352.0 + lamni.progress["recent_projection_gaps"] = [240.0] * 10 + lamni.progress["heartbeat"] = T0.isoformat() + monkeypatch.setattr(LamNI, "tomo_type", 2, raising=False) + monkeypatch.setattr(LamNI, "golden_ratio_bunch_size", 100, raising=False) + monkeypatch.setattr(LamNI, "golden_max_number_of_projections", 300, raising=False) + monkeypatch.setattr(LamNI, "_lamnigui_update_progress", lambda self: None) + + lamni._reset_progress_for_new_scan() + + assert lamni.progress["accumulated_idle_time"] == 0.0 + assert lamni.progress["recent_projection_gaps"] == [] + assert lamni.progress["heartbeat"] is None + assert lamni.progress["tomo_start_time"] is not None + + +def test_flomni_progress_reset_clears_idle_bookkeeping(): + progress = FlomniProgress(FakeClient()) + progress["accumulated_idle_time"] = 24352.0 + progress["recent_projection_gaps"] = [240.0] * 10 + progress["heartbeat"] = T0.isoformat() + + progress.reset() + + assert progress["accumulated_idle_time"] == 0.0 + assert progress["recent_projection_gaps"] == [] + assert progress["heartbeat"] is None diff --git a/tests/tests_bec_ipython_client/test_stitch_tile_offsets.py b/tests/tests_bec_ipython_client/test_stitch_tile_offsets.py new file mode 100644 index 00000000..c71b79c3 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_stitch_tile_offsets.py @@ -0,0 +1,43 @@ +"""stitch n -> n+1 tiles per direction, centered on the scan center +(shared by LamNI and flOMNI tomo_scan_projection).""" + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import ( + stitch_tile_offsets, +) + + +@pytest.mark.parametrize( + "n, expected", + [ + (0, [0]), + (1, [-0.5, 0.5]), + (2, [-1, 0, 1]), + (3, [-1.5, -0.5, 0.5, 1.5]), + ], +) +def test_stitch_tile_offsets(n, expected): + assert stitch_tile_offsets(n) == expected + + +def test_stitch_tile_offsets_negative_is_empty(): + assert stitch_tile_offsets(-1) == [] + + +def test_smallest_tile_count_finds_starved_corner_tile(): + """3x3 tiles, 20 um piezo range, 36 um circular FOV: the center tile is + full, but a corner tile holds only a few points (below the scan's + minimum of 20). A smaller piezo range pulls the corners into the circle.""" + from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import ( + lamni_smallest_tile_position_count, + ) + + center, _ = lamni_smallest_tile_position_count(20, 20, 1.0, 0, 0, 0.2, 36) + corner, tile = lamni_smallest_tile_position_count(20, 20, 1.0, 2, 2, 0.2, 36) + smaller_piezo, _ = lamni_smallest_tile_position_count(10, 10, 1.0, 2, 2, 0.2, 36) + + assert center > 100 + assert corner < 20 + assert abs(tile[0]) == 1 and abs(tile[1]) == 1 + assert smaller_piezo >= 20 diff --git a/tests/tests_bec_ipython_client/test_webpage_ptycho_full_size_cap.py b/tests/tests_bec_ipython_client/test_webpage_ptycho_full_size_cap.py new file mode 100644 index 00000000..db2cc063 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_webpage_ptycho_full_size_cap.py @@ -0,0 +1,32 @@ +"""Full-size ptycho PNGs copied for upload are capped in size: the upload +server rejected large (stitched) probe/spectrum images with HTTP 413.""" + +import numpy as np +import pytest + +PIL = pytest.importorskip("PIL.Image") + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base import ( + WebpageGeneratorBase, +) + + +def test_large_png_is_downscaled_below_cap(tmp_path): + src, dst = tmp_path / "S00085-S00093_probe.png", tmp_path / "out.png" + PIL.fromarray((np.random.default_rng(0).random((800, 1200, 3)) * 255).astype("uint8")).save(src) + cap = 300_000 + assert src.stat().st_size > cap + + WebpageGeneratorBase._copy_full_capped(src, dst, max_bytes=cap) + + assert dst.stat().st_size <= cap + assert PIL.open(dst).size[0] < 1200 + + +def test_small_png_is_copied_unchanged(tmp_path): + src, dst = tmp_path / "S00001_phase.png", tmp_path / "out.png" + PIL.new("RGB", (50, 50)).save(src) + + WebpageGeneratorBase._copy_full_capped(src, dst, max_bytes=300_000) + + assert dst.read_bytes() == src.read_bytes() 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 725088f5..29b8f921 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 @@ -484,7 +484,7 @@ def test_align_offers_free_ellipse_after_fzp_click_and_removes_it_on_abort(bec_c # ---------------------------------------------------------------------- -# EXPERIMENTAL: rotation-center smear mode (_smear_sweep) +# rotation-center smear mode (_smear_sweep) # ---------------------------------------------------------------------- @@ -721,7 +721,7 @@ def test_smear_sweep_cancels_report_on_keyboard_interrupt(bec_client_mock): align.gui.set_smear_active.assert_any_call(False) -def test_find_rotation_center_smear_experimental_switches_live_view_signal(bec_client_mock): +def test_find_rotation_center_smear_switches_live_view_signal(bec_client_mock): """Regression guard: the GUI must stay on the composite channel until AFTER the operator's click is collected, then switch back -- switching back inside _smear_sweep itself (before the click) would show the @@ -740,14 +740,14 @@ def test_find_rotation_center_smear_experimental_switches_live_view_signal(bec_c with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): with mock.patch(f"{OMNY_GENERAL_TOOLS}.input", side_effect=["y", "y"]): with mock.patch.object(align, "_save_smear_calibration_data"): - align.find_rotation_center_smear_experimental() + align.find_rotation_center_smear() calls = [c.args[0] for c in align.gui.set_live_view_signal.call_args_list] assert calls == ["smear_preview", "image"] dev_mock.cam_xeye.live_mode_enabled.put.assert_not_called() -def test_find_rotation_center_smear_experimental_saves_composites_and_clicks(bec_client_mock): +def test_find_rotation_center_smear_saves_composites_and_clicks(bec_client_mock): """Composites and clicked centres from every iteration must be archived for offline analysis -- confirms the data reaching _save_smear_calibration_data, without touching the real filesystem.""" @@ -766,7 +766,7 @@ def test_find_rotation_center_smear_experimental_saves_composites_and_clicks(bec with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): with mock.patch(f"{OMNY_GENERAL_TOOLS}.input", side_effect=["y", "y"]): with mock.patch.object(align, "_save_smear_calibration_data") as save_mock: - align.find_rotation_center_smear_experimental(sweep_deg=45.0) + align.find_rotation_center_smear(sweep_deg=45.0) assert len(align.smear_composites) == 1 assert np.array_equal(align.smear_composites[0], frame) diff --git a/tests/tests_bec_widgets/test_tomo_params_keithley_queue.py b/tests/tests_bec_widgets/test_tomo_params_keithley_queue.py new file mode 100644 index 00000000..c5468fdb --- /dev/null +++ b/tests/tests_bec_widgets/test_tomo_params_keithley_queue.py @@ -0,0 +1,62 @@ +"""The LamNI keithley and sleep queue actions must be buildable in the command job dialog, which +is generated from the published action registry.""" + +import os +from unittest import mock + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from qtpy.QtWidgets import QApplication, QDoubleSpinBox # noqa: E402 + +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI # noqa: E402 +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( # noqa: E402 + CommandJobBuilderDialog, +) + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def dialog(qapp): + registry = { + "actions": { + name: {key: spec[key] for key in ("idempotent", "help", "params")} + for name, spec in LamNI._TOMO_QUEUE_ACTIONS.items() + }, + "move_devices": {}, + } + client = mock.MagicMock() + client.get_global_var.return_value = registry + client.device_manager.devices = {"keithley": mock.MagicMock(limits=(0.0, 3.0))} + return CommandJobBuilderDialog(client) + + +def _add_step(dialog, action, **values): + dialog._action_combo.setCurrentIndex(dialog._action_combo.findData(action)) + for name, value in values.items(): + widget, _field = dialog._step_field_widgets[name] + assert isinstance(widget, QDoubleSpinBox) + widget.setValue(value) + dialog._on_add_step() + + +def test_keithley_and_sleep_steps_build_a_queue_job(dialog): + _add_step(dialog, "set_keithley_current", current_mA=1.5, sleep_s=30) + _add_step(dialog, "sleep", seconds=60) + dialog._on_accept() + assert dialog.job["steps"] == [ + {"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 30}}, + {"action": "sleep", "kwargs": {"seconds": 60}}, + ] + assert dialog.job["idempotent"] is True + + +def test_current_field_is_bounded_by_the_device_limits(dialog): + dialog._action_combo.setCurrentIndex(dialog._action_combo.findData("set_keithley_current")) + widget, _field = dialog._step_field_widgets["current_mA"] + assert (widget.minimum(), widget.maximum()) == (0.0, 3.0) diff --git a/tests/tests_bec_widgets/test_tomo_params_no_wheel.py b/tests/tests_bec_widgets/test_tomo_params_no_wheel.py new file mode 100644 index 00000000..7c157260 --- /dev/null +++ b/tests/tests_bec_widgets/test_tomo_params_no_wheel.py @@ -0,0 +1,90 @@ +"""The mouse wheel must not change tomo_params values: users scroll the +panel and fields changed without anyone noticing. The full widget needs a +live BEC client, so this exercises _block_wheel_edits() on a plain scroll +area, the same setup as OMNY_TomoParams._build_ui().""" + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from qtpy.QtCore import QPoint, QPointF, Qt # noqa: E402 +from qtpy.QtGui import QWheelEvent # noqa: E402 +from qtpy.QtWidgets import ( # noqa: E402 + QApplication, + QComboBox, + QDoubleSpinBox, + QScrollArea, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( # noqa: E402 + _block_wheel_edits, +) + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +def _wheel(widget, delta=120): + event = QWheelEvent( + QPointF(5, 5), + QPointF(widget.mapToGlobal(QPoint(5, 5))), + QPoint(0, 0), + QPoint(0, delta), + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, + False, + ) + QApplication.sendEvent(widget, event) + + +def _panel(): + scroll = QScrollArea() + scroll.setWidgetResizable(True) + inner = QWidget() + layout = QVBoxLayout(inner) + dspin, ispin, combo = QDoubleSpinBox(), QSpinBox(), QComboBox() + dspin.setRange(0, 100) + dspin.setValue(50) + ispin.setRange(0, 100) + ispin.setValue(50) + combo.addItems(["1", "2", "3"]) + combo.setCurrentIndex(1) + for w in (dspin, ispin, combo): + layout.addWidget(w) + scroll.setWidget(inner) + return scroll, inner, dspin, ispin, combo + + +def test_wheel_changes_values_without_filter(qapp): + # sanity check that the synthetic wheel event does reach the widgets + scroll, _, dspin, ispin, combo = _panel() # keep scroll alive + for w in (dspin, ispin, combo): + _wheel(w, delta=-120) + assert dspin.value() != 50 or ispin.value() != 50 or combo.currentIndex() != 1 + + +def test_wheel_does_not_change_values(qapp): + scroll, inner, dspin, ispin, combo = _panel() + _block_wheel_edits(inner, scroll.viewport()) + for w in (dspin, ispin, combo): + _wheel(w) + _wheel(w, delta=-120) + assert dspin.value() == 50 + assert ispin.value() == 50 + assert combo.currentIndex() == 1 + assert dspin.focusPolicy() == Qt.FocusPolicy.StrongFocus + + +def test_keyboard_still_edits(qapp): + scroll, inner, dspin, _, _ = _panel() + _block_wheel_edits(inner, scroll.viewport()) + dspin.stepUp() + assert dspin.value() == 51 diff --git a/tests/tests_bec_widgets/test_tomo_params_select_on_focus.py b/tests/tests_bec_widgets/test_tomo_params_select_on_focus.py new file mode 100644 index 00000000..1c42c265 --- /dev/null +++ b/tests/tests_bec_widgets/test_tomo_params_select_on_focus.py @@ -0,0 +1,79 @@ +"""Clicking into a tomo_params spin box and typing must replace the value. +Without select-on-focus the cursor sits at the end and digits are appended: +golden-ratio bunch size 100 (range 30..200) + "5" -> 1005, which the spin +box silently rejects, so only the arrows could change it. The full widget +needs a live BEC client, so this exercises _select_all_on_focus() on a plain +panel, the same setup as OMNY_TomoParams._build_ui().""" + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from qtpy.QtCore import QPoint, Qt # noqa: E402 +from qtpy.QtTest import QTest # noqa: E402 +from qtpy.QtWidgets import ( # noqa: E402 + QApplication, + QDoubleSpinBox, + QLineEdit, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( # noqa: E402 + _select_all_on_focus, +) + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +def _panel(): + panel = QWidget() + layout = QVBoxLayout(panel) + # takes the initial focus, so clicking a spin box is a real focus change + layout.addWidget(QLineEdit()) + bunch = QSpinBox() + bunch.setRange(30, 200) + bunch.setValue(100) + dspin = QDoubleSpinBox() + dspin.setRange(0, 1e6) + dspin.setDecimals(0) + dspin.setValue(1000) + layout.addWidget(bunch) + layout.addWidget(dspin) + return panel, bunch, dspin + + +def _show(qapp, panel): + panel.show() + panel.activateWindow() + qapp.processEvents() + + +def _click_and_type(qapp, spin, text): + le = spin.lineEdit() + QTest.mouseClick(le, Qt.MouseButton.LeftButton, pos=QPoint(le.width() - 3, le.height() // 2)) + qapp.processEvents() + QTest.keyClicks(spin, text) + qapp.processEvents() + return spin.text() + + +def test_click_and_type_is_rejected_without_filter(qapp): + # documents the original problem: the appended digit exceeds the maximum + panel, bunch, _ = _panel() + _show(qapp, panel) + assert _click_and_type(qapp, bunch, "5") == "100" + + +def test_click_and_type_replaces_value(qapp): + panel, bunch, dspin = _panel() + _select_all_on_focus(panel) + _show(qapp, panel) + assert _click_and_type(qapp, bunch, "150") == "150" + assert _click_and_type(qapp, dspin, "42") == "42" diff --git a/tests/tests_devices/test_keithley_2400.py b/tests/tests_devices/test_keithley_2400.py new file mode 100644 index 00000000..51bff74b --- /dev/null +++ b/tests/tests_devices/test_keithley_2400.py @@ -0,0 +1,191 @@ +from unittest import mock + +import pytest +from ophyd.utils import LimitError +from ophyd_devices.utils.controller import ControllerCommunicationError + +from csaxs_bec.devices.keithley.keithley_2400 import ( + Keithley2400, + Keithley2400Controller, + Keithley2400Error, +) + + +class FakeKeithleySocket: + """Minimal 2400: replies CR LF terminated and split in two segments, as seen through the bridge.""" + + instances = [] + output = False + level = 0.0 + source_function = "VOLT" + compliance = 21.0 + max_volts = 100.0 # load limit, above it the instrument sits in compliance + resistance = 1000.0 + + def __init__(self, host, port, socket_timeout=2): + self.is_open = False + self.commands = [] + self.replies = [] + type(self).instances.append(self) + + def open(self, timeout=10): + self.is_open = True + + def close(self): + self.is_open = False + + def receive(self, buffer_length=1024): + return self.replies.pop(0) if self.replies else b"" + + def _measured(self): + volts = min(self.level * self.resistance, self.compliance, self.max_volts) + return volts, volts / self.resistance + + def put(self, msg): + command = msg.decode().strip() + self.commands.append(command) + reply = { + "*IDN?": "KEITHLEY INSTRUMENTS INC.,MODEL 2400,4009094,C32", + ":OUTP?": "1" if self.output else "0", + ":SOUR:FUNC?": self.source_function, + ":SOUR:CURR?": f"{self.level:E}", + ":SENS:VOLT:PROT?": f"{self.compliance:E}", + ":SYST:ERR?": '0,"No error"', + }.get(command) + if command == ":READ?": + assert self.output, "the 2400 refuses :READ? with the output off" + reply = "{:E},{:E}".format(*self._measured()) + elif command == ":SENS:VOLT:PROT:TRIP?": + reply = "1" if self._measured()[0] < self.level * self.resistance else "0" + elif command in (":OUTP ON", ":OUTP OFF"): + type(self).output = command == ":OUTP ON" + elif command == ":SOUR:FUNC CURR": + type(self).source_function = "CURR" + elif command.startswith(":SOUR:CURR ") and "RANG" not in command: + type(self).level = float(command.split()[1]) + elif command.startswith(":SENS:VOLT:PROT "): + type(self).compliance = float(command.split()[1]) + if reply is not None: + data = f"{reply}\r\n".encode() + self.replies += [part for part in (data[:3], data[3:]) if part] + + +@pytest.fixture +def socket_cls(): + """A fresh socket class per test, the instrument state lives on the class.""" + Keithley2400Controller._reset_controller() + return type("Socket", (FakeKeithleySocket,), {"instances": []}) + + +@pytest.fixture +def keithley(socket_cls): + dev = Keithley2400( + name="keithley", socket_cls=socket_cls, host="fake", port=1, device_manager=mock.MagicMock() + ) + yield dev + dev.controller.off(update_config=False) + + +def commands(socket_cls): + return socket_cls.instances[-1].commands + + +def test_connect_with_output_off_configures_and_keeps_it_off(keithley, socket_cls): + keithley.wait_for_connection() + assert socket_cls.source_function == "CURR" + assert socket_cls.compliance == 5.0 + assert socket_cls.output is False + assert not any("OUTP ON" in cmd or "MEAS" in cmd or cmd == "*RST" for cmd in commands(socket_cls)) + + +def test_connect_with_output_on_leaves_the_source_alone(keithley, socket_cls): + socket_cls.output, socket_cls.level, socket_cls.source_function = True, 2e-3, "CURR" + keithley.wait_for_connection() + assert (socket_cls.output, socket_cls.level, socket_cls.compliance) == (True, 2e-3, 21.0) + sent = commands(socket_cls) + assert not any(cmd.startswith((":SOUR:FUNC ", ":SOUR:CURR", ":OUTP ", "*RST")) for cmd in sent) + assert keithley.readback.get() == pytest.approx(2.0) + assert keithley.user_setpoint.get() == pytest.approx(2.0) + + +def test_connect_refuses_voltage_source_with_output_on(keithley, socket_cls): + socket_cls.output = True + with pytest.raises(Keithley2400Error, match="source function VOLT"): + keithley.wait_for_connection() + assert socket_cls.output is True + + +def test_connect_refuses_other_instrument(keithley, socket_cls, monkeypatch): + monkeypatch.setattr(keithley.controller, "socket_put_and_receive", lambda val: "SOMETHING ELSE") + keithley.controller.on() + with pytest.raises(Keithley2400Error, match="identification"): + keithley.controller.configure(5.0, 10.0, False) + + +def test_readbacks_with_output_off_do_not_trigger(keithley, socket_cls): + keithley.wait_for_connection() + assert keithley.readback.get() == 0.0 + assert keithley.voltage.get() == 0.0 + assert ":READ?" not in commands(socket_cls) + + +def test_move_programs_the_level_before_the_output(keithley, socket_cls): + keithley.wait_for_connection() + keithley.move(1.5).wait(timeout=5) + sent = commands(socket_cls) + assert sent.index(":SOUR:CURR 1.500000E-03") < sent.index(":OUTP ON") + assert keithley.readback.get() == pytest.approx(1.5) + assert keithley.voltage.get() == pytest.approx(1.5) + assert keithley.output_enabled.get() == 1 + + +def test_move_outside_limits_changes_nothing(keithley, socket_cls): + keithley.wait_for_connection() + with pytest.raises(LimitError): + keithley.move(3.1) + with pytest.raises(LimitError): + keithley.user_setpoint.put(3.1) + assert socket_cls.level == 0.0 + assert socket_cls.output is False + + +def test_move_in_compliance_fails_with_the_reason(keithley, socket_cls, monkeypatch): + monkeypatch.setattr(keithley, "SETTLE_TIMEOUT", 0.1) + keithley.wait_for_connection() + with pytest.raises(Keithley2400Error, match="compliance"): + socket_cls.max_volts = 1.0 + keithley.move(2.0).wait(timeout=5) + + +def test_output_off_keeps_the_level(keithley, socket_cls): + keithley.wait_for_connection() + keithley.move(1.0).wait(timeout=5) + keithley.output_off() + assert socket_cls.output is False + assert socket_cls.level == pytest.approx(1e-3) + assert keithley.readback.get() == 0.0 + + +def test_status_prints_the_state(keithley, socket_cls, capsys): + keithley.wait_for_connection() + keithley.status() + assert "Compliance voltage" in capsys.readouterr().out + + +def test_stop_and_destroy_leave_the_output_on(socket_cls): + dev = Keithley2400( + name="keithley", socket_cls=socket_cls, host="fake", port=1, device_manager=mock.MagicMock() + ) + dev.wait_for_connection() + dev.move(1.0).wait(timeout=5) + dev.stop() + dev.destroy() + assert socket_cls.output is True + assert socket_cls.level == pytest.approx(1e-3) + + +def test_closed_connection_raises(keithley, socket_cls): + keithley.controller.on() + socket_cls.instances[-1].put = lambda msg: None + with pytest.raises(ControllerCommunicationError): + keithley.controller.socket_put_and_receive(":OUTP?") diff --git a/tests/tests_devices/test_pseudo_devices.py b/tests/tests_devices/test_pseudo_devices.py index 336e67cf..373ab3e7 100644 --- a/tests/tests_devices/test_pseudo_devices.py +++ b/tests/tests_devices/test_pseudo_devices.py @@ -141,8 +141,9 @@ def patched_dm_bpm(dm_with_devices): return dm -@pytest.fixture -def bpm(patched_dm_bpm): +@pytest.fixture(params=[False]) +def bpm(request, patched_dm_bpm): + invert = request.param name = "bpm" bpm_config = Device( name=name, @@ -165,6 +166,7 @@ def bpm(patched_dm_bpm): right_top_ref="right_top", right_bot_ref="right_bot", left_bot_ref="left_bot", + invert=invert, device_manager=patched_dm_bpm, ) patched_dm_bpm.devices._add_device(bpm.name, bpm) @@ -239,3 +241,22 @@ def test_bpm_positions(bpm): assert bpm.pos_x.get() == 0 assert bpm.pos_y.get() == 0 assert bpm.diagonal.get() == 1 + + +@pytest.mark.parametrize("bpm", [True], indirect=True) +def test_bpm_invert_negates_blades_keeps_positions(bpm): + """invert=True: negative amplifier currents read as positive blade + signals and intensity; the ratio-based positions are unchanged.""" + devices = bpm.device_manager.devices + devices["left_top"].put(-1) + devices["right_top"].put(-3) + devices["right_bot"].put(-2) + devices["left_bot"].put(-2) + + assert bpm.left_top.get() == 1 + assert bpm.right_top.get() == 3 + assert bpm.intensity.get() == 8 + # same ratios as the non-inverted signals would give + assert bpm.pos_x.get() == 0.25 + assert bpm.pos_y.get() == 0 + assert bpm.diagonal.get() == 0.25 diff --git a/tests/tests_devices/test_rt_flomni.py b/tests/tests_devices/test_rt_flomni.py index b3ebe5e5..25b7b678 100644 --- a/tests/tests_devices/test_rt_flomni.py +++ b/tests/tests_devices/test_rt_flomni.py @@ -101,6 +101,28 @@ def test_feedback_enable_without_reset_raises(rt_flomni): assert mock.call(b"l3\n") in rt_flomni.sock.put.mock_calls +@pytest.mark.parametrize( + "first,second,expected", [(10, 10, "toolow"), (10, 200, "ok")] +) +def test_laser_tracker_signalstrength_rereads_after_too_low(rt_flomni, first, second, expected): + """A too-low signal gets a second chance after 1 s: the signal must be + re-read, not the stale first value compared again.""" + rt_flomni.device_manager.devices.rtx.user_parameter.get.side_effect = { + "min_signal": 50, + "low_signal": 100, + }.get + with ( + mock.patch.object(rt_flomni, "laser_tracker_check_enabled", return_value=True), + mock.patch.object(rt_flomni, "laser_tracker_wait_on_target"), + mock.patch.object( + rt_flomni, "read_ssi_interferometer", side_effect=[first, second] + ) as read_ssi, + mock.patch("csaxs_bec.devices.omny.rt.rt_flomni_ophyd.time.sleep"), + ): + assert rt_flomni.laser_tracker_check_signalstrength(verbose=False) == expected + assert read_ssi.call_count == 2 + + def test_flyer_reports_average_stdev_to_the_client(rt_flomni): flyer = RtFlomniFlyer(name="rt_positions", socket_cls=SocketMock, device_manager=rt_flomni.device_manager) flyer.controller = rt_flomni