diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index bca42043..3338699e 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -35,7 +35,11 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.idle_time import account_p 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 @@ -193,12 +197,14 @@ 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". _TOMO_QUEUE_ACTIONS = { "move": { "func": "_queue_action_move", @@ -206,8 +212,97 @@ 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", + }, + }, + }, } + 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). @@ -589,6 +684,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. @@ -907,6 +1003,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") @@ -1422,7 +1571,8 @@ class LamNI( 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}, y={self.manual_shift_y}") + 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 = [] @@ -1447,12 +1597,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] @@ -2474,6 +2626,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("") 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..da734d87 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) 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 fc9ed181..6fc8e641 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 @@ -163,6 +163,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. @@ -804,11 +837,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}]") 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..593a7c66 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 @@ -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 5e34fba2..5b21b70c 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -1013,6 +1013,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: @@ -2067,6 +2068,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 @@ -2717,6 +2745,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 @@ -3655,14 +3684,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 @@ -3779,15 +3810,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 @@ -3917,6 +3950,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: @@ -4245,7 +4280,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"}, @@ -4276,7 +4311,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." ), @@ -4296,33 +4331,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 @@ -4330,9 +4367,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 @@ -4347,8 +4384,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 d8263501..606394dd 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_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 fe38600e..9d0d2ac9 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -53,6 +53,9 @@ from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints from bec_widgets import BECWidget, SafeSlot 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, @@ -1777,6 +1780,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") @@ -1790,7 +1794,7 @@ class TomoQueueDialog(QDialog): "", "", "", - _format_command_summary(job), + _format_command_summary(job, action_registry), ] else: params = job.get("params", {}) @@ -1804,7 +1808,7 @@ class TomoQueueDialog(QDialog): _fmt_num(params.get("tomo_shellstep")), _format_hook_detail(params, registered_hooks), ] - tooltip = _job_tooltip(job) + tooltip = _job_tooltip(job, action_registry) for col, text in enumerate(cells): item = QTableWidgetItem(text) # Label/Details are now wide, free-text columns -- left-align @@ -2258,8 +2262,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. @@ -2346,6 +2349,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 @@ -2401,6 +2406,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": @@ -2467,10 +2532,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: @@ -2899,14 +2967,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}]" @@ -2924,7 +2990,7 @@ def _format_hook_detail(params: dict, registered_hooks: list) -> str: return f"hook: {hook_name} (not registered)" -def _job_tooltip(job: dict) -> str: +def _job_tooltip(job: dict, registry: dict | None = 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.""" @@ -2933,9 +2999,7 @@ def _job_tooltip(job: dict) -> 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/devices/omny/rt/rt_lamni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py index 22ea1cf4..90cdbdb2 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/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index 682581a9..bee8275f 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -141,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. @@ -159,10 +159,11 @@ 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()`. @@ -499,10 +500,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/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py index fdc43c3e..71321b33 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py @@ -193,6 +193,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 +422,19 @@ 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"