feat(lamni,flomni): optics-Z shift for queued loptz/foptz moves
CI for csaxs_bec / test (push) Successful in 4m21s

- New loptz_shift_x/y (lamni, mm) and foptz_shift_x/y (flomni, um):
  non-rotating shifts added next to manual_shift_x/y, but not snapshotted
  by tomo queue jobs, so a queued optics-Z move's shift survives the
  following scan jobs. Zeroed at x-ray eye alignment (flomni also on
  sample change); the optics-Z position is stored as
  <optz>_shift_reference for a future calibration.
- lamni: new tomo-queue action move_loptz_with_shift (limits checked,
  refused on OSA collision at current losaz); flomni's
  move_foptz_with_shift now sets foptz_shift_x/y.
- lamni RT controller no longer locks loptz read-only with feedback on.
- Tomo-queue GUI: live OSA-collision warning for the loptz target.
- Command-job summaries (tomo_queue_show, GUI table/tooltip/builder)
  now show units via format_command_step(); shift prints carry units.
- Docs + omny TODO note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ
This commit is contained in:
x12saandClaude Opus 5.5 committed 2026-10-19 16:19:53 +02:00
1 parent fa778180a0
commit 411a9ff1aa
13 files changed
+511 -66

No files matched your search

@@ -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 <mm> = {self.tomo_fovy_offset:.4f}")
print(f" _manual_shift_x <mm> = {self.manual_shift_x:.4f}")
print(f" _manual_shift_y <mm> = {self.manual_shift_y:.4f}")
print(f" _loptz_shift_x <mm> = {self.loptz_shift_x:.4f} (queue loptz moves)")
print(f" _loptz_shift_y <mm> = {self.loptz_shift_y:.4f}")
if self.at_each_angle_hook:
print(f"At-each-angle hook = {self._describe_active_hook()}")
print("")
@@ -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(
@@ -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)
@@ -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}]")
@@ -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
@@ -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
@@ -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 <um> = {self.manual_shift_x}")
print(f" _manual_shift_y <um> = {self.manual_shift_y}")
print(f" _foptz_shift_x <um> = {self.foptz_shift_x} (queue foptz moves)")
print(f" _foptz_shift_y <um> = {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.
@@ -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()
@@ -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:
- `<optz>_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()`.
- `<optz>_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_<optz>_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_<optz>_with_shift`: moves optics Z and
sets the new `<optz>_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).
@@ -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", {})
@@ -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(
+8 -6
View File
@@ -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
@@ -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"