From 7066243c578a72a2243e14ccd22c4d694f311137 Mon Sep 17 00:00:00 2001
From: Mirko Holler
Date: Wed, 26 Aug 2026 19:51:18 +0200
Subject: [PATCH 001/102] fix(omny): unbreak --session omny startup and the
sim/harness gaps blocking its live smoke test
- post_startup.py: --session omny imported OMNY from the flomni plugin
package instead of omny (ImportError on every start)
- omny/x_ray_eye_align.py: XrayEyeAlign.__init__ referenced self.lamni.align
on the OMNY instance mid-construction, before OMNY.__init__ finishes
assigning it (AttributeError on every OMNY() construction)
- omny/gui_tools.py: OMNYGuiTools.__init__ crashed with KeyError in any
session without an open BEC-widgets "main" window (e.g. --nogui); guarded
to fall back to None like flomni's GuiTools
- omny_e2e_tests/sim_flomni_harness.py: FakeDeviceManager had no .connector,
breaking rt_lamni_ophyd.py's feedback_enable_with_reset() client-info call
- omny_e2e_tests/sim_lamni_omny_harness.py: fixed a stale import of the
renamed LamNIFermatScan module
- AI_docs/SIMULATED_ENDSTATIONS.md: recorded the OMNY live-test results
- omny/AI_docs/OPEN_ISSUES.md: new, tracks what the live smoke test found,
including the unfixed osamroy rotation-offset bug that still blocks scans
Co-Authored-By: Claude Sonnet 5
---
.../flomni/AI_docs/SIMULATED_ENDSTATIONS.md | 67 +++++++++-
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 114 ++++++++++++++++++
.../plugins/omny/gui_tools.py | 9 +-
.../plugins/omny/x_ray_eye_align.py | 10 +-
.../startup/post_startup.py | 2 +-
omny_e2e_tests/sim_flomni_harness.py | 8 ++
omny_e2e_tests/sim_lamni_omny_harness.py | 6 +-
7 files changed, 205 insertions(+), 11 deletions(-)
create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
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 9d0ae74c..c3406b6f 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
@@ -138,10 +138,69 @@ Passed: sample storage + GUI, moves incl. live progress bars and Ctrl-C, rotatio
angle coupling, feedback enable/disable/status, tracker, fermat scans incl. progress and
abort, X-ray eye GUI with camera frames, storage bookkeeping. Pending: full transfer
chain end-to-end, tomo_scan (unblocked by the scilog fix), tomo queue executor
-(crash-resume checklist), then queue-reordering implementation. LamNI/OMNY: harness-
-validated; a short live smoke test (config load, a move, one scan, one OMNY transfer)
-recommended before relying on them, since flOMNI testing surfaced integration issues the
-harness cannot see.
+(crash-resume checklist), then queue-reordering implementation. LamNI: harness-validated;
+live smoke test still pending.
+
+**OMNY (2026-08-26): live smoke test run, config load / move / transfer passed, scan
+blocked by a real (pre-existing, non-sim) bug.** Fixes made along the way (separate from
+this doc's sim-package scope, see the repo's git history for exact diffs):
+- `bec_ipython_client/startup/post_startup.py`: `--session omny` imported `OMNY` from the
+ `flomni` plugin package (which doesn't export it) instead of `omny` — `ImportError` on
+ every OMNY session start.
+- `omny/x_ray_eye_align.py`: `XrayEyeAlign.__init__` called `self.lamni.align.reset_*()`
+ on the OMNY instance mid-construction (`OMNY.__init__` does
+ `self.align = XrayEyeAlign(self.client, self)`, so `.align` doesn't exist yet) —
+ `AttributeError` on every `OMNY()` construction. This file is otherwise still an
+ unadapted copy of LamNI's `x_ray_eye_align.py` (docstrings, types, and `self.lamni`
+ throughout) — the same kind of OMNY-specific adaptation `LamNI`'s copy already got is
+ still open as future work.
+- `omny/gui_tools.py`: `OMNYGuiTools.__init__` did `self.gui.windows["main"].widget`
+ unconditionally — `KeyError` in any session without an open BEC-widgets "main" window
+ (e.g. `--nogui`). Guarded to fall back to `None`, matching flomni's `GuiTools` pattern
+ (`self.flomni_window`, populated lazily by `flomnigui_show_gui()`).
+- Two *environment* fixes, not code bugs, but required before any of the above could even
+ be exercised: `csaxs_bec` and `bec_widgets` were both pip-installed editable pointing at
+ stale pre-repo-reorganization paths (`~/bec/csaxs_bec`, `~/bec/bec_widgets`, neither
+ exists) instead of their current location under `~/bec/bec/...` — `bec-server` couldn't
+ import its own plugins, which is what "bec-server doesn't start" actually was.
+- `omny_e2e_tests/sim_flomni_harness.py`: `FakeDeviceManager` had no `.connector` —
+ `rt_lamni_ophyd.py`'s `feedback_enable_with_reset()` gained a
+ `device_manager.connector.send_client_info(...)` call (commit `f736900`) the harness
+ mock never picked up. Added a no-op `FakeConnector`.
+- `omny_e2e_tests/sim_lamni_omny_harness.py`: imported the pre-rename module path
+ `csaxs_bec.scans.LamNIFermatScan` (now `lamni_components.LamNIComponents`, a
+ `@staticmethod`) — `ModuleNotFoundError`. Both harnesses pass cleanly again (42 flOMNI +
+ 32 LamNI/OMNY checks) after these two fixes.
+
+Checklist result against a live local sim deployment (`simulated_omny.yaml`,
+`bec --session omny`):
+- **Config load**: pass — `bec.config.update_session_with_file(...)`, `OMNY(bec)`
+ constructs cleanly, all `simulated_omny.yaml` devices visible on `dev.*`.
+- **A move**: pass — `umv(dev.osamx, ...)`/`umv(dev.osamy, ...)`, live progress bar,
+ correct readback.
+- **One OMNY transfer**: pass — `omny.otransfer_park_slot(6)` drove `oparkz` to the
+ expected `-0.9` with progress reporting and no errors.
+- **One scan**: **blocked**, real bug found (not fixed — needs Mirko's confirmation of
+ intended hardware semantics before touching it). `osamroy` (the rotation axis, axis `C`
+ on the port-8083 controller) has a permanent, documented `+25`° display offset applied
+ only on read (`OMNYGalilReadbackSignal._socket_get`, `ogalil_ophyd.py:56-60` — "we
+ introduce an offset of 25 to the rotation axis... that way we just do tomography from 0
+ to 180 degrees"). Nothing subtracts that 25 back out on the write side
+ (`OMNYGalilMotor.move()`, same file) or in its own success check, so
+ `readback == raw + 25` is compared against the raw target passed to `.move()` — every
+ `.move()`/`umv()` call on `osamroy` fails its success check by exactly 25, unconditionally
+ (confirmed: readback stays fixed at the boot value 25.0 regardless of target; `osamx`/
+ `osamy` on the same controller move correctly, since only this one axis has the offset).
+ This isn't sim-specific — `ogalil_ophyd.py` is real, unmodified device code, so the sim
+ is faithfully reproducing a genuine bug. It unconditionally blocks
+ `feedback_enable_with_reset()`'s "rotate to zero before enabling feedback" precondition
+ (`rt_omny_ophyd.py:526-533`), which blocks every scan needing interferometer feedback —
+ i.e. effectively all of them. A correct fix needs to decide, with domain input: should
+ `OMNYGalilMotor.move()` convert the caller's display-space target to raw before sending
+ it over the wire for this axis (so `.move(0)` means "go to displayed zero"), or should
+ callers already pass raw targets (in which case `feedback_enable_with_reset()`'s
+ `osamroy.obj.move(0, ...)` and its `np.fabs(readback) > 0.1` guard are the ones passing
+ the wrong space)? Either reading is plausible from the code alone.
## Operational notes
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
new file mode 100644
index 00000000..ee91ca86
--- /dev/null
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -0,0 +1,114 @@
+# OMNY open issues (as of 2026-08-26)
+
+Tracks what the first OMNY live-smoke-test session found: bugs (fixed and unfixed) and
+what's still untested. Written so a future session can pick this up without re-deriving
+it. Companion docs: `../../flomni/AI_docs/SIMULATED_ENDSTATIONS.md` (the sim architecture
+and the fullest write-up of the rotation-offset bug below) and
+`../../flomni/AI_docs/FLOMNI_TO_LAMNI_COMPARISON.md` (the flomni→lamni migration this
+OMNY work is meant to mirror).
+
+## Standing question: OMNY should follow flOMNI, not LamNI
+
+Mirko's reaction on seeing the first findings: OMNY should be architecturally closer to
+**flOMNI**, not LamNI — the two systems are more alike (OMNY is flOMNI's predecessor/
+sibling; LamNI is the more different, laminography-specific setup with e.g. no 180° mode,
+different tomo-type scheduling, `tomo_circfov` instead of `fovx`/`fovy`, etc., per
+`FLOMNI_TO_LAMNI_COMPARISON.md`). This makes it suspicious whenever an OMNY file reads
+like LamNI, and is a general lens to apply going forward: if fixing an OMNY bug means
+choosing what the "correct" shape looks like, default to whatever flOMNI already does,
+not whatever LamNI does — a LamNI-shaped OMNY file is more likely a leftover copy-paste
+than a deliberate design choice. See the `x_ray_eye_align.py` finding below for a concrete
+example already found this way.
+
+## Bug found, NOT fixed — needs Mirko's call before touching it
+
+**`osamroy` (rotation axis) permanently fails every `.move()`/`umv()` call.** Full
+technical write-up: `SIMULATED_ENDSTATIONS.md`'s OMNY live-test section. Short version:
+`OMNYGalilReadbackSignal._socket_get()` (`devices/omny/galil/ogalil_ophyd.py:56-60`) adds
+a documented `+25`° offset on *read* only ("we introduce an offset of 25 to the rotation
+axis... that way we just do tomography from 0 to 180 degrees"). Nothing subtracts it back
+out on the *write* side (`OMNYGalilMotor.move()`, same file) or in that method's own
+success check, so `readback (= raw + 25)` is compared against the raw target passed to
+`.move()` — always off by exactly 25, for any target. This is real, unmodified hardware-
+facing code (not a sim artifact — the sim just relays it faithfully), and it
+unconditionally blocks `feedback_enable_with_reset()` (`rt_omny_ophyd.py:526-533`), which
+blocks every scan needing interferometer feedback, i.e. effectively all of them.
+
+Two plausible fixes, and only Mirko can say which matches real hardware intent:
+1. `OMNYGalilMotor.move()` should convert the caller's display-space target to raw
+ (subtract 25) before sending it over the wire, for this axis — i.e. `.move(0)` means
+ "go to displayed zero."
+2. Callers are expected to already pass raw targets, in which case
+ `feedback_enable_with_reset()`'s `osamroy.obj.move(0, wait=True)` and its
+ `np.fabs(readback) > 0.1` guard (which reads *display*-space) are the ones using the
+ wrong space, and should target/compare against `-25` instead of `0`.
+
+Do not guess-fix this one — a wrong sign/direction on a real rotation-stage offset is the
+kind of thing that should get hardware-review, not silent inference from code reading.
+
+## `x_ray_eye_align.py` — worse than the crash that got fixed
+
+The construction-time crash (`self.lamni.align.reset_correction()` called on an object
+still mid-construction) is fixed (matches the fix LamNI's own copy of this file already
+got — see its `__init__` for the rationale comment). But that only unblocks
+*construction*. The rest of `omny/x_ray_eye_align.py` is still an **unadapted copy of
+`LamNI/x_ray_eye_align.py`**, not code actually written for OMNY:
+- Docstring: `"""BEC-GUI-based X-ray eye alignment for LamNI. ... The key difference from
+ the FlOMNI alignment is that LamNI needs *both* x and y fits..."""` — describes LamNI
+ vs flOMNI, doesn't mention OMNY at all.
+- Type hint: `def __init__(self, client, lamni: LamNI) -> None`, attribute `self.lamni`
+ used throughout (`self.lamni.align...`, `self.lamni.read_xray_eye_correction_from_gui()`
+ etc.) — the parameter is actually the `OMNY` instance, not `LamNI`.
+- `LAMNI_ALIGNMENT_ANGLES = [k * 45 for k in range(8)]` (8 angles, full 360°, laminography-
+ specific) — presumably wrong for OMNY, which (per the flOMNI/LamNI comparison doc) is
+ the kind of full-rotation tomography flOMNI's alignment already handles differently.
+
+None of this was exercised in the live smoke test beyond the constructor no longer
+crashing — `start_x_ray_eye_alignment()` / `xrayeye_update_frame()` were never called.
+Given the "OMNY ≈ flOMNI, not LamNI" principle above, the right fix is almost certainly to
+port OMNY's version from `flomni/x_ray_eye_align.py` (440 lines, the flOMNI-specific
+implementation) the way `LamNI/x_ray_eye_align.py` was itself adapted from flOMNI's,
+rather than patching the current LamNI-derived file in place.
+
+## Checklist items not yet run (smoke test only covered 3 of 4)
+
+From the live session against `simulated_omny.yaml` / `bec --session omny`:
+- **Passed**: config load, a translation-axis move (`osamx`, `osamy`), one lightweight
+ transfer (`omny.otransfer_park_slot(6)`).
+- **Blocked**: any scan (see rotation-offset bug above) — includes `omny.tomo_scan()`
+ itself, never reached.
+
+Still completely untested (not attempted yet, not known-broken — just unexplored):
+- The **full gripper sample-transfer chain**: `otransfer_put_sample()` /
+ `otransfer_get_sample()` (the actual pin pickup/dropoff with the `confirm`/`atconfir`
+ handshake), as opposed to the lighter `otransfer_park_slot()` used for the smoke test.
+- **X-ray eye alignment** end-to-end (blocked in practice by the section above, but worth
+ retesting once that file is properly ported).
+- **Dewar / temperatures** (`omny_dewar`, `omny_temperatures` devices) — they load and
+ construct (`SimOMNYDewar`/`SimOMNYTemperatures`), but no `omny.py`-level method that
+ reads/uses them was called.
+- **Sample storage** (`omny_samples`) beyond it loading with seeded slots — no
+ `omny.py`-level storage-bookkeeping method exercised.
+- `omny_alignment_mixin.py`, `omny_optics_mixin.py`, and the rest of `omny_rt.py` beyond
+ `feedback_enable_with_reset`/`feedback_disable`/`feedback_status` — none of these were
+ called.
+- GUI-dependent flows (`omnygui_show_gui()` etc.) — the smoke test ran `--nogui`; nothing
+ that needs a real BEC-widgets window was exercised.
+
+## Known, already-documented, separate from this session
+
+- `omny_webpage_generator.py` (2957 lines) is dead/unwired code — never instantiated by
+ `omny.py`, per `FLOMNI_TO_LAMNI_COMPARISON.md`'s note ("omny was left untouched...
+ separate future task if omny ever needs a real one"). Not touched this session either.
+- The tomo-queue / `TomoParamsWidget` generalization done for LamNI
+ (`LamNI/AI_docs/TOMO_QUEUE_PORT.md`, `TOMO_PARAMS_GUI_PORT.md`) has no OMNY counterpart
+ yet — out of scope for the sim-validation work, in scope for the eventual macro-
+ standardization pass.
+
+## Environment note (not code, but will bite again if forgotten)
+
+Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
+reorganization paths (`~/bec/csaxs_bec`, `~/bec/bec_widgets`) that no longer exist — real
+location is under `~/bec/bec/...` now. Fixed this session (`pip install -e` from the
+correct path for both). If `bec-server` or `bec_widgets` imports start failing again with
+no obvious code cause, check `pip show ` → `Editable project location` first.
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
index 76d1757f..89ef27f5 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
@@ -19,7 +19,14 @@ class OMNYGuiTools:
def __init__(self, client):
self.gui = getattr(client, "gui", None)
- self.gui_window = self.gui.windows["main"].widget
+ # No "main" BEC-widgets window exists yet in --nogui sessions, or before
+ # a GUI has actually been opened; fall back to None like flomni's
+ # GuiTools does (self.flomni_window, lazily populated by
+ # flomnigui_show_gui()), instead of crashing OMNY's construction.
+ try:
+ self.gui_window = self.gui.windows["main"].widget
+ except (KeyError, AttributeError):
+ self.gui_window = None
self.fig200 = None
self.fig201 = None
self.fig202 = None
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py
index ec15cff4..868d5c6f 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py
@@ -57,9 +57,13 @@ class XrayEyeAlign:
# ...
# k=8 : sample at 315°
self.alignment_values: dict[int, list[float]] = {}
- # Reset correction state via the existing alignment object on lamni
- self.lamni.align.reset_correction()
- self.lamni.align.reset_xray_eye_correction()
+ # Deliberately NOT calling self.lamni.align.reset_correction()/
+ # reset_xray_eye_correction() here: `self.lamni` (the OMNY instance) is
+ # still mid-construction when this runs (OMNY.__init__ does
+ # `self.align = XrayEyeAlign(self.client, self)`), so `self.lamni.align`
+ # doesn't exist yet -- this crashed every OMNY() construction with
+ # AttributeError. Matches LamNI's x_ray_eye_align.py, which was fixed
+ # the same way (see its __init__ for the full rationale).
# ------------------------------------------------------------------
# GUI shortcut
diff --git a/csaxs_bec/bec_ipython_client/startup/post_startup.py b/csaxs_bec/bec_ipython_client/startup/post_startup.py
index d560a94c..32b6b1ea 100644
--- a/csaxs_bec/bec_ipython_client/startup/post_startup.py
+++ b/csaxs_bec/bec_ipython_client/startup/post_startup.py
@@ -65,7 +65,7 @@ if _args.session.lower() == "lamni":
""")
elif _args.session.lower() == "omny":
- from csaxs_bec.bec_ipython_client.plugins.flomni import OMNY
+ from csaxs_bec.bec_ipython_client.plugins.omny import OMNY
_session_name = "OMNY"
omny = OMNY(bec)
diff --git a/omny_e2e_tests/sim_flomni_harness.py b/omny_e2e_tests/sim_flomni_harness.py
index 1b8778e4..47793ab4 100644
--- a/omny_e2e_tests/sim_flomni_harness.py
+++ b/omny_e2e_tests/sim_flomni_harness.py
@@ -46,9 +46,17 @@ class FakeDeviceContainer(dict):
raise AttributeError(name) from exc
+class FakeConnector:
+ """Stand-in for device_manager.connector; client-info notifications are a no-op here."""
+
+ def send_client_info(self, *args, **kwargs):
+ pass
+
+
class FakeDeviceManager:
def __init__(self):
self.devices = FakeDeviceContainer()
+ self.connector = FakeConnector()
def add(self, name, obj, user_parameter=None):
self.devices[name] = FakeConfiguredDevice(obj, user_parameter)
diff --git a/omny_e2e_tests/sim_lamni_omny_harness.py b/omny_e2e_tests/sim_lamni_omny_harness.py
index b388d5a6..915b19bb 100644
--- a/omny_e2e_tests/sim_lamni_omny_harness.py
+++ b/omny_e2e_tests/sim_lamni_omny_harness.py
@@ -178,10 +178,12 @@ def run_lamni(): # noqa: C901
# coarse-stage coupling: with feedback off, the interferometer follows lsamx/lsamy
# through the LamNI geometry (this is what makes the scan-center routine converge)
- from csaxs_bec.scans.LamNIFermatScan import lamni_from_stage_coordinates
+ from csaxs_bec.scans.lamni_components import LamNIComponents
lsamx.move(87.55, wait=True) # +0.05 mm from center
- expected_x, expected_y = (val * 1000 for val in lamni_from_stage_coordinates(0.05, 0.0))
+ expected_x, expected_y = (
+ val * 1000 for val in LamNIComponents.lamni_from_stage_coordinates(0.05, 0.0)
+ )
check(
np.isclose(rtx.readback.get(), expected_x, atol=0.5)
and np.isclose(rty.readback.get(), expected_y, atol=0.5),
From 57903579fc62bd805e3a62edaf14e6390206963f Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 03:00:01 +0200
Subject: [PATCH 002/102] fix(sim): apply osamroy's 25 deg rotation offset in
the simulated Galil controller
Real hardware already applies this correctly: controller1.dmc's #NEWPAR
handler subtracts 25 deg from the target when setting osamroy's position
(contrID=3, axis=2), symmetric with the +25 the ophyd read side
(OMNYGalilReadbackSignal._socket_get) already adds back. The simulated
Galil controller (SimGalilState.start_move_from_newpar) didn't replicate
this, so a simulated osamroy.move(x) landed 25 deg away from where real
hardware would -- a simulator-fidelity bug, not a hardware or ophyd bug.
Also adds osamroy to the offline sim_lamni_omny_harness so this class of
bug is caught there going forward, and a focused unit test for the
round-trip and its scoping to port 8083 / axis 2 only.
---
csaxs_bec/devices/sim/sim_galil.py | 13 ++++
omny_e2e_tests/sim_lamni_omny_harness.py | 22 +++++-
.../test_sim_galil_omny_rotation_offset.py | 77 +++++++++++++++++++
3 files changed, 111 insertions(+), 1 deletion(-)
create mode 100644 tests/tests_devices/test_sim_galil_omny_rotation_offset.py
diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py
index f80e2a6b..c7df6f74 100644
--- a/csaxs_bec/devices/sim/sim_galil.py
+++ b/csaxs_bec/devices/sim/sim_galil.py
@@ -34,6 +34,12 @@ logger = bec_logger.logger
_ASSIGNMENT_RE = re.compile(r"^([A-Za-z]+)(?:\[(\d+)\])?\s*=\s*(.+)$")
_STPPERMM_EXPR_RE = re.compile(r"^([0-9.]+)\s*\*\s*stppermm\[(\d+)\]$")
+# OMNY's rotation-stage controller (contrID=3 in controller1.dmc) listens on port 8083;
+# osamroy is axis_Id "C" -> numeric 2. Kept in sync with OMNYGalilReadbackSignal's own
+# gating condition in ogalil_ophyd.py.
+_OMNY_ROTATION_PORT = 8083
+_OMNY_ROTATION_AXIS = 2
+
class _SimGalilAxis:
"""State of a single simulated Galil axis (positions in microsteps)."""
@@ -235,6 +241,13 @@ class SimGalilState:
if not (self.all_axes_referenced() and ax.referenced):
return
ax.motor_on = True
+ if self.port == _OMNY_ROTATION_PORT and axis_index == _OMNY_ROTATION_AXIS:
+ # Mirrors controller1.dmc's #NEWPAR handler for contrID=3 (the OMNY
+ # rotation-stage controller): "IF((contrID=3)&(axis=2)) ntarget=ntarget-25".
+ # Real hardware applies this on set; the ophyd read side already adds the
+ # +25 back (OMNYGalilReadbackSignal._socket_get). Without it here, the sim
+ # rotation axis lands 25 deg away from where real hardware would.
+ target_mm -= 25
ax.start_move(target_mm * ax.stppermm)
def reference_axis(self, axis_index: int, direction: float = 0.0):
diff --git a/omny_e2e_tests/sim_lamni_omny_harness.py b/omny_e2e_tests/sim_lamni_omny_harness.py
index 915b19bb..1d6f38bd 100644
--- a/omny_e2e_tests/sim_lamni_omny_harness.py
+++ b/omny_e2e_tests/sim_lamni_omny_harness.py
@@ -232,6 +232,18 @@ def run_omny(): # noqa: C901
sim_initial_position=2.5,
),
)
+ osamroy = dm.add(
+ "osamroy",
+ SimOMNYGalilMotor(
+ "C",
+ name="osamroy",
+ host="mpc3217.psi.ch",
+ port=8083,
+ sign=1,
+ device_manager=dm,
+ sim_initial_position=0,
+ ),
+ )
rtx = dm.add(
"rtx",
SimRtOMNYMotor(
@@ -257,7 +269,7 @@ def run_omny(): # noqa: C901
name="rt_positions", host=host, port=3333, device_manager=dm, sim_point_dwell_s=0.005
),
)
- for device in (otransy, otracky, rtx, rty, rtz):
+ for device in (otransy, otracky, osamroy, rtx, rty, rtz):
device.wait_for_connection()
flyer.controller.on()
print("--- OMNY simulated devices connected ---")
@@ -279,6 +291,14 @@ def run_omny(): # noqa: C901
otransy.move(5.0, wait=True)
check(np.isclose(otransy.readback.get(), 5.0, atol=1e-3), "omny: otransy moved to 5.0")
+ # osamroy: 25 deg rotation-stage offset applied on set by the real Galil controller
+ # (contrID=3 in controller1.dmc); the sim must mirror it so a move to a display-space
+ # target reads back the same target, not target+25.
+ osamroy.move(0, wait=True)
+ check(np.isclose(osamroy.readback.get(), 0.0, atol=1e-3), "omny: osamroy moved to 0")
+ osamroy.move(90, wait=True)
+ check(np.isclose(osamroy.readback.get(), 90.0, atol=1e-3), "omny: osamroy moved to 90")
+
# shuttle-style direct axis commands
con.socket_put_confirmed("SPG=15000")
con.socket_put_confirmed("SHG")
diff --git a/tests/tests_devices/test_sim_galil_omny_rotation_offset.py b/tests/tests_devices/test_sim_galil_omny_rotation_offset.py
new file mode 100644
index 00000000..1d6338bf
--- /dev/null
+++ b/tests/tests_devices/test_sim_galil_omny_rotation_offset.py
@@ -0,0 +1,77 @@
+import pytest
+from bec_server.device_server.tests.utils import DMMock
+
+from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilController
+from csaxs_bec.devices.sim.sim_galil import SimGalilState
+from csaxs_bec.devices.sim.sim_omny import SimOMNYGalilMotor
+from csaxs_bec.devices.sim.sim_socket import SimStateRegistry
+
+# osamroy: axis_Id "C" (numeric 2) on port 8083 -- the OMNY rotation-stage controller
+# (contrID=3 in controller1.dmc). This is the only axis/port combination on which the
+# real controller applies the 25 deg offset when setting a position.
+_ROTATION_AXIS_ID = "C"
+_ROTATION_PORT = 8083
+_HOST = "test-omny-galil.psi.ch"
+# fast enough that a ~200 deg simulated move settles well within the poll loop's
+# resolution; OMNYGalilController is a per-(host, port) singleton so it must be reset
+# between tests too, like test_galil_flomni.py does for FlomniGalilController.
+_SIM_VELOCITY = 1000.0
+
+
+@pytest.fixture(autouse=True)
+def _reset_sim_state():
+ OMNYGalilController._reset_controller()
+ SimStateRegistry.reset()
+ yield
+ SimStateRegistry.reset()
+ OMNYGalilController._reset_controller()
+
+
+def _make_axis(axis_Id, port):
+ motor = SimOMNYGalilMotor(
+ axis_Id,
+ name=f"axis_{axis_Id}_{port}",
+ host=_HOST,
+ port=port,
+ sign=1,
+ device_manager=DMMock(),
+ sim_initial_position=0,
+ sim_velocity=_SIM_VELOCITY,
+ )
+ motor.wait_for_connection()
+ return motor
+
+
+@pytest.mark.parametrize("target", [0.0, 90.0, 180.0])
+def test_osamroy_move_round_trips_through_25_deg_offset(target):
+ """osamroy must round-trip through the sim exactly like real hardware does via
+ controller1.dmc's #NEWPAR 'IF((contrID=3)&(axis=2)) ntarget=ntarget-25' handler:
+ a move to a display-space target reads back the same target, not target+25."""
+ osamroy = _make_axis(_ROTATION_AXIS_ID, _ROTATION_PORT)
+ try:
+ osamroy.move(target, wait=True)
+ assert osamroy.readback.get() == pytest.approx(target, abs=1e-3)
+
+ # the raw sim axis must actually be sitting 25 deg away from the logical target,
+ # matching what the real controller drives the motor to
+ state = SimStateRegistry.get(SimGalilState, _HOST, _ROTATION_PORT)
+ raw_axis = state.axis(osamroy.axis_Id_numeric)
+ assert raw_axis.position() / raw_axis.stppermm == pytest.approx(target - 25, abs=1e-3)
+ finally:
+ osamroy.destroy()
+
+
+def test_offset_is_scoped_to_omny_rotation_axis_only():
+ """A different axis on the same controller, or the same axis letter on a different
+ port, must NOT get the -25 offset -- only port 8083 / axis numeric 2 (osamroy)."""
+ other_axis_same_port = _make_axis("A", _ROTATION_PORT) # numeric 0, not the rotation axis
+ same_axis_other_port = _make_axis(_ROTATION_AXIS_ID, 8081) # not the rotation controller
+ try:
+ other_axis_same_port.move(10.0, wait=True)
+ assert other_axis_same_port.readback.get() == pytest.approx(10.0, abs=1e-3)
+
+ same_axis_other_port.move(10.0, wait=True)
+ assert same_axis_other_port.readback.get() == pytest.approx(10.0, abs=1e-3)
+ finally:
+ other_axis_same_port.destroy()
+ same_axis_other_port.destroy()
From 446b20130232f3140b079fea9f7723e434004e8e Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 14:17:38 +0200
Subject: [PATCH 003/102] fix(omny): realistic sim motor temperatures, rotation
heads-up, and corr_pos_x crash
- sim_galil.py: default unseeded analog-input channels to a voltage that
reads back as a realistic ~30 degC motor temperature instead of the
previous 0.0V/150 degC default (real motors run well under 60 degC).
- rt_omny_ophyd.py: feedback_enable_with_reset() now tells the user the
current angle and warns it might take a while when osamroy has more
than 5 deg to rotate back to zero, matching flomni's style of heads-up.
- omny_sample_transfer_mixin.py: otransfer_put_sample() now calls
reset_correction()/reset_tomo_alignment_fit() when mounting into the
sample stage (pin_position == 0), mirroring flomni's
ftransfer_put_sample() -> ftransfer_flomni_stage_in() pattern. Fixes
an AttributeError ('OMNY' object has no attribute 'corr_pos_x') hit
when calling tomo_scan_projection() after a sample transfer.
---
.../plugins/omny/omny_sample_transfer_mixin.py | 8 ++++++++
csaxs_bec/devices/omny/rt/rt_omny_ophyd.py | 13 ++++++++++---
csaxs_bec/devices/sim/sim_galil.py | 10 ++++++++--
3 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 3cb19d27..b0f59611 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -764,6 +764,14 @@ class OMNYSampleTransferMixin:
self._otransfer_controller_disable_mount_mode()
# update the new sample status in storage
self._otransfer_check_free_slot_available_at_position(pin_position, 1)
+
+ if pin_position == 0:
+ # sample is now in the sample stage (scanning position): any alignment
+ # correction data belongs to the previous sample, discard it -- matches
+ # flomni's ftransfer_put_sample()/ftransfer_flomni_stage_in()
+ self.reset_correction()
+ self.reset_tomo_alignment_fit()
+
# this also moves the gripper to parking position
# alternatively a next sample is to be picked
# keep shuttle open and ask user which sample to pick
diff --git a/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py b/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
index f2566f25..70c905b1 100644
--- a/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
+++ b/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
@@ -527,9 +527,16 @@ class RtOMNYController(Controller):
# the following read will also upate the angle in rt during readout
readback = osamroy.obj.readback.get()
if np.fabs(readback) > 0.1:
- self.device_manager.connector.send_client_info(
- f"Rotating to zero", scope="feedback enable", show_asap=True
- )
+ if np.fabs(readback) > 5:
+ self.device_manager.connector.send_client_info(
+ f"Rotating from {readback:.1f} deg to zero. This might take a while.",
+ scope="feedback enable",
+ show_asap=True,
+ )
+ else:
+ self.device_manager.connector.send_client_info(
+ f"Rotating to zero", scope="feedback enable", show_asap=True
+ )
osamroy.obj.move(0, wait=True)
osamx = self.device_manager.devices.osamx
diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py
index c7df6f74..d6a21fc3 100644
--- a/csaxs_bec/devices/sim/sim_galil.py
+++ b/csaxs_bec/devices/sim/sim_galil.py
@@ -40,6 +40,12 @@ _STPPERMM_EXPR_RE = re.compile(r"^([0-9.]+)\s*\*\s*stppermm\[(\d+)\]$")
_OMNY_ROTATION_PORT = 8083
_OMNY_ROTATION_AXIS = 2
+# Default analog input voltage for unseeded channels, used by GalilController.
+# motor_temperature() (@AN[axis+1], converted via (V+10)/20*300 to degC). -8.0V -> 30
+# degC, a realistic idle motor temperature; the previous 0.0V default read back as
+# 150 degC on every axis without an explicit sim_analog_inputs override.
+_DEFAULT_ANALOG_INPUT_V = -8.0
+
class _SimGalilAxis:
"""State of a single simulated Galil axis (positions in microsteps)."""
@@ -144,7 +150,7 @@ class SimGalilState:
}
self.digital_inputs = {14: 1.0} # light curtain reads triggered by default
self.digital_outputs = {9: 0.0, 15: 1.0} # gripper closed, lights on
- self.analog_inputs = {1: -1.6}
+ self.analog_inputs = {}
self._thread_deadlines = {} # thread_id -> unix time until which thread is active
self._transfer_thread = None
self._transfer_abort = threading.Event()
@@ -494,7 +500,7 @@ class SimGalilSocket(SimSocketBase):
return f"{state.digital_outputs.get(channel, 0.0):.4f}"
if arg.startswith("@AN["):
channel = int(arg[4:].split("]")[0])
- return f"{state.analog_inputs.get(channel, 0.0):.4f}"
+ return f"{state.analog_inputs.get(channel, _DEFAULT_ANALOG_INPUT_V):.4f}"
if arg in ("allaxref", "axisref"):
return f"{1.0 if state.all_axes_referenced() else 0.0:.4f}"
if arg.startswith("axisref["):
From e6928f295e7cc3be67c14f77cbbef0eb3a7e9728 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 14:26:10 +0200
Subject: [PATCH 004/102] feat(omny): add 4/8 sub-tomogram tomo modes, hide
advanced modes from regular users
- New shared static helper _equally_spaced_subtomo_angles(), ported from
flomni's _subtomo_angle_plan() with the 180/360-degree-range branch
removed (OMNY only ever scans 180 degrees). Generalizes the bit-reversal
sub-tomogram interleaving to any power-of-two sub-tomogram count; for
n_subtomos=2 it reduces to OMNY's original type-1 scheme.
- sub_tomo_scan() refactored onto the new helper (gains an n_subtomos
param); tomo_type 1 (2 sub-tomograms) is otherwise unchanged.
- Adds tomo_type 4 (4 sub-tomograms) and 5 (8 sub-tomograms), gated by the
same "wear in OMNY" authorization-code prompt already used for tomo_type
2/3.
- Advanced tomo modes (2, 3, 4, 5) are now hidden from tomo_parameters()'s
menu unless bec.active_account is in the new _SUPERUSER_ACCOUNTS
allowlist; a non-superuser landing on an advanced type is forced back to
type 1. _SUPERUSER_ACCOUNTS is left empty -- needs real account codes
before anyone can use the advanced modes.
- As a side effect of reusing flomni's formula, also fixes a latent bug:
the old code derived the inter-sub-tomogram phase offset from the raw
configured tomo_angle_stepsize rather than the achievable (int-truncated)
step, producing an unevenly-spaced combined grid whenever stepsize didn't
divide 180 evenly.
- New tests/tests_bec_ipython_client/test_omny_tomo_angles.py: angle-count
and no-duplicate-angle checks for n_subtomos in (2, 4, 8), a regression
check against the old type-1 formula, an explicit test of the
achievable-step fix, a cross-check against flomni's hardcoded N=8
bit-reversal table, and superuser-gating tests (including the full
tomo_parameters() interactive flow forcing a non-superuser back to
type 1, and a superuser successfully unlocking type 5).
---
.../bec_ipython_client/plugins/omny/omny.py | 165 ++++++++---
.../test_omny_tomo_angles.py | 259 ++++++++++++++++++
2 files changed, 390 insertions(+), 34 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_omny_tomo_angles.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index 2a987d75..a11c7bce 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -29,9 +29,11 @@ if builtins.__dict__.get("bec") is not None:
dev = builtins.__dict__.get("dev")
scans = builtins.__dict__.get("scans")
+
def umv(*args):
return scans.umv(*args, relative=False)
+
class OMNYInitError(Exception):
pass
@@ -40,6 +42,13 @@ class OMNYError(Exception):
pass
+# BEC accounts (bec.active_account, e.g. "e12345") allowed to select advanced tomo
+# modes (golden ratio, 4/8 sub-tomogram) in OMNY.tomo_parameters(). Regular users only
+# ever see "1: 2 equally spaced sub-tomograms". Populate with real beamline-staff
+# account codes -- left empty until populated, so nobody sees the advanced options yet.
+_SUPERUSER_ACCOUNTS: frozenset[str] = frozenset({})
+
+
class OMNYInitStagesMixin:
def enable_all_devices(self):
@@ -744,6 +753,12 @@ class OMNY(
elif val == 3:
# equally spaced tomography with starting angles shifted by golden ratio
self.client.set_global_var("tomo_type", val)
+ elif val == 4:
+ # equally spaced tomography with 4 sub tomograms
+ self.client.set_global_var("tomo_type", val)
+ elif val == 5:
+ # equally spaced tomography with 8 sub tomograms
+ self.client.set_global_var("tomo_type", val)
else:
raise ValueError("Unknown tomo_type.")
@@ -925,41 +940,71 @@ class OMNY(
tags,
)
- def sub_tomo_scan(self, subtomo_number, start_angle=None):
+ @staticmethod
+ def _equally_spaced_subtomo_angles(
+ subtomo_number, n_subtomos, tomo_angle_stepsize, start_angle=None
+ ):
+ """
+ Compute the projection angles for one sub-tomogram of an N-way, bit-reversal
+ interleaved, equally spaced 180 degree tomogram (n_subtomos a power of two:
+ 2, 4, or 8). Ported from flomni's ``_subtomo_angle_plan()``
+ (flomni/flomni.py), with the 180-vs-360-degree-range branch removed since
+ OMNY only ever scans 180 degrees.
+
+ Odd sub-tomograms scan forward (ascending), even sub-tomograms scan in
+ reverse (descending, to minimize travel between sub-tomograms). Each
+ sub-tomogram's phase offset within a step is picked via a bit-reversal
+ permutation of 0..n_subtomos-1, so that successive sub-tomograms maximally
+ subdivide the angular gaps left by the previous ones. For n_subtomos=2 this
+ reduces to phases {0, step/2} -- the original OMNY scheme.
+
+ Always returns exactly ``int(180 / tomo_angle_stepsize)`` angles (no
+ redundant 0/180 duplicate point), matching the convention already used by
+ flomni's and lamni's own sub_tomo_scan().
+
+ Args:
+ subtomo_number (int): 1-indexed sub-tomogram number.
+ n_subtomos (int): Total number of sub-tomograms (2, 4, or 8).
+ tomo_angle_stepsize (float): Angular step size in degrees.
+ start_angle (float, optional): Explicit start angle override. Defaults
+ to the bit-reversal phase offset described above.
+ """
+ n_points = int(180.0 / tomo_angle_stepsize)
+ step = 180.0 / n_points
+
+ bits = n_subtomos.bit_length() - 1
+ subtomo_index = subtomo_number - 1
+ phase_index = int(format(subtomo_index, f"0{bits}b")[::-1], 2) if bits else 0
+ phase = step / n_subtomos * phase_index
+
+ forward = bool(subtomo_number % 2)
+ if start_angle is None:
+ start_angle = phase if forward else (180.0 - step + phase)
+
+ if forward:
+ return start_angle + np.arange(n_points) * step
+ return start_angle - np.arange(n_points) * step
+
+ def sub_tomo_scan(self, subtomo_number, n_subtomos=2, start_angle=None):
"""
Performs a sub tomogram scan.
Args:
subtomo_number (int): The sub tomogram number.
+ n_subtomos (int): Total number of sub-tomograms in this scan (2, 4, or 8).
start_angle (float, optional): The start angle of the scan. Defaults to None.
"""
self._write_subtomo_to_scilog(subtomo_number)
- if start_angle is None:
- if subtomo_number == 1:
- start_angle = 0
- elif subtomo_number == 2:
- start_angle = self.tomo_angle_stepsize / 2.0
-
- # _tomo_shift_angles (potential global variable)
- _tomo_shift_angles = 0
- angle_end = start_angle + 180
- angles = np.linspace(
- start_angle + _tomo_shift_angles,
- angle_end,
- num=int(180 / self.tomo_angle_stepsize) + 1,
- endpoint=True,
+ angles = self._equally_spaced_subtomo_angles(
+ subtomo_number, n_subtomos, self.tomo_angle_stepsize, start_angle
)
- # reverse even sub-tomogram
- if not (subtomo_number % 2):
- angles = np.flip(angles)
- for angle in angles:
+ n_points = len(angles)
+ for idx, angle in enumerate(angles):
self.progress["subtomo"] = subtomo_number
- self.progress["subtomo_projection"] = np.where(angles == angle)[0][0]
- self.progress["subtomo_total_projections"] = 180 / self.tomo_angle_stepsize
- self.progress["projection"] = (subtomo_number - 1) * self.progress[
- "subtomo_total_projections"
- ] + self.progress["subtomo_projection"]
- self.progress["total_projections"] = 180 / self.tomo_angle_stepsize * 2
+ self.progress["subtomo_projection"] = idx
+ self.progress["subtomo_total_projections"] = n_points
+ self.progress["projection"] = (subtomo_number - 1) * n_points + idx
+ self.progress["total_projections"] = n_points * n_subtomos
self.progress["angle"] = angle
self._tomo_scan_at_angle(angle, subtomo_number)
@@ -1009,7 +1054,7 @@ class OMNY(
self._current_special_angles = self.special_angles.copy()
# a new tomo scan was started
if (
- (self.tomo_type == 1 and subtomo_start == 1 and start_angle is None)
+ (self.tomo_type in (1, 4, 5) and subtomo_start == 1 and start_angle is None)
or (self.tomo_type == 2 and projection_number == None)
or (self.tomo_type == 3 and projection_number == None)
):
@@ -1030,11 +1075,12 @@ class OMNY(
self.tomo_id = 0
with scans.dataset_id_on_hold:
- if self.tomo_type == 1:
- # 2 equally spaced sub-tomograms
- self.progress["tomo_type"] = "Equally spaced sub-tomograms"
- for ii in range(subtomo_start, 3):
- self.sub_tomo_scan(ii, start_angle=start_angle)
+ if self.tomo_type in (1, 4, 5):
+ # equally spaced sub-tomograms (2, 4, or 8)
+ n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type]
+ self.progress["tomo_type"] = f"Equally spaced sub-tomograms ({n_subtomos})"
+ for ii in range(subtomo_start, n_subtomos + 1):
+ self.sub_tomo_scan(ii, n_subtomos=n_subtomos, start_angle=start_angle)
start_angle = None
elif self.tomo_type == 2:
@@ -1289,6 +1335,17 @@ class OMNY(
corridor_size=corridor_size,
)
+ def _is_superuser_account(self) -> bool:
+ """Whether the active BEC account is allowed to select advanced tomo modes."""
+ bec = builtins.__dict__.get("bec")
+ try:
+ account = bec.active_account
+ except Exception:
+ return False
+ if isinstance(account, bytes):
+ account = account.decode()
+ return bool(account) and account in _SUPERUSER_ACCOUNTS
+
def tomo_parameters(self):
"""print and update the tomo parameters"""
print("Current settings:")
@@ -1329,6 +1386,13 @@ class OMNY(
print(
"repeating prjections at 0 degrees at the beginning of every second subtomogram"
)
+ if self.tomo_type in (4, 5):
+ n_subtomos = {4: 4, 5: 8}[self.tomo_type]
+ print(
+ f"\x1b[1mTomo type {self.tomo_type}:\x1b[0m {n_subtomos} equally spaced sub-tomograms"
+ )
+ print(f"Total number of projections: {180/self.tomo_angle_stepsize*n_subtomos}")
+ print(f"Angular step within sub-tomogram: {self.tomo_angle_stepsize} degrees")
print(f"\nSample name: {self.sample_name}\n")
if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"):
@@ -1344,12 +1408,23 @@ class OMNY(
"Reconstruction queue ", self.ptycho_reconstruct_foldername, str
)
+ is_superuser = self._is_superuser_account()
print("Tomography type:")
print(" 1: 2 equally spaced sub-tomograms")
- print(" 2: Golden ratio tomography")
- print(" 3: Equally spaced tomography, golden ratio starting angle")
+ if is_superuser:
+ print(" 2: Golden ratio tomography")
+ print(" 3: Equally spaced tomography, golden ratio starting angle")
+ print(" 4: 4 equally spaced sub-tomograms")
+ print(" 5: 8 equally spaced sub-tomograms")
self.tomo_type = self._get_val("Tomography type", self.tomo_type, int)
+ if self.tomo_type != 1 and not is_superuser:
+ print(
+ "Advanced tomo modes are only available to superuser accounts. Tomo type 1"
+ " selected: 2 sub-tomograms selected."
+ )
+ self.tomo_type = 1
+
if self.tomo_type == 1:
tomo_numberofprojections = self._get_val(
"Total number of projections", 180 / self.tomo_angle_stepsize * 2, int
@@ -1417,6 +1492,28 @@ class OMNY(
)
self.tomo_type = 1
+ if self.tomo_type in (4, 5):
+ n_subtomos = {4: 4, 5: 8}[self.tomo_type]
+ code = self._get_val(
+ "This mode causes significant wear in OMNY. Enter authorization code.", 0, str
+ )
+ if code == "x12sa":
+ tomo_numberofprojections = self._get_val(
+ "Total number of projections",
+ 180 / self.tomo_angle_stepsize * n_subtomos,
+ int,
+ )
+ print(f"The angular step will be {180/tomo_numberofprojections}")
+ self.tomo_angle_stepsize = 180 / tomo_numberofprojections * n_subtomos
+ print(
+ f"The angular step in a subtomogram it will be {self.tomo_angle_stepsize}"
+ )
+ else:
+ print(
+ "Wrong authorization code. Tomo type 1 selected: 2 sub-tomograms selected."
+ )
+ self.tomo_type = 1
+
@staticmethod
def _get_val(msg: str, default_value, data_type):
return data_type(input(f"{msg} ({default_value}): ") or default_value)
@@ -1507,7 +1604,7 @@ class OMNY(
def dewar_show_all():
dev.omny_dewar.show_all()
-
+
if __name__ == "__main__":
import builtins
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
new file mode 100644
index 00000000..c1c25255
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
@@ -0,0 +1,259 @@
+"""Tests for OMNY's equally-spaced sub-tomogram angle scheme and the
+account-based superuser gating in tomo_parameters().
+
+OMNY._equally_spaced_subtomo_angles() is a static method ported from flomni's
+_subtomo_angle_plan() (flomni/flomni.py), with the 180-vs-360-degree-range
+branch removed since OMNY only ever scans 180 degrees. It backs tomo_type 1
+(2 sub-tomograms, unchanged/pre-existing), 4 (4 sub-tomograms), and 5 (8
+sub-tomograms, new).
+"""
+
+import builtins
+import types
+
+import numpy as np
+import pytest
+
+from csaxs_bec.bec_ipython_client.plugins.omny import omny as omny_module
+from csaxs_bec.bec_ipython_client.plugins.omny.omny import OMNY
+
+STEPSIZES = [10.0, 7.0, 25.0, 5.0]
+
+
+def _angles(subtomo_number, n_subtomos, stepsize):
+ return np.asarray(OMNY._equally_spaced_subtomo_angles(subtomo_number, n_subtomos, stepsize))
+
+
+def _old_omny_type1_angles(subtomo_number, stepsize):
+ """Reimplementation of OMNY's original (pre-refactor) type-1 angle logic,
+ for regression comparison against _equally_spaced_subtomo_angles(). Mirrors
+ the exact math that used to live inline in sub_tomo_scan(), including the
+ silent `_tomo_scan_at_angle()` filtering of angles outside [0, 180.05).
+ """
+ if subtomo_number == 1:
+ start_angle = 0
+ elif subtomo_number == 2:
+ start_angle = stepsize / 2.0
+ else:
+ raise ValueError("old scheme only supports 2 sub-tomograms")
+ angle_end = start_angle + 180
+ angles = np.linspace(start_angle, angle_end, num=int(180 / stepsize) + 1, endpoint=True)
+ if not (subtomo_number % 2):
+ angles = np.flip(angles)
+ return angles[(angles >= 0) & (angles < 180.05)]
+
+
+@pytest.mark.parametrize("stepsize", STEPSIZES)
+@pytest.mark.parametrize("n_subtomos", [2, 4, 8])
+def test_each_subtomo_generates_exactly_n_angles(stepsize, n_subtomos):
+ n_points = int(180.0 / stepsize)
+ for subtomo_number in range(1, n_subtomos + 1):
+ assert len(_angles(subtomo_number, n_subtomos, stepsize)) == n_points
+
+
+@pytest.mark.parametrize("stepsize", STEPSIZES)
+@pytest.mark.parametrize("n_subtomos", [2, 4, 8])
+def test_combined_subtomos_cover_180_degrees_without_duplicates(stepsize, n_subtomos):
+ n_points = int(180.0 / stepsize)
+ step = 180.0 / n_points
+ all_angles = []
+ for subtomo_number in range(1, n_subtomos + 1):
+ all_angles.extend(_angles(subtomo_number, n_subtomos, stepsize).tolist())
+
+ assert len(all_angles) == n_points * n_subtomos
+ unique = {round(a, 6) for a in all_angles}
+ assert len(unique) == n_points * n_subtomos, "combined sub-tomograms must not repeat an angle"
+
+ # each additional sub-tomogram doubles the resolution of the combined grid
+ combined_step = step / n_subtomos
+ sorted_angles = np.sort(np.asarray(all_angles))
+ diffs = np.diff(sorted_angles)
+ assert np.allclose(
+ diffs, combined_step, atol=1e-6
+ ), f"combined angles are not evenly spaced by {combined_step}: got {diffs}"
+
+
+# stepsizes that divide 180 evenly, where the old code's raw-stepsize-based
+# phase offset happens to coincide with the achievable-step-based one below
+EXACT_STEPSIZES = [10.0, 5.0, 4.0, 20.0]
+
+
+@pytest.mark.parametrize("subtomo_number", [1, 2])
+@pytest.mark.parametrize("stepsize", EXACT_STEPSIZES)
+def test_n_subtomos_2_matches_omnys_original_scheme(subtomo_number, stepsize):
+ old = _old_omny_type1_angles(subtomo_number, stepsize)
+ new = _angles(subtomo_number, 2, stepsize)
+ if subtomo_number == 1:
+ # the old code additionally scanned a redundant point at exactly 180
+ # deg (the same physical projection as 0 deg); the new implementation
+ # intentionally drops it, matching the "exactly N points, no
+ # redundant endpoint" convention already used by flomni's and lamni's
+ # own sub-tomogram scans.
+ np.testing.assert_allclose(new, old[:-1], atol=1e-9)
+ else:
+ np.testing.assert_allclose(new, old, atol=1e-9)
+
+
+@pytest.mark.parametrize("stepsize", [7.0, 25.0])
+def test_phase_offset_uses_achievable_step_not_raw_stepsize(stepsize):
+ """For stepsizes that don't divide 180 evenly, the old code derived
+ subtomo 2's phase offset from the raw configured tomo_angle_stepsize
+ (stepsize/2), producing an unevenly-spaced combined grid when overlaid
+ with subtomo 1. flomni's _subtomo_angle_plan() fixes this by deriving the
+ phase offset from the ACHIEVABLE step (180/int(180/stepsize)) instead --
+ this is the same fix, ported to OMNY. Regression-guards against
+ reintroducing the raw-stepsize version.
+ """
+ n_points = int(180.0 / stepsize)
+ achievable_step = 180.0 / n_points
+ new = _angles(2, 2, stepsize)
+
+ raw_stepsize_start = 180.0 - achievable_step + stepsize / 2.0
+ achievable_step_start = 180.0 - achievable_step + achievable_step / 2.0
+
+ assert not np.isclose(
+ new[0], raw_stepsize_start, atol=1e-9
+ ), "phase offset must not be derived from the raw configured tomo_angle_stepsize"
+ assert np.isclose(new[0], achievable_step_start, atol=1e-9)
+
+
+def test_bit_reversal_matches_flomnis_hardcoded_phase_eighths_table():
+ """flomni.py hardcodes the N=8 bit-reversal permutation as a literal dict
+ (phase_eighths = {1:0, 2:4, 3:2, 4:6, 5:1, 6:5, 7:3, 8:7}). The generalized
+ formula here must reproduce the same first-angle phase for n_subtomos=8."""
+ phase_eighths = {1: 0, 2: 4, 3: 2, 4: 6, 5: 1, 6: 5, 7: 3, 8: 7}
+ stepsize = 10.0
+ n_points = int(180.0 / stepsize)
+ step = 180.0 / n_points
+ for subtomo_number, expected_phase_index in phase_eighths.items():
+ angles = _angles(subtomo_number, 8, stepsize)
+ forward = bool(subtomo_number % 2)
+ expected_phase = step / 8.0 * expected_phase_index
+ expected_start = expected_phase if forward else (180.0 - step + expected_phase)
+ assert np.isclose(angles[0], expected_start, atol=1e-9)
+
+
+# --- superuser gating ---------------------------------------------------------
+
+
+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_omny() -> OMNY:
+ """Bare OMNY instance with only what tomo_type/_is_superuser_account() need,
+ bypassing __init__'s heavy side effects."""
+ obj = object.__new__(OMNY)
+ obj.client = FakeClient()
+ return obj
+
+
+@pytest.fixture
+def fake_bec(monkeypatch):
+ fake = types.SimpleNamespace(active_account="")
+ monkeypatch.setattr(builtins, "bec", fake, raising=False)
+ return fake
+
+
+def test_is_superuser_account_true_for_allowlisted_account(monkeypatch, fake_bec):
+ monkeypatch.setattr(omny_module, "_SUPERUSER_ACCOUNTS", frozenset({"e99999"}))
+ fake_bec.active_account = "e99999"
+ omny = make_omny()
+ assert omny._is_superuser_account() is True
+
+
+def test_is_superuser_account_false_for_other_account(monkeypatch, fake_bec):
+ monkeypatch.setattr(omny_module, "_SUPERUSER_ACCOUNTS", frozenset({"e99999"}))
+ fake_bec.active_account = "e00001"
+ omny = make_omny()
+ assert omny._is_superuser_account() is False
+
+
+def test_is_superuser_account_handles_bytes_account(monkeypatch, fake_bec):
+ monkeypatch.setattr(omny_module, "_SUPERUSER_ACCOUNTS", frozenset({"e99999"}))
+ fake_bec.active_account = b"e99999"
+ omny = make_omny()
+ assert omny._is_superuser_account() is True
+
+
+def test_is_superuser_account_false_when_empty_allowlist(monkeypatch, fake_bec):
+ monkeypatch.setattr(omny_module, "_SUPERUSER_ACCOUNTS", frozenset())
+ fake_bec.active_account = "e99999"
+ omny = make_omny()
+ assert omny._is_superuser_account() is False
+
+
+def test_tomo_parameters_forces_type_1_for_non_superuser(monkeypatch, fake_bec):
+ """A non-superuser account that ends up picking an advanced tomo_type via
+ the interactive prompt must be forced back to tomo_type 1."""
+ monkeypatch.setattr(omny_module, "_SUPERUSER_ACCOUNTS", frozenset({"e99999"}))
+ fake_bec.active_account = "e00001" # not a superuser
+ omny = make_omny()
+ omny.tomo_countingtime = 1.0
+ omny.tomo_shellstep = 0.1
+ omny.fovx = 20
+ omny.fovy = 20
+ omny.stitch_x = 1
+ omny.stitch_y = 1
+ omny.ptycho_reconstruct_foldername = "queue"
+ omny.tomo_angle_stepsize = 10.0
+ omny.tomo_type = 1
+ fake_dev = types.SimpleNamespace(
+ omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ )
+ monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
+ omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
+
+ # canned answers for: ctime, step, fovx, fovy, stitch_x, stitch_y,
+ # reconstruct_foldername, tomo_type (=2, an advanced/hidden mode), then the
+ # type-1 fallback's own "Total number of projections" sub-prompt (since
+ # tomo_type gets forced back to 1 after the superuser check)
+ canned = iter([1.0, 0.1, 20, 20, 1, 1, "queue", 2, 36])
+ monkeypatch.setattr(OMNY, "_get_val", staticmethod(lambda *a, **k: next(canned)))
+
+ omny.tomo_parameters()
+
+ assert omny.tomo_type == 1
+
+
+def test_tomo_parameters_allows_superuser_to_unlock_8_subtomo_mode(monkeypatch, fake_bec):
+ """A superuser account can select and unlock tomo_type 5 (8 sub-tomograms)
+ with the correct authorization code."""
+ monkeypatch.setattr(omny_module, "_SUPERUSER_ACCOUNTS", frozenset({"e99999"}))
+ fake_bec.active_account = "e99999" # superuser
+ omny = make_omny()
+ omny.tomo_countingtime = 1.0
+ omny.tomo_shellstep = 0.1
+ omny.fovx = 20
+ omny.fovy = 20
+ omny.stitch_x = 1
+ omny.stitch_y = 1
+ omny.ptycho_reconstruct_foldername = "queue"
+ omny.tomo_angle_stepsize = 10.0
+ omny.tomo_type = 1
+ fake_dev = types.SimpleNamespace(
+ omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ )
+ monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
+ omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
+
+ # canned answers for: ctime, step, fovx, fovy, stitch_x, stitch_y,
+ # reconstruct_foldername, tomo_type (=5, 8 sub-tomograms), authorization
+ # code, total number of projections
+ canned = iter([1.0, 0.1, 20, 20, 1, 1, "queue", 5, "x12sa", 144])
+ monkeypatch.setattr(OMNY, "_get_val", staticmethod(lambda *a, **k: next(canned)))
+
+ omny.tomo_parameters()
+
+ assert omny.tomo_type == 5
+ # 144 total projections / 8 sub-tomograms -> stepsize 180/(144/8) = 10.0
+ assert omny.tomo_angle_stepsize == pytest.approx(10.0)
From 336cae3cf5309ce0b094658e3389e5f8bf69dbf3 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 14:31:00 +0200
Subject: [PATCH 005/102] feat(omny): grant gac-x01dc superuser access to
advanced tomo modes
For testing tomorrow. Note: bec.active_account is a Redis-backed pgroup
value set via `bec-set-account `, not derived from the OS login --
testing under this account requires running `bec-set-account gac-x01dc`
(or otherwise setting the pgroup to that value) before it takes effect.
---
csaxs_bec/bec_ipython_client/plugins/omny/omny.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index a11c7bce..0ff2437a 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -44,9 +44,11 @@ class OMNYError(Exception):
# BEC accounts (bec.active_account, e.g. "e12345") allowed to select advanced tomo
# modes (golden ratio, 4/8 sub-tomogram) in OMNY.tomo_parameters(). Regular users only
-# ever see "1: 2 equally spaced sub-tomograms". Populate with real beamline-staff
-# account codes -- left empty until populated, so nobody sees the advanced options yet.
-_SUPERUSER_ACCOUNTS: frozenset[str] = frozenset({})
+# ever see "1: 2 equally spaced sub-tomograms". active_account is a Redis-backed
+# pgroup value, set explicitly via `bec-set-account ` -- it is NOT derived
+# from the OS login, so testing as this account requires running
+# `bec-set-account gac-x01dc` first (or wiring pgroup "gac-x01dc" some other way).
+_SUPERUSER_ACCOUNTS: frozenset[str] = frozenset({"gac-x01dc"})
class OMNYInitStagesMixin:
From da0121216bbeef232f5ccc6b289595b31b13f73f Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 19:12:03 +0200
Subject: [PATCH 006/102] feat(omny): fall back to OS login for superuser check
when no pgroup is set
bec.active_account is a Redis-backed pgroup, only set via bec-set-account
-- a fresh dev/test session normally has it unset. _is_superuser_account()
now falls back to os.getenv("USER") in that case, matching the
account/system-user comparison convention already used in
OMNY_shared/omny_general_tools.py's _accounts_match(). Lets gac-x01dc's
superuser grant take effect without requiring bec-set-account first.
---
.../bec_ipython_client/plugins/omny/omny.py | 10 +++++++++-
.../test_omny_tomo_angles.py | 18 ++++++++++++++++++
2 files changed, 27 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index 0ff2437a..b8530a5b 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -1338,7 +1338,13 @@ class OMNY(
)
def _is_superuser_account(self) -> bool:
- """Whether the active BEC account is allowed to select advanced tomo modes."""
+ """Whether the active BEC account is allowed to select advanced tomo modes.
+
+ bec.active_account is a pgroup set explicitly via `bec-set-account`; when no
+ pgroup has been set yet (e.g. a fresh dev/test session), fall back to the OS
+ login (matches the account/system-user comparison convention already used in
+ OMNY_shared/omny_general_tools.py's _accounts_match()).
+ """
bec = builtins.__dict__.get("bec")
try:
account = bec.active_account
@@ -1346,6 +1352,8 @@ class OMNY(
return False
if isinstance(account, bytes):
account = account.decode()
+ if not account:
+ account = os.getenv("USER") or ""
return bool(account) and account in _SUPERUSER_ACCOUNTS
def tomo_parameters(self):
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
index c1c25255..f71e5184 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
@@ -192,6 +192,24 @@ def test_is_superuser_account_false_when_empty_allowlist(monkeypatch, fake_bec):
assert omny._is_superuser_account() is False
+def test_is_superuser_account_falls_back_to_os_user_when_no_pgroup_set(monkeypatch, fake_bec):
+ """bec.active_account is a pgroup, only set explicitly via bec-set-account. A
+ fresh dev/test session with no pgroup set must fall back to the OS login."""
+ monkeypatch.setattr(omny_module, "_SUPERUSER_ACCOUNTS", frozenset({"gac-x01dc"}))
+ fake_bec.active_account = ""
+ monkeypatch.setenv("USER", "gac-x01dc")
+ omny = make_omny()
+ assert omny._is_superuser_account() is True
+
+
+def test_is_superuser_account_no_pgroup_and_unlisted_os_user(monkeypatch, fake_bec):
+ monkeypatch.setattr(omny_module, "_SUPERUSER_ACCOUNTS", frozenset({"gac-x01dc"}))
+ fake_bec.active_account = ""
+ monkeypatch.setenv("USER", "someone-else")
+ omny = make_omny()
+ assert omny._is_superuser_account() is False
+
+
def test_tomo_parameters_forces_type_1_for_non_superuser(monkeypatch, fake_bec):
"""A non-superuser account that ends up picking an advanced tomo_type via
the interactive prompt must be forced back to tomo_type 1."""
From 7aedac148cdf99ecb69280ce2e17d310204e651a Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 19:16:35 +0200
Subject: [PATCH 007/102] feat(omny): seed realistic values in SimOMNYDewar and
SimOMNYTemperatures
- SimOMNYDewar: patched_device()'s all-zero default read as an alarm
state (LN2 flow 0.0 l/s trips OMNYDewar.is_flow_low()'s 3.8 l/s
threshold). Seeded to a healthy/nominal state instead. These values
are illustrative -- no real dewar-controller reference was available.
- SimOMNYTemperatures: seeded names/units/readings/setpoints for the 29
real "TEMP" channels, sourced from the real OMNY_tempcontroller/
tempcontrol.py channel config (name, EGU, ChanHeat, ALARMLIM) and
cross-referenced against the existing _set_TEMP_default_setpoints()
(already in this repo, confirmed to cover the same 29 channels).
Heated channels read at their real setpoint; monitor-only channels get
a plausible reading below their real alarm limit -- no channel now
shows a false alarm. Channels not in use are now named "-" (matching
tempcontrol.py's real default), so show_all() cleanly hides them
instead of listing 19 fake all-zero rows. temperature_update_time and
cryo_temperature_update_time are set to now() to avoid a false
"controller communication is not running" warning.
- The 4 "CRYO" channels are NOT seeded yet -- pending the cryocon
controller reference (still needed from Mirko).
---
csaxs_bec/devices/sim/sim_omny.py | 89 ++++++++++++++++++++++++++++++-
1 file changed, 87 insertions(+), 2 deletions(-)
diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py
index 5aebf832..929310fc 100644
--- a/csaxs_bec/devices/sim/sim_omny.py
+++ b/csaxs_bec/devices/sim/sim_omny.py
@@ -21,6 +21,7 @@ uses the patched-device pattern with in-memory mock PVs.
from __future__ import annotations
import random
+import time
from bec_lib.logger import bec_logger
from ophyd_devices.tests.utils import patched_device
@@ -266,16 +267,100 @@ class SimOMNYSampleStorage(OMNYSampleStorage):
class SimOMNYDewar(OMNYDewar):
- """OMNYDewar with all EPICS PVs replaced by in-memory mock PVs."""
+ """OMNYDewar with all EPICS PVs replaced by in-memory mock PVs, seeded to a
+ healthy/nominal state instead of the all-zero patched_device() default (which
+ reads as an alarm state: OMNYDewar.is_flow_low() trips below 3.8 l/s). These
+ values are illustrative -- no real dewar-controller reference was available
+ when writing this (unlike SimOMNYTemperatures below); adjust if real nominal
+ values are known.
+ """
def __new__(cls, *args, **kwargs):
with patched_device(OMNYDewar, *args, **kwargs) as dewar:
+ dewar.dewar_press_0.put(1200)
+ dewar.dewar_refilling.put(0)
+ dewar.dewar_press_1.put(1500)
+ dewar.dewar_press_2.put(950)
+ dewar.dewar_flow_0.put(4.5) # above the 3.8 l/s is_flow_low() threshold
+ dewar.dewar_valvepos.put(0.35)
+ dewar.dewar_supply_voltage.put(24.0)
+ dewar.dewar_uptime_h.put(12)
+ dewar.dewar_uptime_m.put(30)
+ dewar.dewar_uptime_s.put(0)
+ dewar.dewar_valve_movements_h.put(0)
+ dewar.dewar_valve_movements_m.put(5)
+ dewar.dewar_valve_movements_s.put(0)
return dewar
+# OMNY temperature-controller channels ("TEMP" bank, channels 1-48), sourced from the
+# real OMNY_tempcontroller/tempcontrol.py channel config (name, EGU, ChanHeat, ALARMLIM)
+# cross-referenced against OMNYTemperatures._set_TEMP_default_setpoints()'s existing
+# per-channel setpoints (already in this repo, confirmed to cover exactly the same 29
+# channels -- the setpoint value below is copied from that method, not re-derived).
+# Sentinel setpoints ("no active control": -199.9 degC / 73.2 K, matching
+# tempcontrol.py's configure_device()'s unused-channel convention) mark monitor-only
+# channels (ChanHeat=0); those get a plausible physical reading instead of the sentinel.
+# channel: (name, EGU, alarm limit, simulated current reading, real setpoint)
+_TEMP_CHANNELS = {
+ 8: ("XEye Chamber", "degC", 50, 24.0, -199.9),
+ 9: ("Kuehlsystem RT ZufOSA", "degC", 50, 23.0, 23.0),
+ 10: ("OSA_HaltZul_517", "K", 140, 100.0, 73.2),
+ 16: ("XEye Air", "degC", 50, 24.0, -199.9),
+ 17: ("SampleShield_RT_440", "degC", 50, 23.0, 23.0),
+ 18: ("DeltaA_RT", "degC", 50, 26.0, 26.0),
+ 19: ("DeltaB_RT", "degC", 50, 26.0, 26.0),
+ 20: ("DeltaC_RT", "degC", 50, 26.0, 26.0),
+ 21: ("Haube_ST_RT", "degC", 50, 23.0, 23.0),
+ 22: ("Delta_Basisplatte", "degC", 50, 24.0, -199.9),
+ 23: ("InterfBridge", "degC", 30, 24.0, -199.9),
+ 24: ("XEye Cam", "degC", 50, 24.0, -199.9),
+ 25: ("OSA_HSupp_RT_403", "degC", 50, 25.0, 25.0),
+ 27: ("OSA_Supp_RT_404", "degC", 50, 25.0, 25.0),
+ 28: ("ST_Shield_1", "K", 140, 100.0, 73.2),
+ 29: ("ST_Shield_2", "K", 140, 100.0, 73.2),
+ 30: ("OSA_CoolConn_407", "K", 125, 95.0, 73.2),
+ 31: ("OSA_Holder_406", "K", 115, 90.0, 73.2),
+ 35: ("Gripper_Halter_460", "degC", 5, 25.0, 25.0),
+ 36: ("Gripper_Flex_A", "degC", 5, 25.0, 25.0),
+ 37: ("Gripper_Flex_B", "degC", 5, 25.0, 25.0),
+ 38: ("Gripper_A", "K", 140, 100.0, 73.2),
+ 39: ("Gripper_B", "K", 140, 100.0, 73.2),
+ 41: ("FZP", "degC", 1, 30.0, 30.0),
+ 42: ("Park_RT_A", "degC", 5, 25.0, 25.0),
+ 44: ("Park_RT_B", "degC", 5, 25.0, 25.0),
+ 45: ("BaseBlock", "degC", 50, 24.0, -199.9),
+ 46: ("Park_Cryo_R", "K", 140, 100.0, 73.2),
+ 47: ("Park_Cryo_L", "K", 140, 100.0, 73.2),
+}
+
+
class SimOMNYTemperatures(OMNYTemperatures):
- """OMNYTemperatures with all EPICS PVs replaced by in-memory mock PVs."""
+ """OMNYTemperatures with all EPICS PVs replaced by in-memory mock PVs, seeded
+ with realistic per-channel names/units/readings/setpoints for the 29 real "TEMP"
+ channels (see _TEMP_CHANNELS above). The 4 "CRYO" channels are not seeded yet --
+ pending the cryocon controller reference.
+ """
def __new__(cls, *args, **kwargs):
with patched_device(OMNYTemperatures, *args, **kwargs) as temperatures:
+ now = time.time()
+ for channel in range(1, 49):
+ if channel not in _TEMP_CHANNELS:
+ # matches tempcontrol.py's real ChanName default ('-') for
+ # channels not in use; show_all() hides these accordingly
+ getattr(temperatures.temperature_names, f"temperature_name{channel}").put("-")
+ continue
+ name, egu, _alarmlim, reading, setpoint = _TEMP_CHANNELS[channel]
+ getattr(temperatures.temperature_names, f"temperature_name{channel}").put(name)
+ getattr(temperatures.temperature_unit, f"temperature_unit{channel}").put(egu)
+ getattr(temperatures.temperature, f"temperature{channel}").put(reading)
+ getattr(temperatures.temperature_setpoint, f"temperature_setpoint{channel}").put(
+ setpoint
+ )
+ getattr(
+ temperatures.temperature_alarmstate, f"temperature_alarmstate{channel}"
+ ).put(0)
+ temperatures.temperature_update_time.put(now)
+ temperatures.cryo_temperature_update_time.put(now)
return temperatures
From 5e8fda761f9af7218be657789273af35e35ff3f2 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 19:20:12 +0200
Subject: [PATCH 008/102] feat(omny): seed the 3 known CryoCon channels in
SimOMNYTemperatures
Chan A "Cryo sample" 90K, Chan B "Cryo head" 82K, Chan C "Sample backup"
90K, all closed-loop (reading == setpoint). Channel D has no known real
use yet and is left unseeded.
---
csaxs_bec/devices/sim/sim_omny.py | 23 +++++++++++++++++++++--
1 file changed, 21 insertions(+), 2 deletions(-)
diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py
index 929310fc..f0574620 100644
--- a/csaxs_bec/devices/sim/sim_omny.py
+++ b/csaxs_bec/devices/sim/sim_omny.py
@@ -334,12 +334,19 @@ _TEMP_CHANNELS = {
47: ("Park_Cryo_L", "K", 140, 100.0, 73.2),
}
+# CryoCon channels ("CRYO" bank, cryo_temperature1-4 -> A-D). Closed-loop controlled,
+# so the simulated reading is put at the real setpoint. Channel D has no known real
+# use yet -- left unseeded (shows as "0"/0.00; the CRYO branch of show_all() has no
+# name-based hiding, unlike the TEMP bank).
+# controller number (1=A, 2=B, 3=C, 4=D): (name, reading/setpoint in K)
+_CRYO_CHANNELS = {1: ("Cryo sample", 90.0), 2: ("Cryo head", 82.0), 3: ("Sample backup", 90.0)}
+
class SimOMNYTemperatures(OMNYTemperatures):
"""OMNYTemperatures with all EPICS PVs replaced by in-memory mock PVs, seeded
with realistic per-channel names/units/readings/setpoints for the 29 real "TEMP"
- channels (see _TEMP_CHANNELS above). The 4 "CRYO" channels are not seeded yet --
- pending the cryocon controller reference.
+ channels (see _TEMP_CHANNELS above) and the 3 known "CRYO" channels (see
+ _CRYO_CHANNELS above).
"""
def __new__(cls, *args, **kwargs):
@@ -361,6 +368,18 @@ class SimOMNYTemperatures(OMNYTemperatures):
getattr(
temperatures.temperature_alarmstate, f"temperature_alarmstate{channel}"
).put(0)
+ for controller_nr, (name, reading) in _CRYO_CHANNELS.items():
+ getattr(
+ temperatures.cryo_temperature_name, f"cryo_temperature_name{controller_nr}"
+ ).put(name)
+ getattr(temperatures.cryo_temperature, f"cryo_temperature{controller_nr}").put(
+ reading
+ )
+ getattr(
+ temperatures.cryo_temperature_setpoint,
+ f"cryo_temperature_setpoint{controller_nr}",
+ ).put(reading)
+ temperatures.cryo_temperature_closed_loop.put(1)
temperatures.temperature_update_time.put(now)
temperatures.cryo_temperature_update_time.put(now)
return temperatures
From 62b4b8d8faf89862b0d2cf3a47cd9401ee1a48a4 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 19:52:36 +0200
Subject: [PATCH 009/102] feat(omny): skip OSA/FZP moves when already at the
target position
oosa_in(), oosa_out(), ofzp_in(), ofzp_out() used to unconditionally
re-move every axis (and, for FZP, unconditionally disable rt feedback)
even when already at the target -- visible as repeated no-op progress
bars on back-to-back calls. Ported the same "skip if already there"
optimization flomni's fosa_in()/ffzp_in() already have (and that lamni
already got too, see test_lamni_optics_skip_if_in_position.py).
For oosa_in()/oosa_out() the "already there" check has to run *before*
_oosa_check_y(), since that call unconditionally parks oosay at 0.9 --
never the real in/out position for either near- or far-field mode -- so
checking afterward would always miss the match. oosa_out()'s "out"
targets are hardcoded literals in the existing code (no user_parameter
for out, unlike oosa_in's far_field_in/near_field_in), so the skip
check compares against those same literals.
New tests/tests_bec_ipython_client/test_omny_optics_skip_if_in_position.py
(9 tests, mirroring the lamni test's structure): skip-vs-move for both
near/far field on oosa_in/oosa_out, and skip-vs-move-and-disable-feedback
for ofzp_in/ofzp_out.
---
.../plugins/omny/omny_optics_mixin.py | 72 +++++--
.../test_omny_optics_skip_if_in_position.py | 204 ++++++++++++++++++
2 files changed, 264 insertions(+), 12 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_omny_optics_skip_if_in_position.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_optics_mixin.py
index 0023308e..3faf811f 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_optics_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_optics_mixin.py
@@ -129,16 +129,44 @@ class OMNYOpticsMixin:
if np.fabs(omny_oosa_currentx + 2) > 0.1:
raise OMNYError("oosax did not reach target position. Not moving in z.\n")
+ def _oosa_is_at(self, x_pos, y_pos, z_pos, tol=0.05) -> bool:
+ """True if oosax/oosay/oosaz are all within tol of the given position."""
+ dev.oosaz.read(cached=False)
+ return (
+ np.fabs(dev.oosax.get().readback - x_pos) < tol
+ and np.fabs(dev.oosay.get().readback - y_pos) < tol
+ and np.fabs(dev.oosaz.get().readback - z_pos) < tol
+ )
+
def oosa_in(self):
+ # Compute the target *before* touching anything: _oosa_check_y() below
+ # unconditionally parks oosay at 0.9, which is not the "in" position for
+ # either mode, so the "already there" check must happen first or it
+ # would never see the real (pre-park) oosay reading.
+ if self.near_field == False:
+ mode, x_in_pos, y_in_pos, z_in_pos = (
+ "far-field",
+ self._get_user_param_safe("oosax", "far_field_in"),
+ self._get_user_param_safe("oosay", "far_field_in"),
+ self._get_user_param_safe("oosaz", "far_field_in"),
+ )
+ else:
+ mode, x_in_pos, y_in_pos, z_in_pos = (
+ "near-field",
+ self._get_user_param_safe("oosax", "near_field_in"),
+ self._get_user_param_safe("oosay", "near_field_in"),
+ self._get_user_param_safe("oosaz", "near_field_in"),
+ )
+ if self._oosa_is_at(x_in_pos, y_in_pos, z_in_pos):
+ self.OMNYTools.printgreen(f"OSA is already at the IN position ({mode}).")
+ return
+
self._oosa_check_y()
dev.oshield.read(cached=False)
omny_oshield_current = dev.oshield.get().readback
if omny_oshield_current < 15:
self._oshield_ST_close()
if self.near_field == False:
- x_in_pos = self._get_user_param_safe("oosax", "far_field_in")
- y_in_pos = self._get_user_param_safe("oosay", "far_field_in")
- z_in_pos = self._get_user_param_safe("oosaz", "far_field_in")
print("OSA movement in far-field mode.")
dev.oosaz.read(cached=False)
omny_oosa_currentz = dev.oosaz.get().readback
@@ -154,9 +182,6 @@ class OMNYOpticsMixin:
# umv oosay 0.386015
if self.near_field == True:
- x_in_pos = self._get_user_param_safe("oosax", "near_field_in")
- y_in_pos = self._get_user_param_safe("oosay", "near_field_in")
- z_in_pos = self._get_user_param_safe("oosaz", "near_field_in")
print("OSA movement in near-field mode.")
dev.oosaz.read(cached=False)
omny_oosa_currentz = dev.oosaz.get().readback
@@ -178,14 +203,26 @@ class OMNYOpticsMixin:
# rt_feedback_enable
def oosa_out(self):
+ # oosa_out() doesn't move oosay, so "already out" only depends on x/z;
+ # read it before _oosa_check_y() parks oosay at 0.9 below (same reason
+ # as in oosa_in()).
+ omny_oosay_current = dev.oosay.get().readback
+ if self.near_field == False:
+ mode, z_out_pos = "far-field", 6.5
+ else:
+ mode, z_out_pos = "near-field", -0.45
+ if self._oosa_is_at(-2, omny_oosay_current, z_out_pos):
+ self.OMNYTools.printgreen(f"OSA is already at the OUT position ({mode}).")
+ return
+
self._oosa_check_y()
dev.oshield.read(cached=False)
omny_oshield_current = dev.oshield.get().readback
if omny_oshield_current < 15:
self._oshield_ST_close()
- omny_oosaz_current = dev.oosaz.get().readback
if self.near_field == False:
print("OSA movement in far-field mode.")
+ omny_oosaz_current = dev.oosaz.get().readback
if omny_oosaz_current < 6.4:
self._oosa_to_move_corridor()
dev.oosaz.limits = [6.4, 6.6]
@@ -193,6 +230,7 @@ class OMNYOpticsMixin:
umv(dev.oosax, -2)
if self.near_field == True:
print("OSA movement in near-field mode.")
+ omny_oosaz_current = dev.oosaz.get().readback
if omny_oosaz_current > 0:
self._oosa_to_move_corridor()
dev.oosaz.limits = [-0.4, -0.6]
@@ -217,18 +255,28 @@ class OMNYOpticsMixin:
self.OMNYTools.printgreen("OSA is out of shield.")
def ofzp_out(self):
+ y_out_pos = self._get_user_param_safe("ofzpy", "out")
+ needs_move = np.fabs(dev.ofzpy.get().readback - y_out_pos) > 0.02
+ if not needs_move:
+ self.OMNYTools.printgreen("FZP is already at the out position")
+ return
if "rtx" in dev and dev.rtx.enabled:
dev.rtx.controller.feedback_disable()
- y_out_pos = self._get_user_param_safe("ofzpy", "out")
- if np.fabs(dev.ofzpy.get().readback - y_out_pos) > 0.02:
- umv(dev.ofzpy, y_out_pos)
+ umv(dev.ofzpy, y_out_pos)
self.OMNYTools.printgreen("FZP at out position")
def ofzp_in(self):
- if "rtx" in dev and dev.rtx.enabled:
- dev.rtx.controller.feedback_disable()
x_in_pos = self._get_user_param_safe("ofzpx", "in")
y_in_pos = self._get_user_param_safe("ofzpy", "in")
+ needs_move = (
+ np.fabs(dev.ofzpy.get().readback - y_in_pos) > 0.02
+ or np.fabs(dev.ofzpx.get().readback - x_in_pos) > 0.02
+ )
+ if not needs_move:
+ self.OMNYTools.printgreen("FZP is already at the in position")
+ return
+ if "rtx" in dev and dev.rtx.enabled:
+ dev.rtx.controller.feedback_disable()
if np.fabs(dev.ofzpy.get().readback - y_in_pos) > 0.02:
umv(dev.ofzpy, y_in_pos)
diff --git a/tests/tests_bec_ipython_client/test_omny_optics_skip_if_in_position.py b/tests/tests_bec_ipython_client/test_omny_optics_skip_if_in_position.py
new file mode 100644
index 00000000..bbbc5700
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_omny_optics_skip_if_in_position.py
@@ -0,0 +1,204 @@
+"""Tests for omny's "skip if already in position" optics optimization for
+oosa_in()/oosa_out()/ofzp_in()/ofzp_out(), ported from flomni's
+fosa_in()/ffzp_in() (same optimization already done for lamni, see
+test_lamni_optics_skip_if_in_position.py).
+
+Methods under test (omny_optics_mixin.py) reference module-level `dev`/`umv`
+globals (resolved from this module's own namespace, not an instance
+attribute), so tests monkeypatch those module globals directly.
+"""
+
+import types
+
+import pytest
+
+import csaxs_bec.bec_ipython_client.plugins.omny.omny_optics_mixin as optics_mixin_module
+from csaxs_bec.bec_ipython_client.plugins.omny.omny_optics_mixin import OMNYOpticsMixin
+
+# far-field in: oosax=3.022, oosay=0.4122, oosaz=6.5 (out)
+# near-field in: oosax=3.2044, oosay=0.531, oosaz=-0.4452
+FAR_IN = {"oosax": 3.022, "oosay": 0.4122, "oosaz": 6.5}
+NEAR_IN = {"oosax": 3.2044, "oosay": 0.531, "oosaz": -0.4452}
+
+
+class FakeAxis:
+ def __init__(self, value, in_pos=None, out_pos=None):
+ self.value = value
+ self.readback = types.SimpleNamespace(get=lambda: self.value)
+ self.user_parameter = {}
+ if in_pos is not None:
+ self.user_parameter["in"] = in_pos
+ if out_pos is not None:
+ self.user_parameter["out"] = out_pos
+
+ def read(self, cached=True):
+ return self.value
+
+ def get(self):
+ return types.SimpleNamespace(readback=self.value)
+
+
+class FakeController:
+ def __init__(self):
+ self.calls = []
+
+ def feedback_disable(self):
+ self.calls.append("disable")
+
+ def feedback_enable_with_reset(self):
+ self.calls.append("enable_with_reset")
+
+
+class FakeDev:
+ def __init__(self, oosay=0.9, oshield=20.0, osamy=0.0):
+ self.oosax = FakeAxis(0.0, in_pos=FAR_IN["oosax"])
+ self.oosax.user_parameter = {
+ "far_field_in": FAR_IN["oosax"],
+ "near_field_in": NEAR_IN["oosax"],
+ }
+ self.oosay = FakeAxis(oosay)
+ self.oosay.user_parameter = {
+ "far_field_in": FAR_IN["oosay"],
+ "near_field_in": NEAR_IN["oosay"],
+ }
+ self.oosaz = FakeAxis(0.0)
+ self.oosaz.limits = None
+ self.oosaz.user_parameter = {
+ "far_field_in": FAR_IN["oosaz"],
+ "near_field_in": NEAR_IN["oosaz"],
+ }
+ self.oshield = FakeAxis(oshield)
+ self.osamy = FakeAxis(osamy)
+ self.ofzpx = FakeAxis(-0.5, in_pos=-0.5)
+ self.ofzpy = FakeAxis(0.3, in_pos=0.3, out_pos=0.0)
+ self.rtx = types.SimpleNamespace(enabled=True, controller=FakeController())
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+@pytest.fixture
+def fake_dev(monkeypatch):
+ # omny_optics_mixin.py has no module-level `dev`/`umv` names of its own --
+ # it resolves them via the builtins fallback at call time, so the module
+ # attribute must be force-created (raising=False) rather than assumed to
+ # pre-exist.
+ dev = FakeDev()
+ monkeypatch.setattr(optics_mixin_module, "dev", dev, raising=False)
+ return dev
+
+
+@pytest.fixture
+def umv_calls(monkeypatch):
+ calls = []
+ monkeypatch.setattr(optics_mixin_module, "umv", lambda *args: calls.append(args), raising=False)
+ return calls
+
+
+def make_optics(near_field=False):
+ optics = OMNYOpticsMixin()
+ optics.near_field = near_field
+ optics.OMNYTools = types.SimpleNamespace(printgreen=print)
+ return optics
+
+
+def test_oosa_in_skips_move_when_already_in_position_far_field(fake_dev, umv_calls, capsys):
+ fake_dev.oosax.value = FAR_IN["oosax"]
+ fake_dev.oosay.value = FAR_IN["oosay"]
+ fake_dev.oosaz.value = FAR_IN["oosaz"]
+ optics = make_optics(near_field=False)
+
+ optics.oosa_in()
+
+ assert umv_calls == []
+ assert "already at the IN position" in capsys.readouterr().out
+ # _oosa_check_y()/shield-close must not run either -- oosay untouched
+ assert fake_dev.oosay.value == FAR_IN["oosay"]
+
+
+def test_oosa_in_moves_when_out_of_position(fake_dev, umv_calls):
+ fake_dev.oosax.value = -2.0 # not at far-field "in"
+ optics = make_optics(near_field=False)
+
+ optics.oosa_in()
+
+ assert (fake_dev.oosaz, FAR_IN["oosaz"]) in umv_calls
+ assert (fake_dev.oosax, FAR_IN["oosax"]) in umv_calls
+ assert (fake_dev.oosay, FAR_IN["oosay"]) in umv_calls
+
+
+def test_oosa_in_skips_move_when_already_in_position_near_field(fake_dev, umv_calls, capsys):
+ fake_dev.oosax.value = NEAR_IN["oosax"]
+ fake_dev.oosay.value = NEAR_IN["oosay"]
+ fake_dev.oosaz.value = NEAR_IN["oosaz"]
+ optics = make_optics(near_field=True)
+
+ optics.oosa_in()
+
+ assert umv_calls == []
+ assert "already at the IN position" in capsys.readouterr().out
+
+
+def test_oosa_out_skips_move_when_already_out(fake_dev, umv_calls, capsys):
+ fake_dev.oosax.value = -2.0
+ fake_dev.oosaz.value = 6.5
+ optics = make_optics(near_field=False)
+
+ optics.oosa_out()
+
+ assert umv_calls == []
+ assert "already at the OUT position" in capsys.readouterr().out
+
+
+def test_oosa_out_moves_when_not_out(fake_dev, umv_calls):
+ fake_dev.oosax.value = FAR_IN["oosax"]
+ fake_dev.oosaz.value = FAR_IN["oosaz"]
+ optics = make_optics(near_field=False)
+
+ optics.oosa_out()
+
+ assert (fake_dev.oosaz, 6.5) in umv_calls
+ assert (fake_dev.oosax, -2) in umv_calls
+
+
+def test_ofzp_in_skips_move_and_feedback_disable_when_already_in(fake_dev, umv_calls, capsys):
+ optics = make_optics()
+
+ optics.ofzp_in()
+
+ assert umv_calls == []
+ assert fake_dev.rtx.controller.calls == []
+ assert "already at the in position" in capsys.readouterr().out
+
+
+def test_ofzp_in_moves_and_disables_feedback_when_out_of_position(fake_dev, umv_calls):
+ fake_dev.ofzpx.value = -1.0 # not at "in" (-0.5)
+ optics = make_optics()
+
+ optics.ofzp_in()
+
+ assert (fake_dev.ofzpx, -0.5) in umv_calls
+ assert fake_dev.rtx.controller.calls == ["disable"]
+
+
+def test_ofzp_out_skips_move_and_feedback_disable_when_already_out(fake_dev, umv_calls, capsys):
+ fake_dev.ofzpy.value = 0.0 # matches "out"
+ optics = make_optics()
+
+ optics.ofzp_out()
+
+ assert umv_calls == []
+ assert fake_dev.rtx.controller.calls == []
+ assert "already at the out position" in capsys.readouterr().out
+
+
+def test_ofzp_out_moves_and_disables_feedback_when_not_out(fake_dev, umv_calls):
+ optics = make_optics() # ofzpy at 0.3, not "out" (0.0)
+
+ optics.ofzp_out()
+
+ assert (fake_dev.ofzpy, 0.0) in umv_calls
+ assert fake_dev.rtx.controller.calls == ["disable"]
From e08ac1a44be05c4bb0ffd49f03f75d44048b8dc7 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 20:15:25 +0200
Subject: [PATCH 010/102] fix(omny): make correction data global-var-backed,
fix default file path
Two real bugs found while investigating a recurring AttributeError:
'OMNY' object has no attribute 'corr_pos_x' from tomo_scan_projection().
1. default_correction_file/default_correction_file_x were bare relative
filenames ("correction_omny_202204.txt"), unlike flomni's equivalent
which resolves an absolute path via Path(csaxs_bec.__file__). open()
would only ever succeed if bec were launched from this exact plugin
directory -- silently swallowed everywhere else by the existing
FileNotFoundError handler in reset_correction(). The real correction
files genuinely exist on disk next to the plugin code and were very
likely never actually being loaded.
2. corr_pos_x/corr_angle_x/corr_pos_y/corr_angle_y/corr_pos_y_2/
corr_angle_y_2 were plain instance attributes, only ever assigned
inside reset_correction() (itself only called from
otransfer_put_sample() during a sample transfer) -- hence the
AttributeError when calling tomo_scan_projection() beforehand.
Converted to global-var-backed properties defaulting to [] when unset,
mirroring flomni's corr_pos_y/corr_angle_y/etc. exactly. This
supersedes a shallower getattr()-based band-aid from an earlier,
uncommitted attempt at this fix. As a bonus, correction data now
persists across BEC client restarts, same as flomni's.
Note flomni has no x-correction concept at all (only corr_pos_y/
corr_pos_y_2) -- omny's x-correction is its own thing, not something
copied from flomni.
17 new/updated tests in test_omny_alignment_mixin.py, including an
end-to-end check that reset_correction(use_default_correction=True) now
actually loads the real correction_omny_202204*.txt files.
---
.../plugins/omny/omny_alignment_mixin.py | 88 ++++++++++-
.../test_omny_alignment_mixin.py | 143 ++++++++++++++++++
2 files changed, 229 insertions(+), 2 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_omny_alignment_mixin.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_alignment_mixin.py
index ae1f433b..df627113 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_alignment_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_alignment_mixin.py
@@ -1,6 +1,7 @@
import time
import numpy as np
import os
+from pathlib import Path
from rich import box
from rich.console import Console
@@ -17,8 +18,87 @@ class OMNYAlignmentError(Exception):
class OMNYAlignmentMixin:
- default_correction_file = "correction_omny_202204.txt"
- default_correction_file_x = "correction_omny_202204_x.txt"
+ # Resolved to an absolute path (mirrors Flomni's default_correction_file
+ # setup) -- a bare relative filename would only ever be found if bec were
+ # launched from this exact plugin directory, silently failing (caught by
+ # the FileNotFoundError handler below) everywhere else.
+ import csaxs_bec
+
+ _csaxs_bec_basepath = Path(csaxs_bec.__file__).parent
+ default_correction_file = (
+ _csaxs_bec_basepath
+ / "bec_ipython_client"
+ / "plugins"
+ / "omny"
+ / "correction_omny_202204.txt"
+ ).resolve()
+ default_correction_file_x = (
+ _csaxs_bec_basepath
+ / "bec_ipython_client"
+ / "plugins"
+ / "omny"
+ / "correction_omny_202204_x.txt"
+ ).resolve()
+ del csaxs_bec
+
+ # additional x/y-correction curves: global-var-backed so they survive a
+ # BEC client restart, same pattern as every other persistent setting in
+ # this class (and matching Flomni's corr_pos_y/corr_angle_y/etc.). NOTE:
+ # must NOT be pre-assigned in OMNY.__init__ -- doing so would overwrite
+ # the persisted value with [] on every restart.
+ @property
+ def corr_pos_x(self):
+ val = self.client.get_global_var("corr_pos_x")
+ return [] if val is None else val
+
+ @corr_pos_x.setter
+ def corr_pos_x(self, val: list):
+ self.client.set_global_var("corr_pos_x", val)
+
+ @property
+ def corr_angle_x(self):
+ val = self.client.get_global_var("corr_angle_x")
+ return [] if val is None else val
+
+ @corr_angle_x.setter
+ def corr_angle_x(self, val: list):
+ self.client.set_global_var("corr_angle_x", val)
+
+ @property
+ def corr_pos_y(self):
+ val = self.client.get_global_var("corr_pos_y")
+ return [] if val is None else val
+
+ @corr_pos_y.setter
+ def corr_pos_y(self, val: list):
+ self.client.set_global_var("corr_pos_y", val)
+
+ @property
+ def corr_angle_y(self):
+ val = self.client.get_global_var("corr_angle_y")
+ return [] if val is None else val
+
+ @corr_angle_y.setter
+ def corr_angle_y(self, val: list):
+ self.client.set_global_var("corr_angle_y", val)
+
+ @property
+ def corr_pos_y_2(self):
+ val = self.client.get_global_var("corr_pos_y_2")
+ return [] if val is None else val
+
+ @corr_pos_y_2.setter
+ def corr_pos_y_2(self, val: list):
+ self.client.set_global_var("corr_pos_y_2", val)
+
+ @property
+ def corr_angle_y_2(self):
+ val = self.client.get_global_var("corr_angle_y_2")
+ return [] if val is None else val
+
+ @corr_angle_y_2.setter
+ def corr_angle_y_2(self, val: list):
+ self.client.set_global_var("corr_angle_y_2", val)
def reset_correction(self, use_default_correction=True):
"""
@@ -215,6 +295,10 @@ class OMNYAlignmentMixin:
return self._compute_additional_correction(angle, iteration="y2")
def _compute_additional_correction(self, angle, iteration="y1"):
+ # corr_pos_x/corr_pos_y/corr_pos_y_2 etc. are global-var-backed
+ # properties that default to [] -- never AttributeError, even before
+ # reset_correction() has ever run (e.g. testing scan mechanics before
+ # any sample transfer).
if iteration == "x1":
corr_pos = self.corr_pos_x
corr_angle = self.corr_angle_x
diff --git a/tests/tests_bec_ipython_client/test_omny_alignment_mixin.py b/tests/tests_bec_ipython_client/test_omny_alignment_mixin.py
new file mode 100644
index 00000000..9ef20684
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_omny_alignment_mixin.py
@@ -0,0 +1,143 @@
+"""Regression test for OMNYAlignmentMixin's correction data.
+
+corr_pos_x/corr_angle_x/corr_pos_y/etc. used to be plain instance attributes,
+only ever assigned inside reset_correction() -- calling
+_compute_additional_correction() before reset_correction() had ever run (e.g.
+tomo_scan_projection() called to test scan mechanics without first doing a
+sample transfer, the only place reset_correction() gets called, see
+omny_sample_transfer_mixin.py's otransfer_put_sample()) raised
+'OMNY' object has no attribute 'corr_pos_x'.
+
+They are now global-var-backed properties (mirroring Flomni's
+corr_pos_y/corr_angle_y/etc.), defaulting to [] when unset -- matching the
+graceful "no data available" handling _compute_additional_correction()
+already had for an empty (but present) list, and persisting across BEC
+client restarts like every other setting in this class.
+
+Also regression-guards default_correction_file/default_correction_file_x
+being resolved to an absolute path (a bare relative filename would only ever
+be found if bec were launched from this exact plugin directory).
+"""
+
+import os
+
+import pytest
+
+from csaxs_bec.bec_ipython_client.plugins.omny.omny_alignment_mixin import OMNYAlignmentMixin
+
+
+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_alignment():
+ # bare instance, deliberately never calling reset_correction() -- mirrors
+ # a fresh OMNY session where no sample has been transferred in yet
+ align = OMNYAlignmentMixin()
+ align.client = FakeClient()
+ return align
+
+
+def test_default_correction_files_are_absolute_and_exist():
+ assert os.path.isabs(OMNYAlignmentMixin.default_correction_file)
+ assert os.path.isabs(OMNYAlignmentMixin.default_correction_file_x)
+ assert os.path.exists(OMNYAlignmentMixin.default_correction_file)
+ assert os.path.exists(OMNYAlignmentMixin.default_correction_file_x)
+
+
+@pytest.mark.parametrize(
+ "attr",
+ ["corr_pos_x", "corr_angle_x", "corr_pos_y", "corr_angle_y", "corr_pos_y_2", "corr_angle_y_2"],
+)
+def test_correction_properties_default_to_empty_list(attr):
+ align = make_alignment()
+
+ assert getattr(align, attr) == []
+
+
+@pytest.mark.parametrize("iteration", ["x1", "y1", "y2"])
+def test_compute_additional_correction_returns_zero_when_never_reset(iteration, capsys):
+ align = make_alignment()
+
+ result = align._compute_additional_correction(45.0, iteration=iteration)
+
+ assert result == 0
+ assert "No data available" in capsys.readouterr().out
+
+
+def test_compute_additional_correction_x_does_not_raise():
+ align = make_alignment()
+
+ assert align.compute_additional_correction_x(0.0) == 0
+
+
+def test_compute_additional_correction_y_does_not_raise():
+ align = make_alignment()
+
+ assert align.compute_additional_correction_y(0.0) == 0
+
+
+def test_compute_additional_correction_y_2_does_not_raise():
+ align = make_alignment()
+
+ assert align.compute_additional_correction_y_2(0.0) == 0
+
+
+def test_compute_additional_correction_still_works_after_reset():
+ """Once corr_pos_x/corr_angle_x etc. are actually set (mirrors what
+ reset_correction() -> read_additional_correction_x() would do), the
+ normal closest-angle lookup still works."""
+ align = make_alignment()
+ align.corr_pos_x = [1.0, 2.0, 3.0]
+ align.corr_angle_x = [0.0, 90.0, 180.0]
+
+ result = align._compute_additional_correction(85.0, iteration="x1")
+
+ assert result == 2.0
+
+
+def test_correction_properties_persist_across_instances_via_shared_client():
+ """Global-var-backed: two instances sharing the same client see the same
+ value, unlike a plain instance attribute."""
+ client = FakeClient()
+ align_a = OMNYAlignmentMixin()
+ align_a.client = client
+ align_b = OMNYAlignmentMixin()
+ align_b.client = client
+
+ align_a.corr_pos_x = [5.0]
+
+ assert align_b.corr_pos_x == [5.0]
+
+
+def test_reset_correction_with_default_loads_real_file_data():
+ """End-to-end check of the path-resolution fix: reset_correction(True)
+ must actually find and load the real correction_omny_202204*.txt files
+ (previously silently failed everywhere except when bec happened to be
+ launched from this exact plugin directory)."""
+ align = make_alignment()
+
+ align.reset_correction(use_default_correction=True)
+
+ assert len(align.corr_pos_x) > 0
+ assert len(align.corr_pos_y) > 0
+
+
+def test_reset_correction_without_default_clears_to_empty():
+ align = make_alignment()
+ align.corr_pos_x = [1.0]
+ align.corr_angle_x = [0.0]
+
+ align.reset_correction(use_default_correction=False)
+
+ assert align.corr_pos_x == []
+ assert align.corr_angle_x == []
From 68896a8ea237f863ecdf146f092ec2c7c1892274 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 20:27:57 +0200
Subject: [PATCH 011/102] fix(omny): let OmnyFermatScan.corridor_size accept
None, auto-estimate
omny_fermat_scan.py's corridor_size was Annotated[float] = 3, never
updated to accept None the way flomni_fermat_scan.py's already does.
But omny.py's tomo_scan_projection() calling code passes
corridor_size=None whenever omny.corridor_size (a property defaulting
to -1) hasn't been set to a positive value -- producing
ScanInputValidationError: Invalid type for scan argument 'corridor_size':
None is neither float or int.
Ported flomni's fix directly: corridor_size is now
Annotated[float | None] = None, capped at the new MAX_CORRIDOR_SIZE
(3 um) class constant if a caller passes something larger, and
prepare_scan() now calls the same _estimate_corridor_size() static
method flomni uses (median nearest-neighbor distance * 1.5, capped at
MAX_CORRIDOR_SIZE) to pick a sensible corridor size from the actual
scan positions whenever corridor_size is None, instead of crashing.
2 new tests for _estimate_corridor_size() in test_omny_fermat_scan.py.
---
csaxs_bec/scans/omny_fermat_scan.py | 39 ++++++++++++++++++++--
tests/tests_scans/test_omny_fermat_scan.py | 23 +++++++++++++
2 files changed, 59 insertions(+), 3 deletions(-)
diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py
index 55a7a391..0a40d64b 100644
--- a/csaxs_bec/scans/omny_fermat_scan.py
+++ b/csaxs_bec/scans/omny_fermat_scan.py
@@ -20,6 +20,8 @@ import time
from typing import Annotated
import numpy as np
+from scipy.spatial import cKDTree
+
from bec_lib import messages
from bec_lib.logger import bec_logger
from bec_lib.scan_args import DefaultArgType, ScanArgument, Units
@@ -44,6 +46,8 @@ class OmnyFermatScan(ScanBase):
# It must be a valid Python identifier, that is, it can only contain letters, numbers, and underscores, and must not start with a number.
scan_name = "omny_fermat_scan"
+ MAX_CORRIDOR_SIZE = 3 # Corridor size is capped at this value (in um) for stability
+
gui_config = {
"Scan Parameters": [
"fovx",
@@ -68,7 +72,7 @@ class OmnyFermatScan(ScanBase):
step: Annotated[float, ScanArgument(display_name="Step", description="Step size.", units=Units.µm)],
zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z.", units=Units.µm)],
angle: Annotated[float | None, ScanArgument(display_name="Angle", description="Rotation angle (will rotate first)", units=Units.deg)] = None,
- corridor_size: Annotated[float, ScanArgument(display_name="Corridor Size", description="Corridor size for the corridor optimization. ", units=Units.µm)] = 3,
+ corridor_size: Annotated[float | None, ScanArgument(display_name="Corridor Size", description="Corridor size for the corridor optimization. ", units=Units.µm)] = None,
exp_time: DefaultArgType.ExposureTime = 0,
frames_per_trigger: DefaultArgType.FramesPerTrigger = 1,
readout_time: DefaultArgType.ReadoutTime = 0,
@@ -86,7 +90,7 @@ class OmnyFermatScan(ScanBase):
step (float): Step size.
zshift (float): Shift in z.
angle (float | None): Rotation angle (will rotate first)
- corridor_size (float): Corridor size for the corridor optimization.
+ corridor_size (float | None): Corridor size for the corridor optimization.
exp_time (float): Exposure time in seconds
frames_per_trigger (int): Number of frames per trigger for devices that support configurable frame counts per trigger.
readout_time (float): Configuration for devices that support configurable readout times.
@@ -115,6 +119,13 @@ class OmnyFermatScan(ScanBase):
logger.warning("The zshift is smaller than -100 um. It will be limited to -100 um.")
self.zshift = -100
+ if self.corridor_size is not None and self.corridor_size > self.MAX_CORRIDOR_SIZE:
+ logger.warning(
+ f"The corridor_size is larger than {self.MAX_CORRIDOR_SIZE} um. It will be"
+ f" limited to {self.MAX_CORRIDOR_SIZE} um."
+ )
+ self.corridor_size = self.MAX_CORRIDOR_SIZE
+
self.update_scan_info(
exp_time=exp_time, frames_per_trigger=frames_per_trigger, readout_time=readout_time
)
@@ -144,8 +155,12 @@ class OmnyFermatScan(ScanBase):
f"The number of positions must exceed 20. Currently: {len(positions)}."
)
+ corridor_size = self.corridor_size
+ if corridor_size is None:
+ corridor_size = min(self._estimate_corridor_size(positions), self.MAX_CORRIDOR_SIZE)
+
self.positions = self.components.optimize_trajectory(
- positions=positions, corridor_size=self.corridor_size, optimization_type="corridor"
+ positions=positions, corridor_size=corridor_size, optimization_type="corridor"
)
flip_axes = self.reverse_trajectory()
if flip_axes:
@@ -330,6 +345,24 @@ class OmnyFermatScan(ScanBase):
positions.append(right_upper_corner)
return np.array(positions)
+ @staticmethod
+ def _estimate_corridor_size(positions: np.ndarray, factor: float = 1.5) -> float:
+ """
+ Estimate the corridor size based on the median nearest-neighbor distance
+ of the positions, matching the estimation used by the corridor path
+ optimizer if no corridor_size is provided.
+
+ Args:
+ positions (np.ndarray): Array of positions
+ factor (float): Scaling factor for the median distance
+
+ Returns:
+ float: Estimated corridor size
+ """
+ tree = cKDTree(positions[:, :2])
+ dists, _ = tree.query(positions[:, :2], k=2) # k=1 is itself
+ return factor * np.median(dists[:, 1])
+
def prepare_setup(self):
self.dev.rtx.controller.clear_trajectory_generator()
diff --git a/tests/tests_scans/test_omny_fermat_scan.py b/tests/tests_scans/test_omny_fermat_scan.py
index 089afd71..a7187b55 100644
--- a/tests/tests_scans/test_omny_fermat_scan.py
+++ b/tests/tests_scans/test_omny_fermat_scan.py
@@ -1,5 +1,7 @@
from unittest import mock
+import numpy as np
+
from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan
@@ -42,3 +44,24 @@ def test_omny_rotation_moves_when_setpoint_matches_but_readback_far_off():
OmnyFermatScan.omny_rotation(scan, 10.0)
scan.actions.set.assert_called_once()
+
+
+def test_estimate_corridor_size_returns_positive_value_for_regular_grid():
+ # 10x10 grid, 1 um spacing -> nearest-neighbor distance is 1 um everywhere
+ xs, ys = np.meshgrid(np.arange(10), np.arange(10))
+ positions = np.column_stack([xs.ravel(), ys.ravel(), np.zeros(xs.size)])
+
+ result = OmnyFermatScan._estimate_corridor_size(positions)
+
+ assert result > 0
+ assert np.isclose(result, 1.5, atol=1e-6) # factor=1.5 * median nn-distance (1.0)
+
+
+def test_estimate_corridor_size_scales_with_factor():
+ xs, ys = np.meshgrid(np.arange(5), np.arange(5))
+ positions = np.column_stack([xs.ravel(), ys.ravel(), np.zeros(xs.size)])
+
+ default_factor = OmnyFermatScan._estimate_corridor_size(positions)
+ doubled_factor = OmnyFermatScan._estimate_corridor_size(positions, factor=3.0)
+
+ assert np.isclose(doubled_factor, 2 * default_factor)
From 5281812b6992e545db0530e2887b06b27c8d5aa2 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 20:39:20 +0200
Subject: [PATCH 012/102] feat(omny): start osamroy sim at logical 0, speed up
all sim stages 4x
- osamroy's sim_initial_position is now -25 (raw), so the simulated
logical readback (raw + 25, per the controller's calibration-mark
offset) starts at ~0 deg instead of 25 -- no rotation-to-zero needed
just from starting a session.
- All 21 SimOMNYGalilMotor axes now have an explicit sim_velocity, set
to 4x their previous implicit default (the _SimGalilAxis fallback of
5.0 * 25600 = 128000 steps/s). 10.0 for the 20 translation axes
(stppermm=51200); 5.716463414632019 for osamroy specifically, since
its stppermm=89565.8666667 differs -- both correspond to the same
512000 steps/s, i.e. a genuine 4x speedup for every stage.
---
.../simulated_omny/simulated_omny.yaml | 23 ++++++++++++++++++-
1 file changed, 22 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
index aaa710ab..ab2abaa7 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
@@ -163,6 +163,7 @@ ofzpx:
- 0
port: 8081
sign: 1
+ sim_velocity: 10.0
sim_initial_position: -0.4317
enabled: true
onFailure: buffer
@@ -185,6 +186,7 @@ ofzpy:
- 0
port: 8081
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0.7944
enabled: true
onFailure: buffer
@@ -207,6 +209,7 @@ ofzpz:
- 0
port: 8081
sign: -1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -228,6 +231,7 @@ otransx:
- 0
port: 8081
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -249,6 +253,7 @@ otransy:
- 0
port: 8081
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -271,6 +276,7 @@ otransz:
- 0
port: 8081
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -292,6 +298,7 @@ osamx:
- 0
port: 8082
sign: 1
+ sim_velocity: 10.0
sim_initial_position: -0.1
enabled: true
onFailure: buffer
@@ -313,6 +320,7 @@ osamz:
- 0
port: 8082
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: false
onFailure: buffer
@@ -334,6 +342,7 @@ oosay:
- 0
port: 8082
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -356,6 +365,7 @@ oosax:
- 0
port: 8082
sign: -1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -378,6 +388,7 @@ oosaz:
- 0
port: 8082
sign: -1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -400,6 +411,7 @@ oparkz:
- 0
port: 8082
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -421,6 +433,7 @@ oshuttleopen:
- 0
port: 8082
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -442,6 +455,7 @@ oshuttlealign:
- 0
port: 8082
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -463,6 +477,7 @@ osamy:
- 0
port: 8083
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -484,6 +499,7 @@ otracky:
- 0
port: 8083
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -505,7 +521,8 @@ osamroy:
- 0
port: 8083
sign: 1
- sim_initial_position: 0
+ sim_velocity: 5.716463414632019
+ sim_initial_position: -25
enabled: true
onFailure: buffer
readOnly: false
@@ -526,6 +543,7 @@ otrackz:
- 0
port: 8083
sign: -1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -547,6 +565,7 @@ oeyex:
- 0
port: 8083
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -568,6 +587,7 @@ oeyez:
- 0
port: 8083
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -589,6 +609,7 @@ oeyey:
- 0
port: 8083
sign: 1
+ sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
From 68a56607b8a0f816e4c6c9271b66710914120bbd Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 21:27:43 +0200
Subject: [PATCH 013/102] fix(omny): fix scan hang -- RT flyer typo, add
missing tracker-signal guard
Two bugs found by comparing omny's RT-flyer code against flomni's (per
Mirko, since flomni/lamni's fermat scans already work fine in sim).
1. Root cause of the reported scan hang (tomo_scan_projection() starts a
scan but the progress bar freezes at 0% forever, no error):
RtOMNYFlyer.read_positions_from_sampler() called self.get_scan_status()
instead of self.controller.get_scan_status(). get_scan_status() only
exists on RtOMNYController, never on RtOMNYFlyer -- so this line raised
an AttributeError inside the background thread complete() spawns for
readout. An uncaught exception in a thread target doesn't propagate;
the thread just dies silently, before ever calling status.set_finished().
scan_core()'s `while not status.done` loop then spins forever, and since
the thread died before ever updating progress, the bar stays at 0%.
Confirmed correct in both RtFlomniFlyer's and RtLamniFlyer's equivalents
already; fixed to match.
2. RtOMNYSetpointSignal._socket_set() never had the laser-tracker-signal-
strength guard RtFlomniSetpointSignal._socket_set() got in commit
68320e1 ("check tracker signal before rt move, which is otherwise stuck
forever") -- a real-hardware failure mode (a move issued with a weak
interferometer signal never converges). OMNY has its own duplicate
setpoint-signal class, so the fix never propagated. Ported using OMNY's
own existing laser_tracker_check_and_wait_for_signalstrength() (a more
capable, retrying/auto-readjusting check) rather than flomni's
single-shot version, which OMNY doesn't have. Lower confidence this one
is the actual cause of the reported *simulated* hang specifically --
the sim doesn't model signal-dependent convergence -- but it's a real,
confirmed gap worth closing for real-hardware safety regardless.
4 new regression tests in tests/tests_devices/test_rt_omny.py, including
one that directly confirms the fake stand-in used for bug #1's test would
have caught the original bug (raises AttributeError the same way the real
class would).
---
csaxs_bec/devices/omny/rt/rt_omny_ophyd.py | 10 ++-
tests/tests_devices/test_rt_omny.py | 90 ++++++++++++++++++++++
2 files changed, 99 insertions(+), 1 deletion(-)
create mode 100644 tests/tests_devices/test_rt_omny.py
diff --git a/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py b/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
index 70c905b1..ba69da8f 100644
--- a/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
+++ b/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
@@ -930,6 +930,12 @@ class RtOMNYSetpointSignal(RtSetpointSignal):
"The interferometer feedback is not running. Either it is turned off or and"
" interferometer error occured."
)
+ # Mirrors flomni's RtFlomniSetpointSignal._socket_set() (commit 68320e1, "check
+ # tracker signal before rt move, which is otherwise stuck forever"): moving with a
+ # weak interferometer signal can leave the real stage never converging on target,
+ # hanging the caller forever. Reuses OMNY's own wait-and-auto-readjust check rather
+ # than flomni's single-shot version, which OMNY doesn't have.
+ self.parent.controller.laser_tracker_check_and_wait_for_signalstrength()
self.set_with_feedback_disabled(val)
def set_with_feedback_disabled(self, val):
@@ -1258,7 +1264,9 @@ class RtOMNYFlyer(Device):
self.average_stdeviations_x_st_fzp = 0
self.average_stdeviations_y_st_fzp = 0
- mode, number_of_positions_planned, current_position_in_scan = self.get_scan_status()
+ mode, number_of_positions_planned, current_position_in_scan = (
+ self.controller.get_scan_status()
+ )
while mode > 0 and not self.shutdown_event.wait(0.01):
# logger.info(f"Current scan position {current_position_in_scan} out of {number_of_positions_planned}")
mode, number_of_positions_planned, current_position_in_scan = (
diff --git a/tests/tests_devices/test_rt_omny.py b/tests/tests_devices/test_rt_omny.py
new file mode 100644
index 00000000..1cd48be4
--- /dev/null
+++ b/tests/tests_devices/test_rt_omny.py
@@ -0,0 +1,90 @@
+"""Regression tests for two RT-flyer bugs in rt_omny_ophyd.py, found by comparing
+against flomni's (working) equivalents:
+
+1. RtOMNYFlyer.read_positions_from_sampler() called self.get_scan_status() instead
+ of self.controller.get_scan_status() -- get_scan_status() only exists on
+ RtOMNYController, never on RtOMNYFlyer itself. This raised an AttributeError
+ inside the background readout thread started by complete(); since an
+ uncaught exception in a thread target doesn't propagate, the thread died
+ silently before ever calling status.set_finished(), hanging the scan
+ forever at 0% progress (scan_core()'s `while not status.done` loop never
+ exits). flomni's and lamni's equivalents were already correct.
+
+2. RtOMNYSetpointSignal._socket_set() never checked the interferometer signal
+ strength before moving, unlike RtFlomniSetpointSignal._socket_set() (added
+ in commit 68320e1, "check tracker signal before rt move, which is
+ otherwise stuck forever") -- a real-hardware failure mode where a move
+ issued with a weak signal never converges. OMNY has its own duplicate
+ setpoint-signal class, so the fix never propagated.
+"""
+
+from unittest import mock
+
+import pytest
+
+from csaxs_bec.devices.omny.rt.rt_omny_ophyd import RtOMNYFlyer, RtOMNYSetpointSignal
+
+
+class _FakeFlyerSelf:
+ """Minimal stand-in for RtOMNYFlyer -- deliberately has no get_scan_status of
+ its own (matching the real class), so a regression to `self.get_scan_status()`
+ (instead of `self.controller.get_scan_status()`) raises AttributeError here too,
+ the same way it would on the real class.
+ """
+
+ def __init__(self):
+ self.controller = mock.MagicMock()
+ self.shutdown_event = mock.MagicMock()
+ self.average_stdeviations_x_st_fzp = 0
+ self.average_stdeviations_y_st_fzp = 0
+ self.progress = mock.MagicMock()
+ self.data = mock.MagicMock()
+
+
+def test_read_positions_from_sampler_uses_controller_get_scan_status():
+ fake_self = _FakeFlyerSelf()
+ # mode=0 -> the `while mode > 0` loop body never runs; falls straight to the
+ # shutdown-event check below it
+ fake_self.controller.get_scan_status.return_value = (0, 0, 0)
+ fake_self.shutdown_event.wait.return_value = True
+ status = mock.MagicMock()
+
+ RtOMNYFlyer.read_positions_from_sampler(fake_self, status)
+
+ fake_self.controller.get_scan_status.assert_called_once()
+ status.set_finished.assert_called_once()
+
+
+def test_read_positions_from_sampler_would_have_raised_on_the_old_bug():
+ """Sanity-check that _FakeFlyerSelf actually catches the regression: calling
+ the buggy `self.get_scan_status()` form directly must raise AttributeError."""
+ fake_self = _FakeFlyerSelf()
+ with pytest.raises(AttributeError):
+ fake_self.get_scan_status()
+
+
+def test_socket_set_checks_laser_tracker_before_moving():
+ fake_self = mock.MagicMock()
+ fake_self.parent.controller.feedback_is_running.return_value = True
+ call_order = []
+ fake_self.parent.controller.laser_tracker_check_and_wait_for_signalstrength.side_effect = (
+ lambda: call_order.append("check")
+ )
+ fake_self.set_with_feedback_disabled.side_effect = lambda val: call_order.append("move")
+
+ RtOMNYSetpointSignal._socket_set(fake_self, 10.0)
+
+ assert call_order == ["check", "move"]
+ fake_self.parent.controller.laser_tracker_check_and_wait_for_signalstrength.assert_called_once()
+ fake_self.set_with_feedback_disabled.assert_called_once_with(10.0)
+
+
+def test_socket_set_raises_when_feedback_not_running():
+ fake_self = mock.MagicMock()
+ fake_self.parent.controller.feedback_is_running.return_value = False
+
+ with pytest.raises(Exception):
+ RtOMNYSetpointSignal._socket_set(fake_self, 10.0)
+
+ fake_self.parent.controller.laser_tracker_check_and_wait_for_signalstrength.assert_not_called()
+ fake_self.set_with_feedback_disabled.assert_not_called()
From 49ade7ce98829ad01ba73ea675057a5290e05f4c Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 21:51:46 +0200
Subject: [PATCH 014/102] feat(omny): modernize gui_tools.py to flomni's
current dock/window API
OMNYGuiTools still used pre-modernization code: a gui_window handle that
was never actually populated (self.gui.windows["main"].widget always
raised/returned None), the old two-level add_dock().add_widget() API, and
bare `if self.figN is None` checks instead of an _is_deleted()-aware
guard. omnygui_show_progress() crashed with AttributeError as soon as it
was exercised live.
Ported OMNYGuiTools onto flomni's current single-level .new() pattern:
split __init__()/set_client(), a window-reuse check keyed on
self.gui.windows, _omnygui_is_missing() for dock-reuse (verbatim port of
flomni's _is_deleted()-based guard), the current RingProgressBar API for
omnygui_show_progress(), and new omnygui_show_xeyealign()/
omnygui_show_xeyealign_fittab()/omnygui_show_cameras() methods. cam_xeye
is not part of omny's device config (unlike flomni's), so the xeyealign
live-view toggle is guarded with an existence check instead of copied
unconditionally. omnygui_show_cameras() adds a hard-stop console targeting
otransy, the closest omny analog to flomni's ftransy.
Also fixes two independently-broken call sites in x_ray_eye_align.py
(self.lamni.lamnigui_show_xeyealign[_fittab]() -> the omny-side method
names actually defined on OMNYGuiTools -- self.lamni is really the OMNY
instance) that were raising AttributeError before the GUI code was even
reached. The rest of that file's LamNI-derived content is left alone; a
full rewrite is tracked separately.
omny.py's __init__ now calls OMNYGuiTools.__init__(self) + set_client(),
matching the split-constructor pattern instead of the old single-call
OMNYGuiTools.__init__(self, self.client).
Deliberately deferred: flomni's ETA/heartbeat progress fields
(tomo_start_time, heartbeat, estimated_remaining_time, etc.) are not
ported -- omny's self.progress dict only has 7 basic fields. Noted in
omny/AI_docs/OPEN_ISSUES.md so it isn't forgotten; not a crash risk since
omny_webpage_generator.py already reads these defensively.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 14 +
.../plugins/omny/gui_tools.py | 285 +++++++++++-------
.../bec_ipython_client/plugins/omny/omny.py | 3 +-
.../plugins/omny/x_ray_eye_align.py | 46 +--
.../test_omny_gui_tools.py | 106 +++++++
5 files changed, 319 insertions(+), 135 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_omny_gui_tools.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index ee91ca86..6dd51135 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -105,6 +105,20 @@ Still completely untested (not attempted yet, not known-broken — just unexplor
yet — out of scope for the sim-validation work, in scope for the eventual macro-
standardization pass.
+## gui_tools.py progress fields (deferred on purpose)
+
+`gui_tools.py` was modernized to flomni's current single-level `.new()` / `_is_deleted()`-guard
+pattern (window lifecycle, xray-eye-align GUI, cameras + hard-stop console, `RingProgressBar`).
+One piece was deliberately **not** ported: flomni's `self.progress` dict carries
+`tomo_start_time`/`heartbeat`/`estimated_remaining_time`/`estimated_finish_time` plus a
+`_describe_active_hook()` method feeding an ETA/heartbeat line in `_flomnigui_update_progress()`.
+omny's `self.progress` (set up in `OMNY.__init__`, `omny.py`) still only has the original 7 fields
+(`subtomo`, `subtomo_projection`, `subtomo_total_projections`, `projection`,
+`total_projections`, `angle`, `tomo_type`) and `_omnygui_update_progress()` was written against
+just those. Mirko's call: keep it simple for this pass, revisit later. Not a crash risk —
+`omny_webpage_generator.py` already reads the ETA-ish fields defensively via `.get()`, degrading
+to "N/A" when absent, and that file is unwired/dead code anyway (see below).
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
index 89ef27f5..d2a41535 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
@@ -1,12 +1,16 @@
import builtins
+import time
-# from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_get, epics_put, fshopen, fshclose
+from bec_lib import bec_logger
+
+logger = bec_logger.logger
if builtins.__dict__.get("bec") is not None:
bec = builtins.__dict__.get("bec")
dev = builtins.__dict__.get("dev")
scans = builtins.__dict__.get("scans")
+
def umv(*args):
return scans.umv(*args, relative=False)
@@ -16,76 +20,184 @@ class OMNYGuiToolsError(Exception):
class OMNYGuiTools:
+ # Screen assumed 2560x1440. Window is right-aligned with a small margin
+ # from the top, matching flomni's gui_tools.py convention.
+ _SCREEN_WIDTH = 2560
+ _WINDOW_WIDTH = 1500
+ _WINDOW_HEIGHT = 850
+ _WINDOW_TOP_MARGIN = 50
- def __init__(self, client):
- self.gui = getattr(client, "gui", None)
- # No "main" BEC-widgets window exists yet in --nogui sessions, or before
- # a GUI has actually been opened; fall back to None like flomni's
- # GuiTools does (self.flomni_window, lazily populated by
- # flomnigui_show_gui()), instead of crashing OMNY's construction.
- try:
- self.gui_window = self.gui.windows["main"].widget
- except (KeyError, AttributeError):
- self.gui_window = None
+ def __init__(self):
+ self.omny_window = None
+ self.text_box = None
+ self.progressbar = None
+ self.xeyegui = None
+ self.idle_text_box = None
self.fig200 = None
self.fig201 = None
self.fig202 = None
self.fig203 = None
- self.progressbar = None
- self.text_box = None
- self.idle_text_box = None
+ self.console = None
+
+ def set_client(self, client):
+ self.client = client
+ self.gui = getattr(client, "gui", None)
def omnygui_show_gui(self):
- self.gui_window.show()
+ if "omny" in self.gui.windows:
+ self.omny_window = self.gui.windows["omny"]
+ self.gui.omny.raise_window()
+ else:
+ # geometry: (pos_x, pos_y, w, h)
+ pos_x = self._SCREEN_WIDTH - self._WINDOW_WIDTH
+ geometry = (pos_x, self._WINDOW_TOP_MARGIN, self._WINDOW_WIDTH, self._WINDOW_HEIGHT)
+ self.omny_window = self.gui.new("omny", geometry=geometry)
+ time.sleep(1)
def omnygui_stop_gui(self):
- self.gui_window.hide()
+ self.gui.omny.hide()
+
+ def omnygui_raise(self):
+ self.gui.omny.raise_window()
+
+ def _omnygui_is_missing(self, attribute_name):
+ widget = getattr(self, attribute_name, None)
+ if widget is None:
+ return True
+ if hasattr(widget, "_is_deleted") and widget._is_deleted():
+ return True
+ return False
+
+ def _omnycam_check_device_exists(self, device):
+ try:
+ device
+ except:
+ return False
+ else:
+ return True
+
+ def omnygui_show_xeyealign(self):
+ self.omnygui_show_gui()
+ if self._omnygui_is_missing("xeyegui"):
+ self.omnygui_remove_all_docks()
+ self.xeyegui = self.gui.omny.new("OMNY_XRayEye", object_name="xrayeye")
+ # cam_xeye is not (yet) part of omny's device config -- guard rather
+ # than assume it exists, unlike flomni's unconditional version.
+ if "cam_xeye" in dev and not dev.cam_xeye.live_mode_enabled.get():
+ dev.cam_xeye.live_mode_enabled.put(True)
+ self.xeyegui.switch_tab("alignment")
+
+ def omnygui_show_xeyealign_fittab(self):
+ self.omnygui_show_gui()
+ if self._omnygui_is_missing("xeyegui"):
+ self.omnygui_remove_all_docks()
+ self.xeyegui = self.gui.omny.new("OMNY_XRayEye", object_name="xrayeye")
+ self.xeyegui.switch_tab("fit")
def _omnycam_parking(self):
self.omnygui_show_omnycam_parking()
def omnygui_show_omnycam_parking(self):
self.omnygui_show_gui()
- if self.fig200 is None:
- self._omnycam_clear()
- self.fig200 = self.gui_window.add_dock(name="omnycam200").add_widget("BECImageWidget")
+ if self._omnygui_is_missing("fig200") or self._omnygui_is_missing("fig203"):
+ self.omnygui_remove_all_docks()
+ self.fig200 = self.gui.omny.new("BECImageWidget")
if self._omnycam_check_device_exists(dev.cam200):
fig = self.fig200.image("cam200")
fig.set_rotation(deg_90=3)
self.fig200.lock_aspect_ratio(True)
else:
print("Cannot open cam200. Device does not exist.")
- self.fig203 = self.gui_window.add_dock(name="omnycam203").add_widget("BECImageWidget")
+ self.fig203 = self.gui.omny.new("BECImageWidget")
if self._omnycam_check_device_exists(dev.cam203):
fig = self.fig203.image("cam203")
fig.set_rotation(deg_90=3)
self.fig203.lock_aspect_ratio(True)
else:
print("Cannot open cam203. Device does not exist.")
- try:
- self.gui_window.remove_dock(name="default_figure")
- except:
- pass
+
+ def _omnycam_samplestage(self):
+ self.omnygui_show_omnycam_samplestage()
+
+ def omnygui_show_omnycam_samplestage(self):
+ self.omnygui_show_gui()
+ if self._omnygui_is_missing("fig201") or self._omnygui_is_missing("fig202"):
+ self.omnygui_remove_all_docks()
+ self.fig201 = self.gui.omny.new("BECImageWidget")
+ if self._omnycam_check_device_exists(dev.cam201):
+ fig = self.fig201.image("cam201")
+ fig.set_rotation(deg_90=3)
+ self.fig201.lock_aspect_ratio(True)
+ else:
+ print("Cannot open cam201. Device does not exist.")
+ self.fig202 = self.gui.omny.new("BECImageWidget")
+ if self._omnycam_check_device_exists(dev.cam202):
+ fig = self.fig202.image("cam202")
+ fig.set_rotation(deg_90=3)
+ self.fig202.lock_aspect_ratio(True)
+ else:
+ print("Cannot open cam202. Device does not exist.")
+
+ def omnygui_show_cameras(self):
+ """General camera view + hard-stop console, the omny analog of
+ flomni's flomnigui_show_cameras(). Distinct from
+ omnygui_show_omnycam_parking()/omnygui_show_omnycam_samplestage()
+ above, which are omny's own pre-existing, narrower parking/
+ samplestage-specific views -- left untouched.
+ """
+ self.omnygui_show_gui()
+ if (
+ self._omnygui_is_missing("fig200")
+ or self._omnygui_is_missing("fig203")
+ or self._omnygui_is_missing("console")
+ ):
+ self.omnygui_remove_all_docks()
+ self.fig200 = self.gui.omny.new("BECImageWidget")
+ if self._omnycam_check_device_exists(dev.cam200):
+ fig = self.fig200.image("cam200")
+ fig.set_rotation(deg_90=3)
+ self.fig200.lock_aspect_ratio(True)
+ else:
+ print("Cannot open cam200. Device does not exist.")
+ self.fig203 = self.gui.omny.new("BECImageWidget")
+ if self._omnycam_check_device_exists(dev.cam203):
+ fig = self.fig203.image("cam203")
+ fig.set_rotation(deg_90=3)
+ self.fig203.lock_aspect_ratio(True)
+ else:
+ print("Cannot open cam203. Device does not exist.")
+
+ # Hard-stop console, docked below the cameras. otransy is the
+ # closest omny analog to flomni's ftransy (both the transfer-Y
+ # axis) -- no secondary/extra stop device wired yet (Mirko's
+ # call, revisit once a second candidate is confirmed).
+ self.console = self.gui.omny.new(
+ "z_ConsoleButtonsWidget",
+ object_name="console",
+ where="bottom",
+ hard_stop_device_name="otransy",
+ hard_stop_label="OMNY Motion Stop",
+ )
+ self.gui.omny.set_layout_ratios(vertical=[5, 1])
def omnygui_remove_all_docks(self):
- self.gui_window.clear_all()
+ if hasattr(self.gui, "omny"):
+ self.gui.omny.delete_all()
+ self.progressbar = None
+ self.text_box = None
+ self.xeyegui = None
+ self.idle_text_box = None
self.fig200 = None
self.fig201 = None
self.fig202 = None
self.fig203 = None
- self.progressbar = None
- self.text_box = None
- self.idle_text_box = None
+ self.console = None
def omnygui_idle(self):
self.omnygui_show_gui()
- if self.idle_text_box is None:
+ if self._omnygui_is_missing("idle_text_box"):
self.omnygui_remove_all_docks()
- self.idle_text_box = self.gui_window.add_dock(name="idle_text").add_widget("TextBox")
- try:
- self.gui_window.remove_dock(name="default_figure")
- except:
- pass
+ self.idle_text_box = self.gui.omny.new("TextBox")
text = (
""
+ "██████╗ ███████╗ ██████╗ ██████╗ ███╗ ███╗███╗ ██╗██╗ ██╗\n"
@@ -98,79 +210,50 @@ class OMNYGuiTools:
)
self.idle_text_box.set_html_text(text)
- def _omnycam_clear(self):
- self.omnygui_remove_all_docks()
-
- def _omnycam_check_device_exists(self, device):
- try:
- device
- except:
- return False
- else:
- return True
-
- def _omnycam_samplestage(self):
- self.omnygui_show_omnycam_samplestage()
-
- def omnygui_show_omnycam_samplestage(self):
- self.omnygui_show_gui()
- if self.fig201 is None:
- self.omnygui_remove_all_docks()
- self.fig201 = self.gui_window.add_dock(name="omnycam201").add_widget("BECImageWidget")
- if self._omnycam_check_device_exists(dev.cam201):
- fig = self.fig201.image("cam201")
- fig.set_rotation(deg_90=3)
- self.fig201.lock_aspect_ratio(True)
- else:
- print("Cannot open cam201. Device does not exist.")
- self.fig202 = self.gui_window.add_dock(name="omnycam202").add_widget("BECImageWidget")
- if self._omnycam_check_device_exists(dev.cam202):
- fig = self.fig202.image("cam202")
- fig.set_rotation(deg_90=3)
- self.fig202.lock_aspect_ratio(True)
- else:
- print("Cannot open cam202. Device does not exist.")
- try:
- self.gui_window.remove_dock(name="default_figure")
- except:
- pass
-
def omnygui_show_progress(self):
- self.omnygui_show_gui()
- if self.progressbar is None:
- self.omnygui_remove_all_docks()
- # Add a new dock with a RingProgressBar widget
- self.progressbar = self.gui_window.add_dock(name="progress").add_widget(
- "RingProgressBar"
- )
- # Customize the size of the progress ring
- self.progressbar.set_line_widths(20)
- # Disable automatic updates and manually set the self.progressbar value
- self.progressbar.enable_auto_updates(False)
- # Set precision for the self.progressbar display
- self.progressbar.set_precision(1) # Display self.progressbar with one decimal places
- # Setting multiple rigns with different values
- self.progressbar.set_number_of_bars(3)
- self.progressbar.rings[2].set_update("scan")
- # Set the values of the rings to 50, 75, and 25 from outer to inner ring
- # self.progressbar.set_value([50, 75])
- # Add a new dock with a TextBox widget
- self.text_box = self.gui_window.add_dock(name="progress_text").add_widget("TextBox")
- try:
- self.gui_window.remove_dock(name="default_figure")
- except:
- pass
- self._omnygui_update_progress()
+ try:
+ self.omnygui_show_gui()
+ if self._omnygui_is_missing("progressbar"):
+ self.omnygui_remove_all_docks()
+ self.progressbar = self.gui.omny.new("RingProgressBar")
+ # Two manually-updated rings: overall tomogram progress and
+ # subtomo progress. Unlike flomni's third, auto-updating
+ # "scan"-linked ring, and unlike flomni's ETA/heartbeat/
+ # active-hook text, omny's progress dict doesn't carry that
+ # data yet (see omny/AI_docs/OPEN_ISSUES.md) -- kept simple
+ # here on purpose, revisit once that's ported.
+ self.progressbar.add_ring().set_update("manual")
+ self.progressbar.add_ring().set_update("manual")
+ self._omnygui_update_progress()
+ except Exception as e:
+ logger.warning(f"Error in omnygui_show_progress: {e}")
def _omnygui_update_progress(self):
- if self.progressbar is not None:
+ try:
+ if self.progressbar is None:
+ logger.warning("OMNY progress GUI update skipped: progressbar is not available.")
+ return
+ main_progress_ring = self.progressbar.rings[0]
+ subtomo_progress_ring = self.progressbar.rings[1]
progress = self.progress["projection"] / self.progress["total_projections"] * 100
- subotmo_progress = (
+ subtomo_progress = (
self.progress["subtomo_projection"]
/ self.progress["subtomo_total_projections"]
* 100
)
- self.progressbar.set_value([progress, subotmo_progress])
+ main_progress_ring.set_value(progress)
+ subtomo_progress_ring.set_value(subtomo_progress)
- text = f"Progress report:\n Tomo type: ....................... {self.progress['tomo_type']}\n Projection: ...................... {self.progress['projection']:.0f}\n Total projections expected ....... {self.progress['total_projections']}\n Angle: ........................... {self.progress['angle']}\n Current subtomo: ................. {self.progress['subtomo']}\n Current projection within subtomo: {self.progress['subtomo_projection']}\n Total projections per subtomo: ... {self.progress['subtomo_total_projections']}"
- self.text_box.set_plain_text(text)
+ text = (
+ f"Progress report:\n"
+ f" Tomo type: {self.progress['tomo_type']}\n"
+ f" Projection: {self.progress['projection']:.0f}\n"
+ f" Total projections expected {self.progress['total_projections']:.1f}\n"
+ f" Angle: {self.progress['angle']:.1f}\n"
+ f" Current subtomo: {self.progress['subtomo']}\n"
+ f" Current projection within subtomo: {self.progress['subtomo_projection']}\n"
+ f" Total projections per subtomo: {int(self.progress['subtomo_total_projections'])}"
+ )
+ self.progressbar.set_center_label(text)
+ except Exception as exc:
+ logger.warning(f"OMNY progress GUI update skipped: {exc}")
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index b8530a5b..b0df048f 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -596,7 +596,8 @@ class OMNY(
self.OMNYTools = OMNYTools(self.client)
OMNY_rt_client.__init__(self)
self.align = XrayEyeAlign(self.client, self)
- OMNYGuiTools.__init__(self, self.client)
+ OMNYGuiTools.__init__(self)
+ self.set_client(self.client)
def start_x_ray_eye_alignment(self):
if self.OMNYTools.yesno(
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py
index 868d5c6f..55a1ee29 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py
@@ -79,7 +79,7 @@ class XrayEyeAlign:
# ------------------------------------------------------------------
def _reset_init_values(self):
- self.shift_xy = [0.0, 0.0] # base shift to bring sample to beam centre [µm]
+ self.shift_xy = [0.0, 0.0] # base shift to bring sample to beam centre [µm]
self._xray_fov_xy = [0.0, 0.0]
def tomo_rotate(self, val: float):
@@ -161,7 +161,7 @@ class XrayEyeAlign:
Compute x/y offsets, push to GUI fit tab, wait for DAP fit,
then load fit parameters into the global variable store.
"""
- self.lamni.lamnigui_show_xeyealign()
+ self.lamni.omnygui_show_xeyealign()
self.send_message("Getting things ready. Please wait...")
self.gui.enable_submit_button(False)
@@ -188,14 +188,8 @@ class XrayEyeAlign:
k = 0
while True:
if dev.omny_xray_gui.submit.get() == 1:
- val_x = (
- getattr(dev.omny_xray_gui, f"xval_x_{k}").get()
- * self.PIXEL_CALIBRATION
- )
- val_y = (
- getattr(dev.omny_xray_gui, f"yval_y_{k}").get()
- * self.PIXEL_CALIBRATION
- )
+ val_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.PIXEL_CALIBRATION
+ val_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() * self.PIXEL_CALIBRATION
self.alignment_values[k] = [val_x, val_y]
print(
f"Clicked position {k}: "
@@ -242,15 +236,11 @@ class XrayEyeAlign:
self.gui.enable_submit_button(False)
self.scans.lamni_move_to_scan_center(
- self.shift_xy[0] / 1000,
- self.shift_xy[1] / 1000,
- self.get_tomo_angle(),
+ self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
).wait()
time.sleep(1)
self.scans.lamni_move_to_scan_center(
- self.shift_xy[0] / 1000,
- self.shift_xy[1] / 1000,
- self.get_tomo_angle(),
+ self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
).wait()
dev.omny_xray_gui.angle.set(self.get_tomo_angle())
@@ -274,16 +264,12 @@ class XrayEyeAlign:
self._disable_rt_feedback()
self.tomo_rotate(approach_angle)
self.scans.lamni_move_to_scan_center(
- self.shift_xy[0] / 1000,
- self.shift_xy[1] / 1000,
- self.get_tomo_angle(),
+ self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
).wait()
self._disable_rt_feedback()
self.tomo_rotate(target_angle)
self.scans.lamni_move_to_scan_center(
- self.shift_xy[0] / 1000,
- self.shift_xy[1] / 1000,
- self.get_tomo_angle(),
+ self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
).wait()
dev.omny_xray_gui.angle.set(self.get_tomo_angle())
@@ -317,9 +303,7 @@ class XrayEyeAlign:
self.shift_xy[0] += _mvx
self.shift_xy[1] += _mvy
self.scans.lamni_move_to_scan_center(
- self.shift_xy[0] / 1000,
- self.shift_xy[1] / 1000,
- self.get_tomo_angle(),
+ self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
).wait()
print(
f"Current centre: "
@@ -350,7 +334,7 @@ class XrayEyeAlign:
self.client.set_global_var("tomo_fov_offset", self.shift_xy)
# Switch GUI to fit tab and wait for DAP to finish fitting
- self.lamni.lamnigui_show_xeyealign_fittab()
+ self.lamni.omnygui_show_xeyealign_fittab()
print("Waiting 5 s for DAP sinusoidal fit to converge...")
time.sleep(5)
@@ -399,12 +383,8 @@ class XrayEyeAlign:
f.write("angle\thorizontal\tvertical\n")
for k in range(1, 9):
angle_deg = LAMNI_ALIGNMENT_ANGLES[k - 1]
- x_off = (
- self.alignment_values[1][0] - self.alignment_values[k][0]
- ) * 1000
- y_off = (
- self.alignment_values[k][1] - self.alignment_values[1][1]
- ) * 1000
+ x_off = (self.alignment_values[1][0] - self.alignment_values[k][0]) * 1000
+ y_off = (self.alignment_values[k][1] - self.alignment_values[1][1]) * 1000
f.write(f"{angle_deg}\t{x_off:.4f}\t{y_off:.4f}\n")
print(
f" Angle {angle_deg:3d} deg: "
@@ -428,4 +408,4 @@ class XrayEyeAlign:
# Push to XRayEye widget: feeds waveform_x (row 1) and waveform_y (row 2)
self.gui.submit_fit_array(data)
- print(f"Fit data submitted with shape {data.shape}:\n{data}")
\ No newline at end of file
+ print(f"Fit data submitted with shape {data.shape}:\n{data}")
diff --git a/tests/tests_bec_ipython_client/test_omny_gui_tools.py b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
new file mode 100644
index 00000000..4786e180
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
@@ -0,0 +1,106 @@
+"""Unit tests for OMNYGuiTools, ported to flomni's current set_client()/
+_is_deleted()-guard pattern. Focused on the pure-logic pieces that don't
+require a real bec_widgets/Qt GUI: the dock-reuse guard and the
+cam_xeye existence guard in omnygui_show_xeyealign() (omny's device config
+has no cam_xeye device, unlike flomni's)."""
+
+from unittest import mock
+
+from csaxs_bec.bec_ipython_client.plugins.omny.gui_tools import OMNYGuiTools
+
+# pylint: disable=protected-access
+
+GUI_TOOLS = "csaxs_bec.bec_ipython_client.plugins.omny.gui_tools"
+
+
+def _make_gui_tools():
+ tools = OMNYGuiTools()
+ client = mock.MagicMock()
+ tools.set_client(client)
+ return tools, client
+
+
+def test_omnygui_is_missing_true_when_never_set():
+ tools, _ = _make_gui_tools()
+ assert tools._omnygui_is_missing("xeyegui") is True
+
+
+def test_omnygui_is_missing_true_when_deleted():
+ tools, _ = _make_gui_tools()
+ widget = mock.MagicMock()
+ widget._is_deleted.return_value = True
+ tools.xeyegui = widget
+ assert tools._omnygui_is_missing("xeyegui") is True
+
+
+def test_omnygui_is_missing_false_for_live_widget():
+ tools, _ = _make_gui_tools()
+ widget = mock.MagicMock()
+ widget._is_deleted.return_value = False
+ tools.xeyegui = widget
+ assert tools._omnygui_is_missing("xeyegui") is False
+
+
+def test_omnygui_is_missing_false_when_no_is_deleted_method():
+ tools, _ = _make_gui_tools()
+ tools.xeyegui = object()
+ assert tools._omnygui_is_missing("xeyegui") is False
+
+
+def test_omnygui_show_xeyealign_skips_cam_xeye_when_not_configured():
+ """omny has no cam_xeye device configured -- must not raise when dev
+ lacks it, unlike flomni's unconditional live_mode_enabled toggle."""
+ tools, client = _make_gui_tools()
+ client.gui.windows = {"omny": mock.MagicMock()}
+ new_widget = mock.MagicMock()
+ client.gui.omny.new.return_value = new_widget
+
+ dev_mock = mock.MagicMock()
+ dev_mock.__contains__ = mock.Mock(return_value=False)
+
+ with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True):
+ tools.omnygui_show_xeyealign()
+
+ new_widget.switch_tab.assert_called_once_with("alignment")
+
+
+def test_omnygui_show_xeyealign_enables_live_mode_when_cam_xeye_present():
+ tools, client = _make_gui_tools()
+ client.gui.windows = {"omny": mock.MagicMock()}
+ new_widget = mock.MagicMock()
+ client.gui.omny.new.return_value = new_widget
+
+ dev_mock = mock.MagicMock()
+ dev_mock.__contains__ = mock.Mock(return_value=True)
+ dev_mock.cam_xeye.live_mode_enabled.get.return_value = False
+
+ with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True):
+ tools.omnygui_show_xeyealign()
+
+ dev_mock.cam_xeye.live_mode_enabled.put.assert_called_once_with(True)
+ new_widget.switch_tab.assert_called_once_with("alignment")
+
+
+def test_omnygui_show_gui_reuses_existing_window():
+ tools, client = _make_gui_tools()
+ existing_window = mock.MagicMock()
+ client.gui.windows = {"omny": existing_window}
+
+ tools.omnygui_show_gui()
+
+ client.gui.new.assert_not_called()
+ client.gui.omny.raise_window.assert_called_once()
+ assert tools.omny_window is existing_window
+
+
+def test_omnygui_show_gui_creates_new_window_when_absent():
+ tools, client = _make_gui_tools()
+ client.gui.windows = {}
+ new_window = mock.MagicMock()
+ client.gui.new.return_value = new_window
+
+ with mock.patch(f"{GUI_TOOLS}.time.sleep"):
+ tools.omnygui_show_gui()
+
+ client.gui.new.assert_called_once()
+ assert tools.omny_window is new_window
From c802104f6da10ce095103e0db7f0af58c4ed8350 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 1 Sep 2026 22:19:46 +0200
Subject: [PATCH 015/102] fix(omny): fix stale widget-API bugs in gui_tools.py
cameras/xray-eye, add cam_xeye
Live testing of the previous gui_tools.py port (49ade7c) hit two further crashes:
1. omnygui_show_xeyealign() -> ValueError: Device 'cam_xeye' not found in current
BEC session. The OMNY_XRayEye widget (shared verbatim with flomni) hardcodes
CAMERA = ("cam_xeye", "image") as a module constant -- it isn't configurable
per-beamline, so omny needs its own cam_xeye device or the widget can never be
constructed. Added cam_xeye to simulated_omny.yaml (hand-added, duplicating
flomni's real cam_xeye's camera_id/pixel_calibration as placeholders; SimIDSCamera
already accepts both camera_id and the legacy camera_ID key omny's other cameras
use). ptycho_omny.yaml intentionally left untouched -- the real hardware camera ID
and pixel calibration for OMNY's x-ray-eye camera are unknown; noted as a followup
in OPEN_ISSUES.md, including that a future regen of simulated_omny.yaml from
ptycho_omny.yaml will drop this hand-added block until the real config has one too.
2. omnygui_show_omnycam_parking() -> ValueError: Unknown widget type: BECImageWidget.
That string was carried over unmodified from omny's pre-modernization code, not
from flomni's current gui_tools.py (whose current class name is "Image"). Beyond
the widget name, the per-camera calls inside were also stale: fig.set_rotation(
deg_90=3) doesn't exist on the current Image/ImageBase widget at all (replaced by
the num_rotation_90 property), and self.figN.lock_aspect_ratio(True) called a
property as a function. Ported omnygui_show_omnycam_parking()/
omnygui_show_omnycam_samplestage() onto flomni's current per-camera pattern
(image(device=, signal="preview"), num_rotation_90/lock_aspect_ratio as
properties, start_live_mode()), preserving the existing 3x90 rotation for
cam200/cam203 (physical mount) and no rotation for cam201/cam202, unchanged from
before.
Also removed omnygui_show_cameras() (added last pass, modeled on flomni's single
generic camera view): redundant now that both existing parking/samplestage views got
the current API, since omny -- unlike flomni -- already has two purpose-built camera
views. Its hard-stop console (z_ConsoleButtonsWidget, targeting otransy) now lives
directly in both omnygui_show_omnycam_parking() and omnygui_show_omnycam_samplestage()
instead.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 20 ++++
.../plugins/omny/gui_tools.py | 103 ++++++++----------
.../simulated_omny/simulated_omny.yaml | 24 ++++
.../test_omny_gui_tools.py | 59 ++++++++++
4 files changed, 149 insertions(+), 57 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 6dd51135..1f043c8d 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -119,6 +119,26 @@ just those. Mirko's call: keep it simple for this pass, revisit later. Not a cra
`omny_webpage_generator.py` already reads the ETA-ish fields defensively via `.get()`, degrading
to "N/A" when absent, and that file is unwired/dead code anyway (see below).
+## cam_xeye device (simulated only, real hardware config still needed)
+
+`omnygui_show_xeyealign()` uses the shared `OMNY_XRayEye` widget
+(`csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`), which hardcodes `CAMERA = ("cam_xeye",
+"image")` as a module-level constant -- the device name isn't configurable per-beamline. omny's
+config had no `cam_xeye` device at all, so simply calling `omnygui_show_xeyealign()` failed with
+`ValueError: Device 'cam_xeye' not found in current BEC session` as soon as the widget was
+constructed. Added a `cam_xeye` entry to `simulated_omny.yaml` (hand-added, not generated --
+`ptycho_omny.yaml`, the generator's source, has no such device), reusing flomni's real `cam_xeye`'s
+`camera_id`/`pixel_calibration` as placeholders (`SimIDSCamera` accepts both `camera_id` and the
+legacy `camera_ID` key omny's other sim cameras use). **Two follow-ups still needed before real
+hardware use:**
+- `ptycho_omny.yaml` needs a real `cam_xeye` entry with OMNY's actual x-ray-eye camera hardware
+ `camera_id` and measured `pixel_calibration` (mm/pixel) -- unknown to me, needs Mirko.
+- `simulated_omny.yaml` is regenerated by `bin/generate_simulated_configs.py` from
+ `ptycho_omny.yaml`; since `cam_xeye` isn't in the real config yet, a regen will drop this
+ hand-added block until the real config also has one (matches the existing pattern for
+ `sim_velocity`/`sim_initial_position` hand-additions elsewhere in this file, already present
+ before this session).
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
index d2a41535..037a1eff 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
@@ -98,53 +98,6 @@ class OMNYGuiTools:
self.omnygui_show_omnycam_parking()
def omnygui_show_omnycam_parking(self):
- self.omnygui_show_gui()
- if self._omnygui_is_missing("fig200") or self._omnygui_is_missing("fig203"):
- self.omnygui_remove_all_docks()
- self.fig200 = self.gui.omny.new("BECImageWidget")
- if self._omnycam_check_device_exists(dev.cam200):
- fig = self.fig200.image("cam200")
- fig.set_rotation(deg_90=3)
- self.fig200.lock_aspect_ratio(True)
- else:
- print("Cannot open cam200. Device does not exist.")
- self.fig203 = self.gui.omny.new("BECImageWidget")
- if self._omnycam_check_device_exists(dev.cam203):
- fig = self.fig203.image("cam203")
- fig.set_rotation(deg_90=3)
- self.fig203.lock_aspect_ratio(True)
- else:
- print("Cannot open cam203. Device does not exist.")
-
- def _omnycam_samplestage(self):
- self.omnygui_show_omnycam_samplestage()
-
- def omnygui_show_omnycam_samplestage(self):
- self.omnygui_show_gui()
- if self._omnygui_is_missing("fig201") or self._omnygui_is_missing("fig202"):
- self.omnygui_remove_all_docks()
- self.fig201 = self.gui.omny.new("BECImageWidget")
- if self._omnycam_check_device_exists(dev.cam201):
- fig = self.fig201.image("cam201")
- fig.set_rotation(deg_90=3)
- self.fig201.lock_aspect_ratio(True)
- else:
- print("Cannot open cam201. Device does not exist.")
- self.fig202 = self.gui.omny.new("BECImageWidget")
- if self._omnycam_check_device_exists(dev.cam202):
- fig = self.fig202.image("cam202")
- fig.set_rotation(deg_90=3)
- self.fig202.lock_aspect_ratio(True)
- else:
- print("Cannot open cam202. Device does not exist.")
-
- def omnygui_show_cameras(self):
- """General camera view + hard-stop console, the omny analog of
- flomni's flomnigui_show_cameras(). Distinct from
- omnygui_show_omnycam_parking()/omnygui_show_omnycam_samplestage()
- above, which are omny's own pre-existing, narrower parking/
- samplestage-specific views -- left untouched.
- """
self.omnygui_show_gui()
if (
self._omnygui_is_missing("fig200")
@@ -152,25 +105,61 @@ class OMNYGuiTools:
or self._omnygui_is_missing("console")
):
self.omnygui_remove_all_docks()
- self.fig200 = self.gui.omny.new("BECImageWidget")
+ self.fig200 = self.gui.omny.new("Image")
if self._omnycam_check_device_exists(dev.cam200):
- fig = self.fig200.image("cam200")
- fig.set_rotation(deg_90=3)
- self.fig200.lock_aspect_ratio(True)
+ self.fig200.image(device="cam200", signal="preview")
+ self.fig200.num_rotation_90 = 3
+ self.fig200.lock_aspect_ratio = True
+ dev.cam200.start_live_mode()
else:
print("Cannot open cam200. Device does not exist.")
- self.fig203 = self.gui.omny.new("BECImageWidget")
+ self.fig203 = self.gui.omny.new("Image")
if self._omnycam_check_device_exists(dev.cam203):
- fig = self.fig203.image("cam203")
- fig.set_rotation(deg_90=3)
- self.fig203.lock_aspect_ratio(True)
+ self.fig203.image(device="cam203", signal="preview")
+ self.fig203.num_rotation_90 = 3
+ self.fig203.lock_aspect_ratio = True
+ dev.cam203.start_live_mode()
else:
print("Cannot open cam203. Device does not exist.")
# Hard-stop console, docked below the cameras. otransy is the
# closest omny analog to flomni's ftransy (both the transfer-Y
- # axis) -- no secondary/extra stop device wired yet (Mirko's
- # call, revisit once a second candidate is confirmed).
+ # axis) -- no secondary/extra stop device wired yet.
+ self.console = self.gui.omny.new(
+ "z_ConsoleButtonsWidget",
+ object_name="console",
+ where="bottom",
+ hard_stop_device_name="otransy",
+ hard_stop_label="OMNY Motion Stop",
+ )
+ self.gui.omny.set_layout_ratios(vertical=[5, 1])
+
+ def _omnycam_samplestage(self):
+ self.omnygui_show_omnycam_samplestage()
+
+ def omnygui_show_omnycam_samplestage(self):
+ self.omnygui_show_gui()
+ if (
+ self._omnygui_is_missing("fig201")
+ or self._omnygui_is_missing("fig202")
+ or self._omnygui_is_missing("console")
+ ):
+ self.omnygui_remove_all_docks()
+ self.fig201 = self.gui.omny.new("Image")
+ if self._omnycam_check_device_exists(dev.cam201):
+ self.fig201.image(device="cam201", signal="preview")
+ self.fig201.lock_aspect_ratio = True
+ dev.cam201.start_live_mode()
+ else:
+ print("Cannot open cam201. Device does not exist.")
+ self.fig202 = self.gui.omny.new("Image")
+ if self._omnycam_check_device_exists(dev.cam202):
+ self.fig202.image(device="cam202", signal="preview")
+ self.fig202.lock_aspect_ratio = True
+ dev.cam202.start_live_mode()
+ else:
+ print("Cannot open cam202. Device does not exist.")
+
self.console = self.gui.omny.new(
"z_ConsoleButtonsWidget",
object_name="console",
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
index ab2abaa7..46925f66 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
@@ -60,6 +60,30 @@ cam203:
readoutPriority: async
deviceTags:
- simulated_omny
+# Hand-added (not part of the generated ptycho_omny.yaml source -- OMNY has no cam_xeye
+# entry in the real config yet). The OMNY_XRayEye widget hardcodes the device name
+# "cam_xeye", so omnygui_show_xeyealign() needs one to exist even in simulation.
+# camera_id/pixel_calibration are placeholders duplicated from flomni's real cam_xeye
+# (simulated_flomni.yaml); see omny/AI_docs/OPEN_ISSUES.md for the real-hardware follow-up.
+cam_xeye:
+ description: Camera OMNY Xray eye (simulated, placeholder)
+ deviceClass: csaxs_bec.devices.sim.sim_cameras.SimIDSCamera
+ deviceConfig:
+ camera_id: 11
+ bits_per_pixel: 24
+ num_rotation_90: 0
+ transpose: true
+ force_monochrome: true
+ m_n_colormode: 1
+ sim_noise_std: 0
+ enabled: true
+ onFailure: buffer
+ readOnly: false
+ readoutPriority: async
+ userParameter:
+ pixel_calibration: 0.00044247787610619477 # mm/pixel, placeholder (flomni's value)
+ deviceTags:
+ - simulated_omny
rtx:
description: OMNY rt
deviceClass: csaxs_bec.devices.sim.sim_omny.SimRtOMNYMotor
diff --git a/tests/tests_bec_ipython_client/test_omny_gui_tools.py b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
index 4786e180..5e02edc7 100644
--- a/tests/tests_bec_ipython_client/test_omny_gui_tools.py
+++ b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
@@ -104,3 +104,62 @@ def test_omnygui_show_gui_creates_new_window_when_absent():
client.gui.new.assert_called_once()
assert tools.omny_window is new_window
+
+
+def test_omnygui_show_cameras_was_removed():
+ """omnygui_show_cameras() duplicated omnygui_show_omnycam_parking() (both just
+ showed cam200/cam203) and was dropped -- the console now lives directly in the
+ parking/samplestage views instead."""
+ assert not hasattr(OMNYGuiTools, "omnygui_show_cameras")
+
+
+def test_omnygui_show_omnycam_parking_uses_current_image_widget_api():
+ tools, client = _make_gui_tools()
+ client.gui.windows = {"omny": mock.MagicMock()}
+ fig200 = mock.MagicMock()
+ fig203 = mock.MagicMock()
+ console = mock.MagicMock()
+ client.gui.omny.new.side_effect = [fig200, fig203, console]
+
+ dev_mock = mock.MagicMock()
+
+ with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True):
+ tools.omnygui_show_omnycam_parking()
+
+ client.gui.omny.new.assert_any_call("Image")
+ fig200.image.assert_called_once_with(device="cam200", signal="preview")
+ assert fig200.num_rotation_90 == 3
+ assert fig200.lock_aspect_ratio is True
+ dev_mock.cam200.start_live_mode.assert_called_once()
+ fig203.image.assert_called_once_with(device="cam203", signal="preview")
+ dev_mock.cam203.start_live_mode.assert_called_once()
+
+ client.gui.omny.new.assert_any_call(
+ "z_ConsoleButtonsWidget",
+ object_name="console",
+ where="bottom",
+ hard_stop_device_name="otransy",
+ hard_stop_label="OMNY Motion Stop",
+ )
+ assert tools.console is console
+
+
+def test_omnygui_show_omnycam_samplestage_uses_current_image_widget_api():
+ tools, client = _make_gui_tools()
+ client.gui.windows = {"omny": mock.MagicMock()}
+ fig201 = mock.MagicMock()
+ fig202 = mock.MagicMock()
+ console = mock.MagicMock()
+ client.gui.omny.new.side_effect = [fig201, fig202, console]
+
+ dev_mock = mock.MagicMock()
+
+ with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True):
+ tools.omnygui_show_omnycam_samplestage()
+
+ fig201.image.assert_called_once_with(device="cam201", signal="preview")
+ assert fig201.lock_aspect_ratio is True
+ dev_mock.cam201.start_live_mode.assert_called_once()
+ fig202.image.assert_called_once_with(device="cam202", signal="preview")
+ dev_mock.cam202.start_live_mode.assert_called_once()
+ assert tools.console is console
From c37bfc76d62286a3b47d6895e70c2bc8a1db6f1f Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 03:46:52 +0200
Subject: [PATCH 016/102] fix(omny): set cam200-203/cam_xeye readoutPriority to
on_request, fixes scan crash
omny.tomo_scan_projection(1) -> omny_fermat_scan crashed with ValueError: 'cam200'
is not in list once a scan actually did a readback-tracked osamroy rotation (the
first time this branch's testing got that far).
Root cause, traced through bec_server + bec_ipython_client core code: omny_fermat_scan's
add_scan_report_instruction_readback(devices=["osamroy"]) registers osamroy as requiring
a response; post_scan()'s complete_all_devices() then batches every enabled+claimable-owned
device into one end-of-scan instruction, including cam200 (readoutPriority: async +
enabled: true default to ownership_mode: "claimable", auto-acquired for any scan unless
readoutPriority is "on_request"). The batched instruction's response flag isn't scoped
per-device, so the server publishes a device-request-status message for cam200 too, under
the same RID the osamroy-only readback progressbar is listening on -- which then crashes
on an unrecognized device name.
cam200/201/202/203 (and cam_xeye, added last pass) are view-only GUI cameras never
referenced by any scan code (confirmed via repo-wide grep). Changing their readoutPriority
to on_request excludes them from the claimable/owned auto-acquire set entirely, fixing the
crash with a config-only change -- and matches flomni's own convention for this same
category of device (cam_flomni_gripper/cam_flomni_overview are both on_request already).
Live-mode GUI streaming (start_live_mode(), used by the parking/samplestage camera views)
is unaffected since it's a dedicated background thread, not driven by readout-priority
scheduling -- flomni's own cameras already prove on_request + live-mode works together.
Applied to both simulated_omny.yaml and the real ptycho_omny.yaml per Mirko: this brings
both configs in line with flomni's convention, not just a sim-only workaround.
The underlying bug (instruction-batch-scoped response flag in scan_actions.py:_send(),
plus missing per-device filtering in ReadbackDataHandler/DeviceProgressBar) is a genuine
bec-core issue, not omny-specific -- flomni's own cam_xeye has the same latent exposure,
untouched here since it's out of this branch's scope. Recorded in omny/AI_docs/OPEN_ISSUES.md
and reported upstream separately.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 37 +++++++++++++++++++
csaxs_bec/device_configs/ptycho_omny.yaml | 8 ++--
.../simulated_omny/simulated_omny.yaml | 10 ++---
3 files changed, 46 insertions(+), 9 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 1f043c8d..7e5b3243 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -139,6 +139,43 @@ hardware use:**
`sim_velocity`/`sim_initial_position` hand-additions elsewhere in this file, already present
before this session).
+## cam200-203 readoutPriority: async caused a scan crash (fixed, bec-core bug found along the way)
+
+`omny.tomo_scan_projection(1)` -> `omny_fermat_scan` crashed with `ValueError: 'cam200' is not in
+list` in `bec_ipython_client`'s `DeviceProgressBar.set_finished()`, once a scan actually did a
+readback-tracked osamroy rotation (the first time this branch got that far). Root cause, traced
+through bec_server + bec_ipython_client core code:
+
+- `omny_fermat_scan.py`'s `add_scan_report_instruction_readback(devices=["osamroy"], ...)`
+ registers `osamroy` as requiring a response.
+- `post_scan()`'s `complete_all_devices()` batches **every enabled+claimable-owned device** into
+ one instruction -- including `cam200`, because `readoutPriority: async` + `enabled: true` default
+ to `ownership_mode: "claimable"`, and claimable async-priority devices are auto-acquired for any
+ scan **unless** their `readoutPriority` is `"on_request"` (`bec_server/.../scan_actions.py:
+ 1148-1188`).
+- The batched instruction's `response=True` flag is set because *any* device in the batch needs a
+ response (`scan_actions.py:_send()`, not scoped per-device) -- so the server publishes a
+ `DeviceReqStatusMessage` for cam200 too, under the same RID the osamroy-only readback progressbar
+ is listening on.
+- `ReadbackDataHandler.on_req_status()` (`bec_ipython_client/.../move_device.py`) filters by RID
+ only, not by device, so it records cam200's status even though this progressbar only tracks
+ `["osamroy"]`, and `DeviceProgressBar.set_finished()` crashes on `self.devices.index("cam200")`.
+
+**Fixed** by changing `cam200`/`cam201`/`cam202`/`cam203` from `readoutPriority: async` to
+`on_request` in both `simulated_omny.yaml` and `ptycho_omny.yaml` (also applied to the `cam_xeye`
+added earlier this session) -- `on_request` is explicitly excluded from the claimable/owned
+auto-acquire set, and is also the semantically correct value for these view-only cameras (never
+referenced by any scan code), matching flomni's own `cam_flomni_gripper`/`cam_flomni_overview`
+convention. Live-mode GUI streaming (`start_live_mode()`) is unaffected -- it's a dedicated
+background thread, not driven by readout-priority scheduling.
+
+**Not fixed, out of scope here**: this is a genuine bec-core bug (steps 3-4 above -- an
+instruction-batch-scoped response flag combined with device-unfiltered status handling
+client-side), not something specific to omny. flomni's own `cam_xeye` (`ptycho_flomni.yaml`,
+`readoutPriority: async`, untouched by this session) has the exact same latent exposure and would
+hit the same crash the first time a flomni scan does an actual (non-"already at angle") rotation
+with cam_xeye enabled. Reported upstream as feedback; not something csaxs_bec can fix directly.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/device_configs/ptycho_omny.yaml b/csaxs_bec/device_configs/ptycho_omny.yaml
index 9580b7b1..d68d9aac 100755
--- a/csaxs_bec/device_configs/ptycho_omny.yaml
+++ b/csaxs_bec/device_configs/ptycho_omny.yaml
@@ -12,7 +12,7 @@ cam200:
enabled: true
onFailure: buffer
readOnly: false
- readoutPriority: async
+ readoutPriority: on_request
deviceTags:
- ptycho_omny
@@ -27,7 +27,7 @@ cam201:
enabled: true
onFailure: buffer
readOnly: false
- readoutPriority: async
+ readoutPriority: on_request
deviceTags:
- ptycho_omny
@@ -42,7 +42,7 @@ cam202:
enabled: true
onFailure: buffer
readOnly: false
- readoutPriority: async
+ readoutPriority: on_request
deviceTags:
- ptycho_omny
@@ -57,7 +57,7 @@ cam203:
enabled: true
onFailure: buffer
readOnly: false
- readoutPriority: async
+ readoutPriority: on_request
deviceTags:
- ptycho_omny
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
index 46925f66..199d010e 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
@@ -15,7 +15,7 @@ cam200:
enabled: true
onFailure: buffer
readOnly: false
- readoutPriority: async
+ readoutPriority: on_request
deviceTags:
- simulated_omny
cam201:
@@ -29,7 +29,7 @@ cam201:
enabled: true
onFailure: buffer
readOnly: false
- readoutPriority: async
+ readoutPriority: on_request
deviceTags:
- simulated_omny
cam202:
@@ -43,7 +43,7 @@ cam202:
enabled: true
onFailure: buffer
readOnly: false
- readoutPriority: async
+ readoutPriority: on_request
deviceTags:
- simulated_omny
cam203:
@@ -57,7 +57,7 @@ cam203:
enabled: true
onFailure: buffer
readOnly: false
- readoutPriority: async
+ readoutPriority: on_request
deviceTags:
- simulated_omny
# Hand-added (not part of the generated ptycho_omny.yaml source -- OMNY has no cam_xeye
@@ -79,7 +79,7 @@ cam_xeye:
enabled: true
onFailure: buffer
readOnly: false
- readoutPriority: async
+ readoutPriority: on_request
userParameter:
pixel_calibration: 0.00044247787610619477 # mm/pixel, placeholder (flomni's value)
deviceTags:
From bb5ff0cedf1d5ffa4e9d648d1b1e22460439c93a Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 04:28:50 +0200
Subject: [PATCH 017/102] fix(omny): rotate the root osamroy device, not its
user_setpoint signal
Live-reproduced (local BEC deployment, real client) that the readoutPriority
fix (c37bfc7) didn't actually fix the readback-progressbar crash -- it just
moved it to a different device (rt_positions instead of cam200), confirming
the earlier diagnosis was incomplete.
Real root cause: omny_rotation() called
self.actions.set(self.dev.osamroy.user_setpoint, angle, wait=False),
passing the user_setpoint Signal sub-component instead of the root device.
ScanActions._normalize_device_name (bec_server) normalizes a sub-signal to
its dotted name ("osamroy.user_setpoint"), which never matches the plain
"osamroy" registered as requiring a response by the preceding
add_scan_report_instruction_readback(devices=["osamroy"], ...) call. So the
rotation's own completion is never reported, and the readback progressbar's
request-status listener stays subscribed for the rest of the scan -- until
post_scan()'s complete_all_devices() batches many unrelated owned devices
into one instruction later, which DOES trigger a response (since the literal
"osamroy" is also in that batch), reporting every device in it and crashing
the still-alive progressbar on whichever one happens to complete first.
Confirmed live this is omny-specific, not a flomni-shared exposure as
previously assumed: flomni_fermat_scan's equivalent flomni_rotation() calls
self.actions.set(self.dev.fsamroy, angle, wait=False) -- the whole root
device -- so its own completion reports correctly and the progressbar exits
cleanly well before complete_all_devices() runs. A live flomni_fermat_scan
run with cam_xeye still async+enabled completed without incident, disproving
the earlier "flomni has the same latent exposure" assumption.
Fixed by matching flomni's pattern exactly. OMNYGalilMotor.move()
(ogalil_ophyd.py) already does self.user_setpoint.put(...) plus proper
completion bookkeeping internally, so setting the root device is a strict
superset of the previous behavior, not a functional change to the move
itself. The readoutPriority change from the previous commit is kept (still
correct, matches flomni's camera convention) even though it wasn't the
actual fix.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 75 +++++++++++--------
csaxs_bec/scans/omny_fermat_scan.py | 4 +-
tests/tests_scans/test_omny_fermat_scan.py | 11 ++-
3 files changed, 54 insertions(+), 36 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 7e5b3243..0a192188 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -139,42 +139,53 @@ hardware use:**
`sim_velocity`/`sim_initial_position` hand-additions elsewhere in this file, already present
before this session).
-## cam200-203 readoutPriority: async caused a scan crash (fixed, bec-core bug found along the way)
+## omny_fermat_scan rotation crashed on an arbitrary device (readback progressbar) (fixed)
-`omny.tomo_scan_projection(1)` -> `omny_fermat_scan` crashed with `ValueError: 'cam200' is not in
-list` in `bec_ipython_client`'s `DeviceProgressBar.set_finished()`, once a scan actually did a
-readback-tracked osamroy rotation (the first time this branch got that far). Root cause, traced
-through bec_server + bec_ipython_client core code:
+`omny.tomo_scan_projection(1)` -> `omny_fermat_scan` crashed with `ValueError: '' is
+not in list` in `bec_ipython_client`'s `DeviceProgressBar.set_finished()`, once a scan actually did
+a readback-tracked osamroy rotation. First seen as `cam200`; changing cam200-203/cam_xeye's
+`readoutPriority` to `on_request` (below) did **not** fix it -- it just changed which device the
+crash landed on (`rt_positions`, confirmed by live reproduction). The `readoutPriority` change is
+still correct/kept (matches flomni's convention for view-only cameras), but it was treating a
+symptom, not the actual bug.
-- `omny_fermat_scan.py`'s `add_scan_report_instruction_readback(devices=["osamroy"], ...)`
- registers `osamroy` as requiring a response.
-- `post_scan()`'s `complete_all_devices()` batches **every enabled+claimable-owned device** into
- one instruction -- including `cam200`, because `readoutPriority: async` + `enabled: true` default
- to `ownership_mode: "claimable"`, and claimable async-priority devices are auto-acquired for any
- scan **unless** their `readoutPriority` is `"on_request"` (`bec_server/.../scan_actions.py:
- 1148-1188`).
-- The batched instruction's `response=True` flag is set because *any* device in the batch needs a
- response (`scan_actions.py:_send()`, not scoped per-device) -- so the server publishes a
- `DeviceReqStatusMessage` for cam200 too, under the same RID the osamroy-only readback progressbar
- is listening on.
-- `ReadbackDataHandler.on_req_status()` (`bec_ipython_client/.../move_device.py`) filters by RID
- only, not by device, so it records cam200's status even though this progressbar only tracks
- `["osamroy"]`, and `DeviceProgressBar.set_finished()` crashes on `self.devices.index("cam200")`.
+**Real root cause, confirmed via live reproduction (local BEC deployment, not just static
+reading)**: `omny_fermat_scan.py`'s `omny_rotation()` called
+`self.actions.set(self.dev.osamroy.user_setpoint, angle, wait=False)` -- passing the
+`user_setpoint` **signal sub-component**, not the root device. `ScanActions._normalize_device_name`
+(`bec_server/.../scan_actions.py:1106-1107`) normalizes a `DeviceBase` to its **dotted name** for a
+sub-signal, i.e. `"osamroy.user_setpoint"`, while `add_scan_report_instruction_readback(devices=
+["osamroy"], ...)` (line above) registers the plain string `"osamroy"` as requiring a response.
+Since `{"osamroy.user_setpoint"} & {"osamroy"}` is empty, `_send()` never sets `response=True` on
+the rotation's own instruction -- the device server never reports `"osamroy"` done, so the readback
+progressbar's `ReadbackDataHandler` never sees its request finish and stays subscribed to that RID
+for the rest of the scan. Later, `post_scan()`'s `complete_all_devices()` batches many unrelated
+owned devices into one instruction; because the literal string `"osamroy"` *is* in that later batch
+and still needs a response, `response=True` fires on the **whole batch**, and the device server
+reports every device in it -- which the still-alive progressbar (only tracking `["osamroy"]`)
+receives and crashes on, for whichever device happens to report first (arbitrary -- hence `cam200`
+for Mirko, `rt_positions` in the live repro here).
-**Fixed** by changing `cam200`/`cam201`/`cam202`/`cam203` from `readoutPriority: async` to
-`on_request` in both `simulated_omny.yaml` and `ptycho_omny.yaml` (also applied to the `cam_xeye`
-added earlier this session) -- `on_request` is explicitly excluded from the claimable/owned
-auto-acquire set, and is also the semantically correct value for these view-only cameras (never
-referenced by any scan code), matching flomni's own `cam_flomni_gripper`/`cam_flomni_overview`
-convention. Live-mode GUI streaming (`start_live_mode()`) is unaffected -- it's a dedicated
-background thread, not driven by readout-priority scheduling.
+**Confirmed this is omny-specific, not a general/flomni-shared bug** (contrary to my earlier
+assumption): `flomni_fermat_scan.py`'s equivalent `flomni_rotation()` calls
+`self.actions.set(self.dev.fsamroy, angle, wait=False)` -- the whole root device, not a sub-signal
+-- so `_normalize_device_name` returns `"fsamroy"`, matching the required-response registration
+exactly. Live-reproduced: `flomni_fermat_scan` with `cam_xeye` still `readoutPriority: async` and
+enabled completed cleanly (no crash), because the rotation's own completion reports correctly and
+the progressbar exits well before `complete_all_devices()` ever runs.
-**Not fixed, out of scope here**: this is a genuine bec-core bug (steps 3-4 above -- an
-instruction-batch-scoped response flag combined with device-unfiltered status handling
-client-side), not something specific to omny. flomni's own `cam_xeye` (`ptycho_flomni.yaml`,
-`readoutPriority: async`, untouched by this session) has the exact same latent exposure and would
-hit the same crash the first time a flomni scan does an actual (non-"already at angle") rotation
-with cam_xeye enabled. Reported upstream as feedback; not something csaxs_bec can fix directly.
+**Fixed** in `omny_fermat_scan.py`'s `omny_rotation()`: `self.actions.set(self.dev.osamroy, angle,
+wait=False)`, matching flomni's pattern exactly. `OMNYGalilMotor.move()`
+(`csaxs_bec/devices/omny/galil/ogalil_ophyd.py`) already does `self.user_setpoint.put(...)` plus
+proper completion bookkeeping internally, so setting the root device is a strict superset of the
+previous behavior, not a functional change to the move itself.
+
+**Not fixed, out of scope, reported upstream as feedback (defense-in-depth, framework-level)**:
+`ReadbackDataHandler.on_req_status()` and `DeviceProgressBar.set_finished()`
+(`bec_ipython_client`) should guard against/ignore a device outside their tracked list rather than
+crash, and `scan_actions.py`'s `_send()` should scope the `response` flag per-device rather than
+per-batched-instruction -- any scan with this same signal-vs-root-device mismatch pattern would hit
+the same failure mode.
## Environment note (not code, but will bite again if forgotten)
diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py
index 0a40d64b..660bd588 100644
--- a/csaxs_bec/scans/omny_fermat_scan.py
+++ b/csaxs_bec/scans/omny_fermat_scan.py
@@ -391,9 +391,7 @@ class OmnyFermatScan(ScanBase):
self.actions.add_scan_report_instruction_readback(
devices=["osamroy"], start=[osamroy_current_setpoint], stop=[angle]
)
- self.omny_rotation_status = self.actions.set(
- self.dev.osamroy.user_setpoint, angle, wait=False
- )
+ self.omny_rotation_status = self.actions.set(self.dev.osamroy, angle, wait=False)
def prepare_setup_part2(self):
if self.omny_rotation_status is not None:
diff --git a/tests/tests_scans/test_omny_fermat_scan.py b/tests/tests_scans/test_omny_fermat_scan.py
index a7187b55..f403fcad 100644
--- a/tests/tests_scans/test_omny_fermat_scan.py
+++ b/tests/tests_scans/test_omny_fermat_scan.py
@@ -23,7 +23,16 @@ def test_omny_rotation_moves_when_setpoint_differs():
scan.actions.set.assert_called_once()
args, kwargs = scan.actions.set.call_args
- assert args[0] is scan.dev.osamroy.user_setpoint
+ # Must target the root osamroy device, not the user_setpoint sub-signal:
+ # add_scan_report_instruction_readback(devices=["osamroy"]) registers the
+ # plain device name as requiring a response, but self.actions.set() on a
+ # Signal normalizes to a dotted name ("osamroy.user_setpoint") that never
+ # matches "osamroy" -- the rotation's own completion is never reported,
+ # leaving the readback progressbar's request-status listener alive until
+ # complete_all_devices() batches many unrelated devices together later
+ # and crashes on an arbitrary one of them (confirmed live; see
+ # omny/AI_docs/OPEN_ISSUES.md).
+ assert args[0] is scan.dev.osamroy
assert args[1] == 10.0
assert kwargs.get("wait") is False
From 37d3197f0389181fc13b91c769e9337458cda159 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 07:45:45 +0200
Subject: [PATCH 018/102] docs(omny): note readoutPriority: on_request was kept
deliberately
Mirko confirmed keeping on_request (not reverting to the original async)
even though it turned out not to be what fixed the omny_fermat_scan crash
-- these cameras are only ever used for live GUI viewing, never read back
in scan code, so async's continuous per-scan logging was likely unused
anyway. Recorded so this doesn't read as leftover cruft later.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 0a192188..390c5689 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -187,6 +187,16 @@ crash, and `scan_actions.py`'s `_send()` should scope the `response` flag per-de
per-batched-instruction -- any scan with this same signal-vs-root-device mismatch pattern would hit
the same failure mode.
+**Note on the earlier `readoutPriority: on_request` change (commit `c37bfc7`)**: once this fix
+landed, that change was no longer load-bearing for this crash -- the rotation's own readback
+progressbar now closes out right after the move, so it's no longer still listening when
+`complete_all_devices()` broadcasts later, regardless of cam200-203/cam_xeye's readout priority.
+Asked Mirko whether to revert it back to `async` (original behavior, cameras' frames get
+continuously logged into every scan) now that it's not strictly needed; he chose to **keep
+`on_request`** -- these cameras are only ever used for live GUI viewing (`start_live_mode()`), never
+read back in any scan code, so continuous per-scan logging was likely never useful. Not reverted;
+this is a deliberate choice, not leftover cruft.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
From e0213697bcf9c0e2c02af4966b9808f8a2c1944e Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 12:15:42 +0200
Subject: [PATCH 019/102] fix(omny): quiet laser tracker check, drop redundant
call
laser_tracker_check_and_wait_for_signalstrength() used send_client_info()
for its "Checking laser tracker..."/"...completed." messages, which queues
onto the scan's client-message list -- exposing it to a bec_lib bug
(QueueItem.get_client_messages() mutates its list while iterating, so it
skips and later re-delivers leftover messages on an unrelated call; see
OPEN_ISSUES.md, reported upstream). flomni's equivalent,
laser_tracker_check_signalstrength(), has always used plain print()
instead, which never queues and so never hits that bug. Switched omny's
version to match exactly (Mirko's call).
Both check functions run once per RT axis set (via
Rt*SetpointSignal._socket_set()), so a single scan setup (rtx+rtz set
together) already triggered 2 checks; omny_fermat_scan.py's
prepare_setup_part2() had an additional explicit third call, pure
duplication of the same check. Removed it -- one less redundant
laser-tracker wait per scan setup, no loss of the actual safety check
(rtx/rtz are still individually checked via their own set() calls).
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 25 +++++++++++++++++
csaxs_bec/devices/omny/rt/rt_omny_ophyd.py | 28 +++++++++----------
csaxs_bec/scans/omny_fermat_scan.py | 6 ++--
3 files changed, 43 insertions(+), 16 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 390c5689..69553586 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -197,6 +197,31 @@ continuously logged into every scan) now that it's not strictly needed; he chose
read back in any scan code, so continuous per-scan logging was likely never useful. Not reverted;
this is a deliberate choice, not leftover cruft.
+## laser_tracker_check_and_wait_for_signalstrength() was chatty (fixed)
+
+Live testing of the first successful omny scan surfaced two odd things at once: a "Summary of
+client messages" printout Mirko had never seen, and client-info messages ("Checking laser
+tracker...") getting reprinted on a later, unrelated call (`tomo_parameters()`). Root cause of the
+*reprint*, live-diagnosed: a genuine `bec_lib` bug (`QueueItem.get_client_messages()` mutates its
+list while iterating -- reported upstream). But the reason it was even *noticeable* is
+omny-specific: `laser_tracker_check_and_wait_for_signalstrength()`
+(`csaxs_bec/devices/omny/rt/rt_omny_ophyd.py`) used `send_client_info()` for its
+"Checking..."/"...completed." pair, which queues onto the scan's client-message list (and is thus
+exposed to the `get_client_messages()` bug), whereas flomni's equivalent,
+`laser_tracker_check_signalstrength()` (`rt_flomni_ophyd.py`), has always used plain `print()`
+(server-side only, never queued, never exposed to that bug). Both mirror each other structurally
+in one other way too: `RtOMNYSetpointSignal._socket_set()`/`RtFlomniSetpointSignal._socket_set()`
+each run this check once per RT axis set -- so a single scan setup (rtx + rtz set together, plus,
+until this fix, an extra explicit call in `omny_fermat_scan.py`'s `prepare_setup_part2()`)
+triggered the check 2-3 times, each producing 2 messages on omny's chatty version vs 0 visible
+output on flomni's silent one.
+
+**Fixed**: switched `laser_tracker_check_and_wait_for_signalstrength()` to plain `print()`
+throughout (matching flomni exactly -- Mirko's explicit call), and removed the now-redundant
+explicit `laser_tracker_check_and_wait_for_signalstrength()` call in `prepare_setup_part2()`
+(pure duplication of the per-axis checks the rtx/rtz `set()` calls already trigger). Net effect:
+quieter output, one less redundant tracker-check wait per scan setup, same safety behavior.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py b/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
index ba69da8f..c9cfba05 100644
--- a/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
+++ b/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
@@ -268,9 +268,15 @@ class RtOMNYController(Controller):
return self.mirror_parameters[channel]
def laser_tracker_check_and_wait_for_signalstrength(self):
- self.device_manager.connector.send_client_info(
- "Checking laser tracker...", scope="", show_asap=True
- )
+ # Plain print(), not send_client_info(): this runs on every RT setpoint move
+ # (RtOMNYSetpointSignal._socket_set()), so send_client_info() queued a "Checking
+ # laser tracker..."/"...completed." pair per move into the scan's client-message
+ # queue -- multiple times per scan setup -- which is both noisy for the user and
+ # triggers a bec_lib get_client_messages() bug that reprints leftover queued
+ # messages on a later, unrelated call (see omny/AI_docs/OPEN_ISSUES.md). Mirrors
+ # flomni's laser_tracker_check_signalstrength() (rt_flomni_ophyd.py), which has
+ # always used plain print() for this.
+ print("Checking laser tracker...")
if not self.laser_tracker_check_enabled():
print(
"laser_tracker_check_and_wait_for_signalstrength: The laser tracker is not even enabled."
@@ -285,10 +291,8 @@ class RtOMNYController(Controller):
low_signal = rtx.user_parameter.get("low_signal")
wait_counter = 0
while signal < min_signal and wait_counter < 10:
- self.device_manager.connector.send_client_info(
- f"The signal of the tracker {signal} is below the minimum required signal of {min_signal}. Waiting...",
- scope="laser_tracker_check_and_wait_for_signalstrength",
- show_asap=True,
+ print(
+ f"The signal of the tracker {signal} is below the minimum required signal of {min_signal}. Waiting..."
)
wait_counter += 1
@@ -296,16 +300,12 @@ class RtOMNYController(Controller):
signal = self._omny_interferometer_get_signalsample("ssi_4", 0.1)
if signal < low_signal:
- self.device_manager.connector.send_client_info(
- f"\x1b[91mThe signal of the tracker {signal} is below the low limit of {low_signal}. Auto readjustment...\x1b[0m",
- scope="laser_tracker_check_and_wait_for_signalstrength",
- show_asap=True,
+ print(
+ f"\x1b[91mThe signal of the tracker {signal} is below the low limit of {low_signal}. Auto readjustment...\x1b[0m"
)
self.omny_interferometer_align_tracking()
- self.device_manager.connector.send_client_info(
- "Checking laser tracker completed.", scope="", show_asap=True
- )
+ print("Checking laser tracker completed.")
def omny_interferometer_align_tracking(self):
mirror_channel = 6
diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py
index 660bd588..11f84007 100644
--- a/csaxs_bec/scans/omny_fermat_scan.py
+++ b/csaxs_bec/scans/omny_fermat_scan.py
@@ -397,12 +397,14 @@ class OmnyFermatScan(ScanBase):
if self.omny_rotation_status is not None:
self.omny_rotation_status.wait()
+ # No separate laser_tracker_check_and_wait_for_signalstrength() call here: setting
+ # rtx/rtz below already runs it once per axis via
+ # RtOMNYSetpointSignal._socket_set() -- an explicit third call here was pure
+ # duplication (same check, same signal), not an extra safety margin.
rt_move_status = self.actions.set(
device=["rtx", "rtz"], value=[self.positions[0][0], self.positions[0][2]], wait=False
)
- self.dev.rtx.controller.laser_tracker_check_and_wait_for_signalstrength()
-
rt_move_status.wait()
self.dev.rtx.controller.add_pos_to_scan(self.positions.tolist())
From bd129b6313618168e30da520fb9f8429c632239b Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 12:37:01 +0200
Subject: [PATCH 020/102] feat(omny): add holler to tomo superuser accounts;
refresh OPEN_ISSUES.md
Mirko is testing live from his own machine as OS user "holler" with no
active_account set, so the superuser fallback (os.getenv("USER")) needs
"holler" in the allow-list to see the advanced tomo_parameters() options.
OPEN_ISSUES.md had gone stale relative to everything fixed this session:
the rotation-offset section still described it as an open, hardware-review
-only question (resolved days ago once the real DMC controller source
confirmed the real hardware code was always correct -- only the simulator
was missing the offset), and the checklist still said scans were blocked
entirely (a full omny_fermat_scan now runs end to end). Rewrote both
sections to reflect current reality, added a "Current status" summary at
the top so a fresh session can orient in one read, and noted the
_SUPERUSER_ACCOUNTS change and that advanced tomo mode testing is next.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 136 ++++++++++--------
.../bec_ipython_client/plugins/omny/omny.py | 9 +-
2 files changed, 85 insertions(+), 60 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 69553586..78b7fd81 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -1,11 +1,34 @@
-# OMNY open issues (as of 2026-08-26)
+# OMNY open issues (as of 2026-09-02)
-Tracks what the first OMNY live-smoke-test session found: bugs (fixed and unfixed) and
-what's still untested. Written so a future session can pick this up without re-deriving
-it. Companion docs: `../../flomni/AI_docs/SIMULATED_ENDSTATIONS.md` (the sim architecture
-and the fullest write-up of the rotation-offset bug below) and
-`../../flomni/AI_docs/FLOMNI_TO_LAMNI_COMPARISON.md` (the flomni→lamni migration this
-OMNY work is meant to mirror).
+Tracks the live-testing effort for bringing OMNY up to flomni's/BEC's current standards on
+branch `update-omny`: bugs found (fixed and unfixed) and what's still untested. Written so a
+future session can pick this up without re-deriving it. Companion docs:
+`../../flomni/AI_docs/SIMULATED_ENDSTATIONS.md` (the sim architecture) and
+`../../flomni/AI_docs/FLOMNI_TO_LAMNI_COMPARISON.md` (the flomni→lamni migration this OMNY work
+is meant to mirror).
+
+## Current status
+
+A full `omny_fermat_scan` (via `omny.tomo_scan_projection()`) now runs end to end against
+`simulated_omny.yaml` without crashing — rotation, feedback, and the scan itself all complete.
+Getting there took several rounds of live-tested fixes; see the dated sections below for the
+full trace of each. GUI flows (`omny.omnygui_show_progress()`, `omnygui_show_xeyealign()`,
+`omnygui_show_omnycam_parking()`/`omnygui_show_omnycam_samplestage()`) also work now, ported onto
+flomni's current `gui_tools.py` API.
+
+**Not yet started**: testing the advanced tomo modes (golden ratio, 4/8 sub-tomogram) added
+earlier this branch — this is the next thing being tested live. `holler` (Mirko's account) was
+added to `_SUPERUSER_ACCOUNTS` (`omny.py`) alongside `gac-x01dc` so `tomo_parameters()` shows the
+advanced options when testing from his own machine, where `bec.active_account` is unset and the
+superuser check falls back to the OS login.
+
+**Still open / deferred** (unchanged from before, not touched this pass):
+- `x_ray_eye_align.py` is still LamNI-derived, unadapted code beyond the two call-site renames
+ that unblocked the GUI-open crash — see its own section below. The GUI itself opens fine now;
+ the alignment *logic* underneath it is still wrong for OMNY.
+- The full gripper sample-transfer chain (`otransfer_put_sample()`/`otransfer_get_sample()`),
+ dewar/temperature methods, sample storage bookkeeping, and `omny_alignment_mixin.py`/
+ `omny_optics_mixin.py` beyond what scans exercise — see "Checklist" section below.
## Standing question: OMNY should follow flOMNI, not LamNI
@@ -18,41 +41,42 @@ like LamNI, and is a general lens to apply going forward: if fixing an OMNY bug
choosing what the "correct" shape looks like, default to whatever flOMNI already does,
not whatever LamNI does — a LamNI-shaped OMNY file is more likely a leftover copy-paste
than a deliberate design choice. See the `x_ray_eye_align.py` finding below for a concrete
-example already found this way.
+example already found this way, and the `omny_fermat_scan` rotation-crash section further
+down for a case where following flomni's exact pattern was the fix.
-## Bug found, NOT fixed — needs Mirko's call before touching it
+## `osamroy` rotation offset — resolved (real hardware code was correct; only the simulator was wrong)
-**`osamroy` (rotation axis) permanently fails every `.move()`/`umv()` call.** Full
-technical write-up: `SIMULATED_ENDSTATIONS.md`'s OMNY live-test section. Short version:
-`OMNYGalilReadbackSignal._socket_get()` (`devices/omny/galil/ogalil_ophyd.py:56-60`) adds
-a documented `+25`° offset on *read* only ("we introduce an offset of 25 to the rotation
-axis... that way we just do tomography from 0 to 180 degrees"). Nothing subtracts it back
-out on the *write* side (`OMNYGalilMotor.move()`, same file) or in that method's own
-success check, so `readback (= raw + 25)` is compared against the raw target passed to
-`.move()` — always off by exactly 25, for any target. This is real, unmodified hardware-
-facing code (not a sim artifact — the sim just relays it faithfully), and it
-unconditionally blocks `feedback_enable_with_reset()` (`rt_omny_ophyd.py:526-533`), which
-blocks every scan needing interferometer feedback, i.e. effectively all of them.
+Originally logged here as an unresolved, hardware-review-only question: whether
+`OMNYGalilMotor.move()` (`devices/omny/galil/ogalil_ophyd.py`) or `feedback_enable_with_reset()`
+(`rt_omny_ophyd.py`) had the wrong sign/space for the documented `+25`° read-side offset on the
+rotation axis (`OMNYGalilReadbackSignal._socket_get()`), since nothing appeared to subtract it
+back out on the write side. **Resolved once the real Galil DMC controller source became
+available** (`controller1.dmc`, `contrID=3` handler): the real controller firmware already
+applies `ntarget=ntarget-25` on write for this axis — `OMNYGalilMotor.move()` was never buggy.
+Only the **simulator** (`csaxs_bec/devices/sim/sim_galil.py`) was missing this, silently passing
+through raw un-offset moves. Fixed in `SimGalilState.start_move_from_newpar()`: applies the same
+`-25` when the port/axis matches the OMNY rotation stage, mirroring the real controller exactly.
+Also seeded `osamroy`'s `sim_initial_position: -25` in `simulated_omny.yaml` so a fresh sim
+session starts at displayed 0° instead of 25° (round-trips through the same offset via
+`OMNYGalilReadbackSignal`'s `+25` read-side conversion).
-Two plausible fixes, and only Mirko can say which matches real hardware intent:
-1. `OMNYGalilMotor.move()` should convert the caller's display-space target to raw
- (subtract 25) before sending it over the wire, for this axis — i.e. `.move(0)` means
- "go to displayed zero."
-2. Callers are expected to already pass raw targets, in which case
- `feedback_enable_with_reset()`'s `osamroy.obj.move(0, wait=True)` and its
- `np.fabs(readback) > 0.1` guard (which reads *display*-space) are the ones using the
- wrong space, and should target/compare against `-25` instead of `0`.
+Separately, and unrelated to this offset: `omny_rotation()`'s own `.set()` call had a different,
+real bug (targeting `osamroy.user_setpoint` instead of the root `osamroy` device), which caused a
+readback-progressbar crash later in the scan. See the `omny_fermat_scan` section below — already
+fixed, do not confuse with this offset issue.
-Do not guess-fix this one — a wrong sign/direction on a real rotation-stage offset is the
-kind of thing that should get hardware-review, not silent inference from code reading.
-
-## `x_ray_eye_align.py` — worse than the crash that got fixed
+## `x_ray_eye_align.py` — GUI now opens, underlying logic still wrong
The construction-time crash (`self.lamni.align.reset_correction()` called on an object
still mid-construction) is fixed (matches the fix LamNI's own copy of this file already
-got — see its `__init__` for the rationale comment). But that only unblocks
-*construction*. The rest of `omny/x_ray_eye_align.py` is still an **unadapted copy of
-`LamNI/x_ray_eye_align.py`**, not code actually written for OMNY:
+got — see its `__init__` for the rationale comment). The two GUI-open call sites
+(`self.lamni.lamnigui_show_xeyealign()`/`lamnigui_show_xeyealign_fittab()` — LamNI-only method
+names that don't exist on `OMNYGuiTools`) were also fixed, renamed to
+`omnygui_show_xeyealign()`/`omnygui_show_xeyealign_fittab()`, so `omny.align.align()` now gets
+past the GUI-open step without crashing.
+
+The rest of `omny/x_ray_eye_align.py` is still an **unadapted copy of `LamNI/x_ray_eye_align.py`**,
+not code actually written for OMNY:
- Docstring: `"""BEC-GUI-based X-ray eye alignment for LamNI. ... The key difference from
the FlOMNI alignment is that LamNI needs *both* x and y fits..."""` — describes LamNI
vs flOMNI, doesn't mention OMNY at all.
@@ -62,38 +86,36 @@ got — see its `__init__` for the rationale comment). But that only unblocks
- `LAMNI_ALIGNMENT_ANGLES = [k * 45 for k in range(8)]` (8 angles, full 360°, laminography-
specific) — presumably wrong for OMNY, which (per the flOMNI/LamNI comparison doc) is
the kind of full-rotation tomography flOMNI's alignment already handles differently.
+- References devices/methods (`lsamrot`, `lfzp_in`, `omnyfsh`, `omny_xray_gui`) that mix LamNI
+ and OMNY naming conventions — needs a careful pass against OMNY's actual device inventory.
-None of this was exercised in the live smoke test beyond the constructor no longer
-crashing — `start_x_ray_eye_alignment()` / `xrayeye_update_frame()` were never called.
-Given the "OMNY ≈ flOMNI, not LamNI" principle above, the right fix is almost certainly to
-port OMNY's version from `flomni/x_ray_eye_align.py` (440 lines, the flOMNI-specific
-implementation) the way `LamNI/x_ray_eye_align.py` was itself adapted from flOMNI's,
-rather than patching the current LamNI-derived file in place.
+None of the alignment logic itself has been exercised live yet — only the constructor and the
+GUI-open step. Given the "OMNY ≈ flOMNI, not LamNI" principle above, the right fix is almost
+certainly to port OMNY's version from `flomni/x_ray_eye_align.py` (440 lines, the flOMNI-specific
+implementation) the way `LamNI/x_ray_eye_align.py` was itself adapted from flOMNI's, rather than
+patching the current LamNI-derived file in place. Not started.
-## Checklist items not yet run (smoke test only covered 3 of 4)
+## Checklist items not yet run
-From the live session against `simulated_omny.yaml` / `bec --session omny`:
-- **Passed**: config load, a translation-axis move (`osamx`, `osamy`), one lightweight
- transfer (`omny.otransfer_park_slot(6)`).
-- **Blocked**: any scan (see rotation-offset bug above) — includes `omny.tomo_scan()`
- itself, never reached.
+- **Passed** (live, against `simulated_omny.yaml` / `bec --session omny`): config load,
+ translation-axis moves (`osamx`, `osamy`), a lightweight transfer (`omny.otransfer_park_slot(6)`),
+ `feedback_enable_with_reset()`, a full `omny_fermat_scan` via `omny.tomo_scan_projection()`
+ end to end, and the GUI flows listed under "Current status" above.
-Still completely untested (not attempted yet, not known-broken — just unexplored):
+Still untested (not attempted yet, not known-broken — just unexplored):
+- **Advanced tomo modes** (golden ratio, 4/8 sub-tomogram, `tomo_parameters()`'s superuser-gated
+ options) — about to be tested next.
- The **full gripper sample-transfer chain**: `otransfer_put_sample()` /
`otransfer_get_sample()` (the actual pin pickup/dropoff with the `confirm`/`atconfir`
- handshake), as opposed to the lighter `otransfer_park_slot()` used for the smoke test.
-- **X-ray eye alignment** end-to-end (blocked in practice by the section above, but worth
- retesting once that file is properly ported).
+ handshake), as opposed to the lighter `otransfer_park_slot()` used so far.
+- **X-ray eye alignment** end-to-end alignment logic (GUI now opens fine, see section above —
+ the actual click-through alignment procedure itself hasn't been exercised).
- **Dewar / temperatures** (`omny_dewar`, `omny_temperatures` devices) — they load and
construct (`SimOMNYDewar`/`SimOMNYTemperatures`), but no `omny.py`-level method that
- reads/uses them was called.
+ reads/uses them has been called live.
- **Sample storage** (`omny_samples`) beyond it loading with seeded slots — no
`omny.py`-level storage-bookkeeping method exercised.
-- `omny_alignment_mixin.py`, `omny_optics_mixin.py`, and the rest of `omny_rt.py` beyond
- `feedback_enable_with_reset`/`feedback_disable`/`feedback_status` — none of these were
- called.
-- GUI-dependent flows (`omnygui_show_gui()` etc.) — the smoke test ran `--nogui`; nothing
- that needs a real BEC-widgets window was exercised.
+- `omny_alignment_mixin.py`, `omny_optics_mixin.py` beyond what a fermat scan's own setup touches.
## Known, already-documented, separate from this session
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index b0df048f..35368497 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -46,9 +46,12 @@ class OMNYError(Exception):
# modes (golden ratio, 4/8 sub-tomogram) in OMNY.tomo_parameters(). Regular users only
# ever see "1: 2 equally spaced sub-tomograms". active_account is a Redis-backed
# pgroup value, set explicitly via `bec-set-account ` -- it is NOT derived
-# from the OS login, so testing as this account requires running
-# `bec-set-account gac-x01dc` first (or wiring pgroup "gac-x01dc" some other way).
-_SUPERUSER_ACCOUNTS: frozenset[str] = frozenset({"gac-x01dc"})
+# from the OS login, so testing as one of these accounts requires either running
+# `bec-set-account ` first, or (dev/test sessions where active_account is
+# unset) simply being logged in as that OS user, since _is_superuser_account()
+# falls back to os.getenv("USER") when active_account is blank. "holler" is
+# Mirko's account, used for live testing this branch.
+_SUPERUSER_ACCOUNTS: frozenset[str] = frozenset({"gac-x01dc", "holler"})
class OMNYInitStagesMixin:
From 2fbaa6679f8df6e6e5ac01b984582e155b2ebcfc Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 13:32:19 +0200
Subject: [PATCH 021/102] feat(omny): drop redundant tomo auth code, cap type-3
step at 2.5 deg
The "Enter authorization code" (x12sa) prompt gating advanced tomo modes
(types 2-5) predates the account-based _SUPERUSER_ACCOUNTS gate added
earlier this branch, and is now fully redundant with it -- Mirko asked to
remove it. Each mode still prints the existing "significant wear" warning
unconditionally instead of only after entering the code.
Also added a hard 2.5 degree ceiling on tomo type 3's angular step (golden
ratio starting angle mode), replacing its old minimum-100-projections floor
with an explicit wear-motivated cap; if the requested projection count
implies a larger step, it's clamped down to 2.5 degrees. Type 2's
golden_ratio_bunch_size minimum stays at 100 per Mirko's call -- wear
avoidance there is handled by the warning message, not a lower floor.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 40 +++++-
.../bec_ipython_client/plugins/omny/omny.py | 122 ++++++++----------
.../test_omny_tomo_angles.py | 47 ++++++-
3 files changed, 130 insertions(+), 79 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 78b7fd81..6f22ce7c 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -16,11 +16,15 @@ full trace of each. GUI flows (`omny.omnygui_show_progress()`, `omnygui_show_xey
`omnygui_show_omnycam_parking()`/`omnygui_show_omnycam_samplestage()`) also work now, ported onto
flomni's current `gui_tools.py` API.
-**Not yet started**: testing the advanced tomo modes (golden ratio, 4/8 sub-tomogram) added
-earlier this branch — this is the next thing being tested live. `holler` (Mirko's account) was
-added to `_SUPERUSER_ACCOUNTS` (`omny.py`) alongside `gac-x01dc` so `tomo_parameters()` shows the
-advanced options when testing from his own machine, where `bec.active_account` is unset and the
-superuser check falls back to the OS login.
+**Now being tested**: the advanced tomo modes (golden ratio, 4/8 sub-tomogram) added earlier this
+branch. `holler` (Mirko's account) was added to `_SUPERUSER_ACCOUNTS` (`omny.py`) alongside
+`gac-x01dc` so `tomo_parameters()` shows the advanced options when testing from his own machine,
+where `bec.active_account` is unset and the superuser check falls back to the OS login. The old
+`x12sa` authorization-code prompt gating tomo types 2-5 was removed — it predates the
+`_SUPERUSER_ACCOUNTS` account check and was fully redundant with it; see "Authorization code
+removed" below. Tomo type 3 also gained a hard cap of 2.5 degrees on the angular step (previously
+only a minimum-100-projections floor, which happened to already satisfy this but wasn't expressed
+as a wear limit).
**Still open / deferred** (unchanged from before, not touched this pass):
- `x_ray_eye_align.py` is still LamNI-derived, unadapted code beyond the two call-site renames
@@ -244,6 +248,32 @@ explicit `laser_tracker_check_and_wait_for_signalstrength()` call in `prepare_se
(pure duplication of the per-axis checks the rtx/rtz `set()` calls already trigger). Net effect:
quieter output, one less redundant tracker-check wait per scan setup, same safety behavior.
+## tomo_parameters(): authorization code removed, type-3 step cap added (fixed)
+
+Advanced tomo modes (types 2-5) used to require entering a hardcoded string (`x12sa`) at an "Enter
+authorization code" prompt, on top of already being hidden from non-superuser accounts by
+`_is_superuser_account()`/`_SUPERUSER_ACCOUNTS` (added earlier this branch). Once account-based
+gating existed, the code prompt was pure redundancy — Mirko pointed this out directly ("we can
+remove that now that it is based on the user account") — so it's been removed from all three
+blocks (types 2, 3, 4/5). Each block still prints the same "This mode causes significant wear in
+OMNY" wear warning as before (now unconditional, not gated behind entering the code), reworded
+slightly to explicitly address the power user.
+
+Also added: tomo type 3 (equally spaced tomography, golden ratio starting angle) now enforces a
+hard ceiling of 2.5 degrees on the angular step, in addition to just deriving the step from the
+requested projection count. Previously it only enforced a minimum-100-projections floor (step
+≤1.8°), which happened to already satisfy a 2.5° ceiling — but expressed the constraint as a
+projection-count floor rather than a wear-motivated angular ceiling. If the requested projection
+count yields a step above 2.5°, the step is clamped down to 2.5° and a message is printed; the
+actual number of projections used ends up higher than what was typed. Type 2's
+`golden_ratio_bunch_size` minimum was left at 100 (unchanged) — the wear-avoidance goal there is
+served by the warning message, not by loosening that floor.
+
+Test coverage: `tests/tests_bec_ipython_client/test_omny_tomo_angles.py`'s
+`test_tomo_parameters_allows_superuser_to_unlock_8_subtomo_mode` updated to drop the now-removed
+`"x12sa"` canned answer; new
+`test_tomo_parameters_clamps_type_3_step_size_to_max_2_5_degrees` covers the clamp.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index 35368497..cc395088 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -1448,85 +1448,69 @@ class OMNY(
print(f"The angular step in a subtomogram it will be {self.tomo_angle_stepsize}")
if self.tomo_type == 2:
- code = self._get_val(
- "This mode causes significant wear in OMNY. Enter authorization code.", 0, str
+ print(
+ "This mode causes significant wear in OMNY. You are a power user -- please"
+ " avoid unnecessarily many rotations and be gentle to the setup."
+ )
+ self.golden_ratio_bunch_size = self._get_val(
+ "Number of projections sorted per bunch (minimum 100)",
+ self.golden_ratio_bunch_size,
+ int,
+ )
+ if self.golden_ratio_bunch_size < 100:
+ print("Minimum of 100 selected.")
+ self.golden_ratio_bunch_size = 100
+ self.golden_max_number_of_projections = self._get_val(
+ "Stop after number of projections (zero for endless)",
+ self.golden_max_number_of_projections,
+ int,
+ )
+ self.golden_projections_at_0_deg_for_damage_estimation = self._get_val(
+ "Repeat projections at 0 deg every second subtomo 1/0 ?",
+ self.golden_projections_at_0_deg_for_damage_estimation,
+ int,
)
- if code == "x12sa":
- self.golden_ratio_bunch_size = self._get_val(
- "Number of projections sorted per bunch (minimum 100)",
- self.golden_ratio_bunch_size,
- int,
- )
- if self.golden_ratio_bunch_size < 100:
- print("Minimum of 100 selected.")
- self.golden_ratio_bunch_size = 100
- self.golden_max_number_of_projections = self._get_val(
- "Stop after number of projections (zero for endless)",
- self.golden_max_number_of_projections,
- int,
- )
- self.golden_projections_at_0_deg_for_damage_estimation = self._get_val(
- "Repeat projections at 0 deg every second subtomo 1/0 ?",
- self.golden_projections_at_0_deg_for_damage_estimation,
- int,
- )
- else:
- print(
- "Wrong authorization code. Tomo type 1 selected: 2 sub-tomograms selected."
- )
- self.tomo_type = 1
if self.tomo_type == 3:
- code = self._get_val(
- "This mode causes significant wear in OMNY. Enter authorization code.", 0, str
+ print(
+ "This mode causes significant wear in OMNY. You are a power user -- please"
+ " avoid unnecessarily many rotations and be gentle to the setup."
)
- if code == "x12sa":
- numprj = self._get_val(
- "Number of projections per sub-tomogram (minimum 100)",
- int(180 / self.tomo_angle_stepsize),
- int,
- )
- if numprj < 100:
- numprj = 100
- print("Minimum of 100 selected.")
- self.tomo_angle_stepsize = 180 / numprj
- self.golden_max_number_of_projections = self._get_val(
- "Stop after number of projections (zero for endless)",
- self.golden_max_number_of_projections,
- int,
- )
- self.golden_projections_at_0_deg_for_damage_estimation = self._get_val(
- "Repeat projections at 0 deg every second subtomo",
- self.golden_projections_at_0_deg_for_damage_estimation,
- int,
- )
- else:
+ numprj = self._get_val(
+ "Number of projections per sub-tomogram",
+ int(180 / self.tomo_angle_stepsize),
+ int,
+ )
+ self.tomo_angle_stepsize = 180 / numprj
+ if self.tomo_angle_stepsize > 2.5:
+ self.tomo_angle_stepsize = 2.5
print(
- "Wrong authorization code. Tomo type 1 selected: 2 sub-tomograms selected."
+ "Angular step exceeded the maximum of 2.5 degrees; clamped to 2.5 degrees"
+ f" ({int(180 / 2.5)} projections per sub-tomogram)."
)
- self.tomo_type = 1
+ self.golden_max_number_of_projections = self._get_val(
+ "Stop after number of projections (zero for endless)",
+ self.golden_max_number_of_projections,
+ int,
+ )
+ self.golden_projections_at_0_deg_for_damage_estimation = self._get_val(
+ "Repeat projections at 0 deg every second subtomo",
+ self.golden_projections_at_0_deg_for_damage_estimation,
+ int,
+ )
if self.tomo_type in (4, 5):
n_subtomos = {4: 4, 5: 8}[self.tomo_type]
- code = self._get_val(
- "This mode causes significant wear in OMNY. Enter authorization code.", 0, str
+ print(
+ "This mode causes significant wear in OMNY. You are a power user -- please"
+ " avoid unnecessarily many rotations and be gentle to the setup."
)
- if code == "x12sa":
- tomo_numberofprojections = self._get_val(
- "Total number of projections",
- 180 / self.tomo_angle_stepsize * n_subtomos,
- int,
- )
- print(f"The angular step will be {180/tomo_numberofprojections}")
- self.tomo_angle_stepsize = 180 / tomo_numberofprojections * n_subtomos
- print(
- f"The angular step in a subtomogram it will be {self.tomo_angle_stepsize}"
- )
- else:
- print(
- "Wrong authorization code. Tomo type 1 selected: 2 sub-tomograms selected."
- )
- self.tomo_type = 1
+ tomo_numberofprojections = self._get_val(
+ "Total number of projections", 180 / self.tomo_angle_stepsize * n_subtomos, int
+ )
+ print(f"The angular step will be {180/tomo_numberofprojections}")
+ self.tomo_angle_stepsize = 180 / tomo_numberofprojections * n_subtomos
+ print(f"The angular step in a subtomogram it will be {self.tomo_angle_stepsize}")
@staticmethod
def _get_val(msg: str, default_value, data_type):
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
index f71e5184..8d28e579 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
@@ -244,8 +244,9 @@ def test_tomo_parameters_forces_type_1_for_non_superuser(monkeypatch, fake_bec):
def test_tomo_parameters_allows_superuser_to_unlock_8_subtomo_mode(monkeypatch, fake_bec):
- """A superuser account can select and unlock tomo_type 5 (8 sub-tomograms)
- with the correct authorization code."""
+ """A superuser account can select and unlock tomo_type 5 (8 sub-tomograms).
+ No authorization code is required -- access is gated purely by the account
+ check (_is_superuser_account()), which is already exercised above."""
monkeypatch.setattr(omny_module, "_SUPERUSER_ACCOUNTS", frozenset({"e99999"}))
fake_bec.active_account = "e99999" # superuser
omny = make_omny()
@@ -265,9 +266,9 @@ def test_tomo_parameters_allows_superuser_to_unlock_8_subtomo_mode(monkeypatch,
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
# canned answers for: ctime, step, fovx, fovy, stitch_x, stitch_y,
- # reconstruct_foldername, tomo_type (=5, 8 sub-tomograms), authorization
- # code, total number of projections
- canned = iter([1.0, 0.1, 20, 20, 1, 1, "queue", 5, "x12sa", 144])
+ # reconstruct_foldername, tomo_type (=5, 8 sub-tomograms), total number of
+ # projections
+ canned = iter([1.0, 0.1, 20, 20, 1, 1, "queue", 5, 144])
monkeypatch.setattr(OMNY, "_get_val", staticmethod(lambda *a, **k: next(canned)))
omny.tomo_parameters()
@@ -275,3 +276,39 @@ def test_tomo_parameters_allows_superuser_to_unlock_8_subtomo_mode(monkeypatch,
assert omny.tomo_type == 5
# 144 total projections / 8 sub-tomograms -> stepsize 180/(144/8) = 10.0
assert omny.tomo_angle_stepsize == pytest.approx(10.0)
+
+
+def test_tomo_parameters_clamps_type_3_step_size_to_max_2_5_degrees(monkeypatch, fake_bec):
+ """tomo_type 3 (equally spaced, golden ratio starting angle) enforces a
+ maximum angular step of 2.5 degrees, to limit wear from too many
+ rotations. A superuser requesting too few projections per sub-tomogram
+ (i.e. too large a step) gets clamped down to 2.5 degrees."""
+ monkeypatch.setattr(omny_module, "_SUPERUSER_ACCOUNTS", frozenset({"e99999"}))
+ fake_bec.active_account = "e99999" # superuser
+ omny = make_omny()
+ omny.tomo_countingtime = 1.0
+ omny.tomo_shellstep = 0.1
+ omny.fovx = 20
+ omny.fovy = 20
+ omny.stitch_x = 1
+ omny.stitch_y = 1
+ omny.ptycho_reconstruct_foldername = "queue"
+ omny.tomo_angle_stepsize = 10.0
+ omny.tomo_type = 1
+ fake_dev = types.SimpleNamespace(
+ omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ )
+ monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
+ omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
+
+ # canned answers for: ctime, step, fovx, fovy, stitch_x, stitch_y,
+ # reconstruct_foldername, tomo_type (=3), projections per sub-tomogram
+ # (=10 -> would be an 18 degree step, far above the 2.5 degree cap),
+ # stop-after-projections, repeat-at-0-deg
+ canned = iter([1.0, 0.1, 20, 20, 1, 1, "queue", 3, 10, 0, 0])
+ monkeypatch.setattr(OMNY, "_get_val", staticmethod(lambda *a, **k: next(canned)))
+
+ omny.tomo_parameters()
+
+ assert omny.tomo_type == 3
+ assert omny.tomo_angle_stepsize == pytest.approx(2.5)
From 789c0dbf721ad82a9672494bf83d8a734683f6a0 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 13:43:08 +0200
Subject: [PATCH 022/102] test(omny): lock in equal spacing at every
sub-tomogram combination stage
Mirko flagged that flomni previously had real, painful bugs where combined
sub-tomograms weren't actually equally spaced -- given that history, verify
(and permanently regression-guard) the same property for OMNY's tomo types
1/4/5, at the specific combination granularity he described: type 4's 1+2
and 3+4 pairings individually, not just the full 1-2-3-4 combination already
covered; and type 5's progressive 1+2 / 1+2+3+4 / 1..8 doubling stages,
matching flomni's own documented interlacing design.
Verified offline against the exact production function
(_equally_spaced_subtomo_angles) across several step sizes including ones
that don't divide 180 evenly; for n_subtomos=8 its bit-reversal phase index
was also cross-checked to reproduce flomni's hardcoded, already-proven-
correct phase_eighths table exactly (already covered by the existing
test_bit_reversal_matches_flomnis_hardcoded_phase_eighths_table, unchanged).
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../test_omny_tomo_angles.py | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
index 8d28e579..dba9bd18 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
@@ -117,6 +117,60 @@ def test_phase_offset_uses_achievable_step_not_raw_stepsize(stepsize):
assert np.isclose(new[0], achievable_step_start, atol=1e-9)
+def _combined_angles(subtomo_numbers, n_subtomos, stepsize):
+ angles = []
+ for s in subtomo_numbers:
+ angles.extend(_angles(s, n_subtomos, stepsize).tolist())
+ return np.sort(np.asarray(angles))
+
+
+def _assert_evenly_spaced(angles, expected_step, label):
+ diffs = np.diff(angles)
+ assert np.allclose(
+ diffs, expected_step, atol=1e-6
+ ), f"{label} are not evenly spaced by {expected_step}: got {diffs}"
+
+
+@pytest.mark.parametrize("stepsize", STEPSIZES)
+def test_type4_intermediate_pairings_equally_spaced(stepsize):
+ """tomo_type 4 (4 equally spaced sub-tomograms) must be equally spaced not
+ only in the full 1-2-3-4 combination (already covered by
+ test_combined_subtomos_cover_180_degrees_without_duplicates), but also at
+ each natural intermediate pairing: sub-tomo 1+2 combined, and sub-tomo 3+4
+ combined. flomni previously shipped a bug where combined sub-tomograms
+ were NOT actually equally spaced (fixed via the bit-reversal phase table
+ this function reuses, see test_bit_reversal_matches_flomnis_hardcoded_
+ phase_eighths_table below) -- guard the same property at this
+ finer-grained level, per Mirko's request given that history.
+ """
+ n_points = int(180.0 / stepsize)
+ step = 180.0 / n_points
+
+ _assert_evenly_spaced(_combined_angles([1, 2], 4, stepsize), step / 2, "sub-tomos 1+2")
+ _assert_evenly_spaced(_combined_angles([3, 4], 4, stepsize), step / 2, "sub-tomos 3+4")
+ _assert_evenly_spaced(
+ _combined_angles([1, 2, 3, 4], 4, stepsize), step / 4, "sub-tomos 1+2+3+4"
+ )
+
+
+@pytest.mark.parametrize("stepsize", STEPSIZES)
+def test_type5_progressive_combinations_equally_spaced(stepsize):
+ """tomo_type 5 (8 equally spaced sub-tomograms, akin to flomni's own
+ 8-sub-tomogram scheme) must be equally spaced at every progressive
+ doubling stage of combination, matching flomni's documented interlacing
+ design (_subtomo_angle_plan()'s docstring): 1+2, 1+2+3+4, and the full
+ 1..8 each independently form a finer evenly spaced grid.
+ """
+ n_points = int(180.0 / stepsize)
+ step = 180.0 / n_points
+
+ _assert_evenly_spaced(_combined_angles([1, 2], 8, stepsize), step / 2, "sub-tomos 1+2")
+ _assert_evenly_spaced(
+ _combined_angles([1, 2, 3, 4], 8, stepsize), step / 4, "sub-tomos 1+2+3+4"
+ )
+ _assert_evenly_spaced(_combined_angles(range(1, 9), 8, stepsize), step / 8, "sub-tomos 1..8")
+
+
def test_bit_reversal_matches_flomnis_hardcoded_phase_eighths_table():
"""flomni.py hardcodes the N=8 bit-reversal permutation as a literal dict
(phase_eighths = {1:0, 2:4, 3:2, 4:6, 5:1, 6:5, 7:3, 8:7}). The generalized
From ea9519ff205a7238332e7428cf7ad58b039f01c0 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 13:48:51 +0200
Subject: [PATCH 023/102] test(omny): check combined sub-tomogram spacing adds
up to 180 degrees
Mirko recalled that flomni/lamni check the combined angular spacing adds up
to 180 (360 for lamni) -- found the precise existing pattern in lamni's own
test suite (test_lamni_tomo_angles.py's _assert_equally_spaced(): sort the
combined angles, append a wraparound point at first_angle + range, and
assert every gap including that seam is uniform). This is strictly stronger
than checking the total span alone -- it also catches a double-wide or
missing gap right at the seam, which is exactly the shape of bug flomni's
historical fixes (e.g. 6acaadb, "fix angular start in even subtomos")
addressed.
Ported the same wraparound-inclusive check into OMNY's tests: the existing
full-combination test now asserts it directly, and the intermediate-pairing
helper (_assert_evenly_spaced, used by the type-4 and type-5 pairwise/
progressive combination tests added previously) now includes it too.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../test_omny_tomo_angles.py | 25 +++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
index dba9bd18..a9671af1 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
@@ -72,6 +72,20 @@ def test_combined_subtomos_cover_180_degrees_without_duplicates(stepsize, n_subt
diffs, combined_step, atol=1e-6
), f"combined angles are not evenly spaced by {combined_step}: got {diffs}"
+ # The wraparound gap (last angle back to the first angle + 180) must be
+ # the SAME size as every other gap -- not just that the angles span 180
+ # in total, but that the seam itself isn't a double-wide gap or an
+ # overlap. Mirrors lamni's _assert_equally_spaced() in
+ # test_lamni_tomo_angles.py (there: %360, +360 wraparound; here: 180,
+ # since OMNY only ever scans a 180 degree range) and is the same
+ # "combined spacing adds up to 180" invariant flomni's
+ # test_180_mode_regression checks via its min>=0/max<180 bounds.
+ wrapped_diffs = np.diff(np.concatenate([sorted_angles, [sorted_angles[0] + 180.0]]))
+ assert np.allclose(wrapped_diffs, combined_step, atol=1e-6), (
+ f"combined sub-tomograms are not evenly spaced by {combined_step} once the"
+ f" 0/180 wraparound gap is included: got {wrapped_diffs}"
+ )
+
# stepsizes that divide 180 evenly, where the old code's raw-stepsize-based
# phase offset happens to coincide with the achievable-step-based one below
@@ -125,10 +139,17 @@ def _combined_angles(subtomo_numbers, n_subtomos, stepsize):
def _assert_evenly_spaced(angles, expected_step, label):
- diffs = np.diff(angles)
+ """Checks every gap between consecutive (sorted) angles, INCLUDING the
+ wraparound gap from the last angle back to the first angle + 180 -- not
+ just that the angles are internally evenly spaced, but that they add up
+ to exactly 180 degrees with no double-wide or missing gap at the seam.
+ Mirrors lamni's _assert_equally_spaced() in test_lamni_tomo_angles.py
+ (there: %360, +360 wraparound, since lamni scans a full circle)."""
+ wrapped = np.concatenate([angles, [angles[0] + 180.0]])
+ diffs = np.diff(wrapped)
assert np.allclose(
diffs, expected_step, atol=1e-6
- ), f"{label} are not evenly spaced by {expected_step}: got {diffs}"
+ ), f"{label} are not evenly spaced by {expected_step} once the 0/180 wraparound gap is included: got {diffs}"
@pytest.mark.parametrize("stepsize", STEPSIZES)
From 906e9f5318d0aec5bc0cb48635eca433f79931f6 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 14:42:35 +0200
Subject: [PATCH 024/102] feat(omny): port tomo-queue backend from flomni/lamni
(Phase 1 of 2)
Mirko wants the tomo params GUI widget available for OMNY before running
real tomo scans. That widget (Phase 2, separate follow-up) needs a working
job queue and scan progress visible outside the CLI session that started
it -- neither existed for OMNY. OMNY now inherits TomoQueueMixin
(OMNY_shared/tomo_queue_mixin.py), the same shared backend flomni/lamni
already use, giving it tomo_queue_add/delete/move/clear/show/execute/
reacquire, at_each_angle_hook registration, and tomo_scan_resume() for the
first time.
self.progress is now a global-var-backed proxy (_ProgressProxy, byte-for-
byte port of flomni's/lamni's own), not a plain in-memory dict -- the
load-bearing change the queue mixin and the eventual widget's busy-banner
both depend on. tomo_queue_mixin.py's tomo_queue_reacquire() hardcoded
tomo_type == 1; generalized via a new _TOMO_TYPE_1_LIKE class attribute
(default frozenset({1}), flomni/lamni unaffected) since OMNY has three
"equally spaced sub-tomograms" flavors (types 1/4/5), not just one.
Along the way, fixed several real bugs in tomo_scan()/write_pdf_report()
that predate this session and would have crashed on a real account/
real energy read (bec.active_account.decode() -- active_account is str
not bytes; dev.mokev doesn't exist for OMNY, only dev.ccm_energy; an
unguarded logbook send; a stale "LamNI" logo/tag copy-paste leftover) --
all direct ports of fixes flomni/lamni already needed and got. Also added
the missing unconditional omnygui_show_progress() call and heartbeat-
clearing try/finally in tomo_scan(), matching both.
Verification: 23 new tests (test_omny_tomo_queue.py, test_omny_tomo_scan.py)
covering the mixin wiring, hook dispatch/resolution, tomo_scan_resume()/
_resolve_type1_projection()/reacquire() across all three sub-tomogram
flavors, account handling, and heartbeat clearing on both normal completion
and a mid-scan exception. Full suite: 702 passed. Not yet live-tested
against a running session (the local bec-server may be in active use by
Mirko's own parallel session) -- next step is his own pull-and-test.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/OMNY_shared/tomo_queue_mixin.py | 29 +-
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 126 ++++-
.../bec_ipython_client/plugins/omny/omny.py | 529 ++++++++++++++----
.../test_omny_tomo_queue.py | 317 +++++++++++
.../test_omny_tomo_scan.py | 183 ++++++
5 files changed, 1051 insertions(+), 133 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_omny_tomo_queue.py
create mode 100644 tests/tests_bec_ipython_client/test_omny_tomo_scan.py
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 927c546a..c26f7fa2 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
@@ -27,10 +27,15 @@ Consumers (e.g. ``Flomni``, ``LamNI``) must:
``flomni_at_each_angle`` builtins hook, single-point acquisition -- that
it stays out of this mixin)
- implement their own ``_resolve_type1_projection()`` (dispatched
- generically by ``tomo_queue_reacquire()``), mapping a flat tomo_type-1
- projection number to a ``(subtomo_start, start_angle)`` pair -- the
- angle math is setup-specific (see each setup's own
- ``_subtomo_angle_plan()``), same reasoning as ``tomo_scan()`` above
+ generically by ``tomo_queue_reacquire()`` for any tomo_type in
+ ``_TOMO_TYPE_1_LIKE``), mapping a flat projection number to a
+ ``(subtomo_start, start_angle)`` pair -- the angle math is
+ setup-specific (see each setup's own ``_subtomo_angle_plan()``/
+ ``_equally_spaced_subtomo_angles()``), same reasoning as
+ ``tomo_scan()`` above
+ - optionally override ``_TOMO_TYPE_1_LIKE`` (default ``frozenset({1})``)
+ if their setup has more than one "equally spaced sub-tomograms"
+ tomo_type flavor (e.g. OMNY's types 1/4/5 -- 2/4/8 sub-tomograms)
"""
from __future__ import annotations
@@ -210,6 +215,13 @@ class TomoQueueMixin:
_TOMO_QUEUE_ACTIONS: dict = {}
_TOMO_QUEUE_MOVE_DEVICES: dict = {}
+ # tomo_type values that behave like "type 1" for tomo_queue_reacquire()'s
+ # purposes -- i.e. resolved via _resolve_type1_projection() rather than a
+ # flat projection_number. Default matches flomni/lamni (type 1 only, their
+ # only "equally spaced sub-tomograms" flavor); OMNY overrides this to
+ # {1, 4, 5} since it has three such flavors (2/4/8 sub-tomograms).
+ _TOMO_TYPE_1_LIKE: frozenset = frozenset({1})
+
# Name of a registered at_each_angle hook (see register_at_each_angle_hook()),
# or None for the default per-projection behaviour.
at_each_angle_hook = _GlobalVarParam(None)
@@ -1012,10 +1024,9 @@ class TomoQueueMixin:
job_index: Position of the job to reopen, in the *current*
queue order (same indexing as ``tomo_queue_show()``/
``tomo_queue_delete()``).
- projection_number: Flat 0-indexed projection to resume from,
- for any tomo_type (1, 2, or 3) -- see
- ``_resolve_type1_projection()`` for how this maps onto a
- tomo_type-1 job's (subtomo, angle).
+ projection_number: Flat 0-indexed projection to resume from, for
+ any tomo_type -- see ``_resolve_type1_projection()`` for how
+ this maps onto a ``_TOMO_TYPE_1_LIKE`` job's (subtomo, angle).
Raises:
TomoQueueError: ``job_index`` is out of range, the job at that
@@ -1049,7 +1060,7 @@ class TomoQueueMixin:
for name, value in job["params"].items():
setattr(self, name, value)
- if self.tomo_type == 1:
+ if self.tomo_type in self._TOMO_TYPE_1_LIKE:
subtomo_start, start_angle = self._resolve_type1_projection(projection_number)
self.progress["subtomo"] = subtomo_start
self.progress["angle"] = start_angle
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 6f22ce7c..971f1d3f 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -26,6 +26,12 @@ removed" below. Tomo type 3 also gained a hard cap of 2.5 degrees on the angular
only a minimum-100-projections floor, which happened to already satisfy this but wasn't expressed
as a wear limit).
+**Just landed**: the tomo-queue backend (Phase 1 of 2 of porting the tomo params GUI widget to
+OMNY) — `OMNY` now has a real job queue, `at_each_angle_hook` registration, `tomo_scan_resume()`,
+and scan progress visible outside the CLI session (previously a plain in-memory dict). See "Tomo-
+queue backend ported to OMNY" below. Phase 2 (the widget itself) is a separate follow-up, not yet
+started — not live-tested against a running session yet either; that's next, once Mirko pulls.
+
**Still open / deferred** (unchanged from before, not touched this pass):
- `x_ray_eye_align.py` is still LamNI-derived, unadapted code beyond the two call-site renames
that unblocked the GUI-open crash — see its own section below. The GUI itself opens fine now;
@@ -33,6 +39,7 @@ as a wear limit).
- The full gripper sample-transfer chain (`otransfer_put_sample()`/`otransfer_get_sample()`),
dewar/temperature methods, sample storage bookkeeping, and `omny_alignment_mixin.py`/
`omny_optics_mixin.py` beyond what scans exercise — see "Checklist" section below.
+- The `TomoParamsWidget`/`OMNY_TomoParams` GUI widget itself (Phase 2 of the tomo-queue port).
## Standing question: OMNY should follow flOMNI, not LamNI
@@ -126,24 +133,29 @@ Still untested (not attempted yet, not known-broken — just unexplored):
- `omny_webpage_generator.py` (2957 lines) is dead/unwired code — never instantiated by
`omny.py`, per `FLOMNI_TO_LAMNI_COMPARISON.md`'s note ("omny was left untouched...
separate future task if omny ever needs a real one"). Not touched this session either.
-- The tomo-queue / `TomoParamsWidget` generalization done for LamNI
- (`LamNI/AI_docs/TOMO_QUEUE_PORT.md`, `TOMO_PARAMS_GUI_PORT.md`) has no OMNY counterpart
- yet — out of scope for the sim-validation work, in scope for the eventual macro-
- standardization pass.
+- **Tomo-queue backend: ported, Phase 1 of 2 (this session).** `OMNY` now inherits
+ `TomoQueueMixin` (`OMNY_shared/tomo_queue_mixin.py`), the same shared backend flomni/lamni
+ use — `tomo_queue_add/delete/move/clear/show/execute/reacquire`,
+ `register_at_each_angle_hook`/`at_each_angle_hook`, `tomo_scan_resume()`. See "Tomo-queue
+ backend ported to OMNY" below for the full detail. **Not yet done (Phase 2, separate
+ follow-up)**: the `TomoParamsWidget`/`OMNY_TomoParams` GUI widget itself
+ (`bec_widgets/widgets/tomo_params/tomo_params.py`) still has no `SETUP_PROFILES["omny"]`
+ entry and its tomo-type selector/job-display logic is hardcoded to 3 tomo types (flomni's
+ and lamni's), not OMNY's 5 — needs its own plan once Phase 1 is live-tested.
-## gui_tools.py progress fields (deferred on purpose)
+## gui_tools.py progress ETA/heartbeat line (still not rendered, data now exists)
`gui_tools.py` was modernized to flomni's current single-level `.new()` / `_is_deleted()`-guard
pattern (window lifecycle, xray-eye-align GUI, cameras + hard-stop console, `RingProgressBar`).
-One piece was deliberately **not** ported: flomni's `self.progress` dict carries
-`tomo_start_time`/`heartbeat`/`estimated_remaining_time`/`estimated_finish_time` plus a
-`_describe_active_hook()` method feeding an ETA/heartbeat line in `_flomnigui_update_progress()`.
-omny's `self.progress` (set up in `OMNY.__init__`, `omny.py`) still only has the original 7 fields
-(`subtomo`, `subtomo_projection`, `subtomo_total_projections`, `projection`,
-`total_projections`, `angle`, `tomo_type`) and `_omnygui_update_progress()` was written against
-just those. Mirko's call: keep it simple for this pass, revisit later. Not a crash risk —
-`omny_webpage_generator.py` already reads the ETA-ish fields defensively via `.get()`, degrading
-to "N/A" when absent, and that file is unwired/dead code anyway (see below).
+As of the tomo-queue backend port (see below), `self.progress` now DOES carry
+`tomo_start_time`/`heartbeat`/`estimated_remaining_time`/`estimated_finish_time` (global-var-backed,
+same shape as flomni's/lamni's) — what's still not ported is purely a `gui_tools.py` rendering
+detail: `_omnygui_update_progress()` was never updated to display an ETA/heartbeat line the way
+flomni's `_flomnigui_update_progress()`/`_describe_active_hook()` do, and still only builds its
+text from the original 7 fields. Not a crash risk either way — `_omnygui_update_progress()`
+ignores fields it doesn't read, and `omny_webpage_generator.py` (unwired/dead code, see below)
+already reads the ETA-ish fields defensively via `.get()`, degrading to "N/A" when absent. Revisit
+whenever the progress ring GUI itself gets an ETA line, independent of the Phase 2 widget work.
## cam_xeye device (simulated only, real hardware config still needed)
@@ -274,6 +286,92 @@ Test coverage: `tests/tests_bec_ipython_client/test_omny_tomo_angles.py`'s
`"x12sa"` canned answer; new
`test_tomo_parameters_clamps_type_3_step_size_to_max_2_5_degrees` covers the clamp.
+## Tomo-queue backend ported to OMNY (Phase 1 of 2)
+
+Mirko wants the tomo params GUI widget available for OMNY before starting real tomo scans. That
+widget (Phase 2, separate follow-up) depends on a working tomo-queue backend and on scan progress
+being visible outside the CLI session that started it -- neither existed for OMNY before this pass.
+Same shared infrastructure and pattern as the flomni -> lamni port
+(`LamNI/AI_docs/TOMO_QUEUE_PORT.md`, `TOMO_PARAMS_GUI_PORT.md`), done as the same two-phase split
+per Mirko's explicit choice.
+
+**`OMNY` now inherits `TomoQueueMixin`** (`OMNY_shared/tomo_queue_mixin.py`, first in the MRO,
+matching flomni/lamni), giving it `tomo_queue_add/add_command/delete/move/clear/show/execute/
+resume/reacquire`, `register_at_each_angle_hook`/`unregister_at_each_angle_hook`/
+`list_at_each_angle_hooks`/`at_each_angle_hook` for the first time. New class attributes:
+`_TOMO_SCAN_PARAM_NAMES` (mirrors flomni's names exactly -- OMNY's properties already mirror
+flomni's 1:1, no `tomo_angle_range`/`single_point_*`/`zero_deg_reference_at_each_subtomo`/
+`frames_per_trigger` since none of these exist as OMNY properties), `_TOMO_QUEUE_MOVE_DEVICES`
+(`ccm_energy`+`idgap`, **not** `mokev` -- OMNY has no `mokev` device in any config, real or
+simulated), `_TOMO_QUEUE_ACTIONS` (same `move`/`optimize_idgap` registry as flomni/lamni,
+`optimize_idgap` still a stub).
+
+**`self.progress` is now a global-var-backed proxy, not a plain dict.** Before this pass, OMNY's
+`self.progress = {}` was a plain in-memory instance dict -- invisible to any other BEC client
+session, a GUI process included. New `_ProgressProxy` class (direct, byte-for-byte port of
+flomni's/lamni's own, backed by `client.get_global_var("tomo_progress")`, same global-var key --
+only one tomography setup is ever live per BEC session, no collision risk), with `progress` now a
+`@property`. Carries the original 7 fields (`subtomo`, `subtomo_projection`,
+`subtomo_total_projections`, `projection`, `total_projections`, `angle`, `tomo_type`) plus
+`tomo_start_time`/`estimated_remaining_time`/`estimated_finish_time`/`heartbeat`/
+`accumulated_idle_time`, matching flomni's/lamni's `_DEFAULTS` shape.
+
+**`tomo_queue_mixin.py`'s `tomo_queue_reacquire()` generalized (small, additive, behavior-
+preserving change to shared code)**: it hardcoded `if self.tomo_type == 1:` to decide when to
+resolve via `_resolve_type1_projection()`. OMNY has three "equally spaced sub-tomograms" flavors
+(types 1/4/5 -- 2/4/8 sub-tomograms), not just type 1. Added `_TOMO_TYPE_1_LIKE: frozenset`
+(default `frozenset({1})`, so flomni/lamni are unaffected -- confirmed via their existing test
+suites, unchanged and passing), which OMNY overrides to `frozenset({1, 4, 5})`.
+
+**New OMNY methods, direct ports generalized for 3 sub-tomogram counts**: `tomo_scan_resume()`
+(dispatches subtomo/angle for tomo_type in `_TOMO_TYPE_1_LIKE`, projection_number for 2/3) and
+`_resolve_type1_projection()` (reuses the already-verified `_equally_spaced_subtomo_angles()` --
+does not re-derive the angle grid a second way).
+
+**Real, pre-existing bugs fixed along the way** (same shapes flomni/lamni already needed and got,
+found by reading `tomo_scan()`/`write_pdf_report()` against the mixin's needs, not newly
+introduced):
+- `bec.active_account.decode()` in `tomo_scan()` -- `active_account` is a plain `str`, not
+ `bytes`; crashed on every real account. Removed (OMNY's existing `if bec.active_account != "":
+ ... else: tomo_id = 0` branch structure kept as-is, unlike flomni's/lamni's "always attempt,
+ let add_sample_database() decide" pattern -- OMNY's own `add_sample_database()` has no
+ test-server/empty-account fallback to lean on).
+- `dev.mokev.read()['mokev']['value']` in `write_pdf_report()` -- OMNY has no `mokev` device in
+ any config. Fixed to `dev.ccm_energy` (from `bl_optics_hutch.yaml`, always included regardless
+ of active endstation) with a try/except -> `"N/A"` fallback, matching lamni's identical fix.
+ Confirmed `ccm_energy` is *not* in the local simulated OMNY config either (`simulated_omny.yaml`/
+ `simulated_bl_endstation.yaml` have no such device), so live sim testing will exercise the
+ fallback path, not a real energy read -- still correct/safe behavior, just not the success path.
+- `bec.logbook.LogbookMessage()`/`send_logbook_message()` in `write_pdf_report()` was unguarded --
+ wrapped in try/except, matching `write_to_scilog()`'s existing tolerance one method up.
+- `write_pdf_report()`'s scilog logo/tags still said `"LamNI"`/pointed at a nonexistent
+ `LamNI_logo.png` (omny's plugin directory only has `OMNY.png`) -- `msg.add_file(logo_path)`
+ would have raised unguarded (now inside the same try/except above regardless). Fixed to
+ `OMNY.png`/`"OMNY"`.
+- `tomo_scan()` never called `self.omnygui_show_progress()` unconditionally at scan start (only
+ per-angle inside `_tomo_scan_at_angle()`) and had no try/finally to clear `heartbeat` on exit --
+ both fixed, matching flomni's/lamni's identical pattern.
+- `_at_each_angle()` now checks `self.at_each_angle_hook` (mixin-provided) first, falling through
+ to the pre-existing `omny_at_each_angle` builtins-global hook, then the default
+ `tomo_scan_projection()` + `tomo_reconstruct()` -- same 3-tier structure as flomni's, minus its
+ single-point branch (OMNY has none).
+
+**Verification**: new `tests/tests_bec_ipython_client/test_omny_tomo_queue.py` (17 tests --
+param-name snapshotting, queue add/show/execute/resume/reacquire, add-command move validation,
+`at_each_angle_hook` dispatch/default/legacy-builtins-fallback/unregistered-name,
+`tomo_scan_resume()`/`_resolve_type1_projection()`/`tomo_queue_reacquire()` correctness across all
+three sub-tomogram flavors) and `test_omny_tomo_scan.py` (6 tests -- account handling, progress-GUI
+auto-show, heartbeat clearing on normal completion and mid-scan exception, new-scan timing-field
+resets). `write_pdf_report()`'s `ccm_energy` fallback has no dedicated unit test, matching
+flomni's/lamni's own precedent (neither has one either -- verified live only, real file I/O and
+device reads make it impractical to unit test in isolation). Full suite: 702 passed. Confirmed via
+plain Python (no live client needed) that `OMNY`'s MRO resolves cleanly with `TomoQueueMixin`
+first, no conflicts. **Not yet live-verified against a real running session** (deliberately --
+the local bec-server may be in active use by Mirko's own parallel session; a full live
+`tomo_scan()`/`tomo_queue_execute()` run moves real simulated hardware and consumes real scan
+numbers) -- next step is Mirko pulling and testing live himself, same pattern as this whole
+engagement.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index cc395088..c40e74b1 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -21,6 +21,7 @@ from csaxs_bec.bec_ipython_client.plugins.omny.omny_sample_transfer_mixin import
OMNYSampleTransferMixin,
)
from csaxs_bec.bec_ipython_client.plugins.omny.x_ray_eye_align import XrayEyeAlign
+from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import TomoQueueMixin
logger = bec_logger.logger
@@ -54,6 +55,81 @@ class OMNYError(Exception):
_SUPERUSER_ACCOUNTS: frozenset[str] = frozenset({"gac-x01dc", "holler"})
+class _ProgressProxy:
+ """Dict-like proxy that persists the OMNY progress dict as a BEC global variable.
+
+ Every read (`proxy["key"]`) fetches the current dict from the global var store,
+ and every write (`proxy["key"] = val`) fetches, updates, and saves it back.
+ This makes the progress state visible to all BEC client sessions via
+ ``client.get_global_var("tomo_progress")`` -- same key and shape as
+ flomni's/lamni's own progress proxy, since only one tomography setup is
+ ever live against a given BEC session. Direct port of
+ ``flomni.py``'s/``lamni.py``'s identical class.
+ """
+
+ _GLOBAL_VAR_KEY = "tomo_progress"
+ _DEFAULTS: dict = {
+ "subtomo": 0,
+ "subtomo_projection": 0,
+ "subtomo_total_projections": 1,
+ "projection": 0,
+ "total_projections": 1,
+ "angle": 0,
+ "tomo_type": 0,
+ "tomo_start_time": None,
+ "estimated_remaining_time": None,
+ "estimated_finish_time": None,
+ "heartbeat": None,
+ "accumulated_idle_time": 0.0,
+ }
+
+ def __init__(self, client):
+ self._client = client
+
+ # ------------------------------------------------------------------
+ # Internal helpers
+ # ------------------------------------------------------------------
+ def _load(self) -> dict:
+ val = self._client.get_global_var(self._GLOBAL_VAR_KEY)
+ if val is None:
+ return dict(self._DEFAULTS)
+ return val
+
+ def _save(self, data: dict) -> None:
+ self._client.set_global_var(self._GLOBAL_VAR_KEY, data)
+
+ # ------------------------------------------------------------------
+ # Dict-like interface
+ # ------------------------------------------------------------------
+ def __getitem__(self, key):
+ return self._load()[key]
+
+ def __setitem__(self, key, value) -> None:
+ data = self._load()
+ data[key] = value
+ self._save(data)
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self._load()!r})"
+
+ def get(self, key, default=None):
+ return self._load().get(key, default)
+
+ def update(self, *args, **kwargs) -> None:
+ """Update multiple fields in a single round-trip."""
+ data = self._load()
+ data.update(*args, **kwargs)
+ self._save(data)
+
+ def reset(self) -> None:
+ """Reset all progress fields to their default values."""
+ self._save(dict(self._DEFAULTS))
+
+ def as_dict(self) -> dict:
+ """Return a plain copy of the current progress state."""
+ return self._load()
+
+
class OMNYInitStagesMixin:
def enable_all_devices(self):
@@ -560,6 +636,7 @@ class OMNYInitStagesMixin:
class OMNY(
+ TomoQueueMixin,
OMNYInitStagesMixin,
OMNYSampleTransferMixin,
OMNYAlignmentMixin,
@@ -568,6 +645,73 @@ class OMNY(
OMNY_rt_client,
OMNYGuiTools,
):
+ # tomo_type values that behave like "type 1" for tomo_queue_reacquire()
+ # (TomoQueueMixin) -- OMNY has three "equally spaced sub-tomograms"
+ # flavors (1/4/5 -> 2/4/8 sub-tomograms), unlike flomni/lamni's single
+ # type-1 flavor (the mixin's default, frozenset({1})).
+ _TOMO_TYPE_1_LIKE: frozenset = frozenset({1, 4, 5})
+
+ # Property names a tomo_queue_add() job snapshots (TomoQueueMixin
+ # contract). Mirrors flomni's own tuple exactly, since OMNY's properties
+ # already mirror flomni's names 1:1 -- no tomo_angle_range/single_point_*/
+ # zero_deg_reference_at_each_subtomo/frames_per_trigger, since OMNY has
+ # no such properties.
+ _TOMO_SCAN_PARAM_NAMES = (
+ "tomo_countingtime",
+ "tomo_shellstep",
+ "fovx",
+ "fovy",
+ "stitch_x",
+ "stitch_y",
+ "tomo_stitch_overlap",
+ "ptycho_reconstruct_foldername",
+ "manual_shift_y",
+ "tomo_type",
+ "tomo_angle_stepsize",
+ "golden_ratio_bunch_size",
+ "golden_max_number_of_projections",
+ "golden_projections_at_0_deg_for_damage_estimation",
+ "corridor_size",
+ "at_each_angle_hook",
+ )
+
+ # Curated allow-list for the "move" queue action -- same shape as
+ # flomni's/lamni's own (TomoQueueMixin contract). "ccm_energy", not
+ # "mokev" -- OMNY has no mokev device in any config (real or
+ # simulated); ccm_energy (bl_optics_hutch.yaml) is always present
+ # regardless of active endstation.
+ _TOMO_QUEUE_MOVE_DEVICES = {
+ "ccm_energy": {"label": "Energy", "unit": "keV"},
+ "idgap": {"label": "Undulator gap", "unit": "mm"},
+ }
+
+ # Same move/optimize_idgap registry as flomni/lamni (TomoQueueMixin
+ # contract) -- optimize_idgap stays a stub (TomoQueueMixin.optimize_idgap
+ # is a no-op), matching both.
+ _TOMO_QUEUE_ACTIONS = {
+ "move": {
+ "func": "_queue_action_move",
+ "idempotent": True, # absolute moves re-run harmlessly
+ "help": "Move device(s) to absolute position(s).",
+ "params": {"positions": {"type": "device_positions"}},
+ },
+ "optimize_idgap": {
+ "func": "optimize_idgap",
+ "idempotent": True,
+ "help": "Scan idgap over a search range and move to the peak. STUB: no-op.",
+ "params": {
+ "search_range": {
+ "type": "float",
+ "default": 0.5,
+ "min": 0.0,
+ "max": 2.0,
+ "unit": "mm",
+ "label": "Search range",
+ }
+ },
+ },
+ }
+
def __init__(self, client):
super().__init__()
self.client = client
@@ -588,20 +732,69 @@ class OMNY(
self.corr_angle_y = []
self.corr_pos_y_2 = []
self.corr_angle_y_2 = []
- self.progress = {}
- self.progress["subtomo"] = 0
- self.progress["subtomo_projection"] = 0
- self.progress["subtomo_total_projections"] = 1
- self.progress["projection"] = 0
- self.progress["total_projections"] = 1
- self.progress["angle"] = 0
- self.progress["tomo_type"] = 0
+ # Progress tracking, persisted via the BEC global variable
+ # "tomo_progress" so it survives a client restart -- deliberately NOT
+ # reset here: this proxy is constructed every time OMNY() is
+ # instantiated (i.e. every new client session), and resetting
+ # unconditionally on every instantiation would wipe tomo_start_time
+ # (and everything else) on exactly the restarts where recovering
+ # scan state matters most. A genuinely new tomo_scan() call resets
+ # the relevant fields itself (see the "new scan" branch in
+ # tomo_scan()).
+ self._progress_proxy = _ProgressProxy(self.client)
self.OMNYTools = OMNYTools(self.client)
+ self._init_tomo_queue()
OMNY_rt_client.__init__(self)
self.align = XrayEyeAlign(self.client, self)
OMNYGuiTools.__init__(self)
self.set_client(self.client)
+ @property
+ def progress(self) -> _ProgressProxy:
+ """Proxy dict backed by the BEC global variable ``tomo_progress``.
+
+ Readable from any BEC client session via::
+
+ client.get_global_var("tomo_progress")
+
+ Individual fields can be read and written just like a regular dict::
+
+ omny.progress["projection"] # read
+ omny.progress["projection"] = 42 # write (persists immediately)
+
+ To update multiple fields atomically use :py:meth:`_ProgressProxy.update`::
+
+ omny.progress.update(projection=42, angle=90.0)
+
+ To reset all fields to their defaults::
+
+ omny.progress.reset()
+ """
+ return self._progress_proxy
+
+ @progress.setter
+ def progress(self, val: dict) -> None:
+ """Replace the entire progress dict.
+
+ Accepts a plain :class:`dict` and persists it to the global var store.
+ """
+ if not isinstance(val, dict):
+ raise TypeError(f"progress must be a dict, got {type(val).__name__!r}")
+ self._progress_proxy._save(val)
+
+ def tomo_progress_reset(self) -> None:
+ """Explicitly clear the persisted tomo progress (start time, ETA,
+ current angle/subtomo/projection, accumulated idle time, ...).
+
+ Not called automatically on OMNY() startup -- that would wipe an
+ in-progress scan's state on every kernel restart. Call this by hand
+ if you want a clean progress display without it being tied to
+ starting a new tomo_scan() (which already resets the relevant
+ fields itself).
+ """
+ self._progress_proxy.reset()
+ print("Tomo progress reset.")
+
def start_x_ray_eye_alignment(self):
if self.OMNYTools.yesno(
"Starting Xrayeye alignment. Deleting any potential existing alignment for this sample.",
@@ -1055,6 +1248,8 @@ class OMNY(
def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None):
"""start a tomo scan"""
+ self.omnygui_show_progress()
+
bec = builtins.__dict__.get("bec")
scans = builtins.__dict__.get("scans")
self._current_special_angles = self.special_angles.copy()
@@ -1066,11 +1261,13 @@ class OMNY(
):
# pylint: disable=undefined-variable
+ # bec.active_account is a plain str, not bytes -- .decode() used
+ # to crash with AttributeError on every real account.
if bec.active_account != "":
self.tomo_id = self.add_sample_database(
self.sample_name,
str(datetime.date.today()),
- bec.active_account.decode(),
+ bec.active_account,
bec.queue.next_scan_number,
"OMNY",
"test additional info",
@@ -1079,111 +1276,201 @@ class OMNY(
self.write_pdf_report()
else:
self.tomo_id = 0
+ self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
+ # reset stale estimates from any previous scan, otherwise the GUI
+ # would keep showing a leftover ETA from before this scan has
+ # accumulated enough projections to compute a fresh one
+ self.progress["estimated_remaining_time"] = None
+ self.progress["estimated_finish_time"] = None
+ self.progress["accumulated_idle_time"] = 0.0
+ self.progress["heartbeat"] = None
- with scans.dataset_id_on_hold:
- if self.tomo_type in (1, 4, 5):
- # equally spaced sub-tomograms (2, 4, or 8)
- n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type]
- self.progress["tomo_type"] = f"Equally spaced sub-tomograms ({n_subtomos})"
- for ii in range(subtomo_start, n_subtomos + 1):
- self.sub_tomo_scan(ii, n_subtomos=n_subtomos, start_angle=start_angle)
- start_angle = None
+ try:
+ with scans.dataset_id_on_hold:
+ if self.tomo_type in (1, 4, 5):
+ # equally spaced sub-tomograms (2, 4, or 8)
+ n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type]
+ self.progress["tomo_type"] = f"Equally spaced sub-tomograms ({n_subtomos})"
+ for ii in range(subtomo_start, n_subtomos + 1):
+ self.sub_tomo_scan(ii, n_subtomos=n_subtomos, start_angle=start_angle)
+ start_angle = None
- elif self.tomo_type == 2:
- # Golden ratio tomography
- previous_subtomo_number = -1
- if projection_number == None:
- ii = 0
- else:
- ii = projection_number
- while True:
- angle, subtomo_number = self._golden(ii, self.golden_ratio_bunch_size, 180, 1)
- if previous_subtomo_number != subtomo_number:
- self._write_subtomo_to_scilog(subtomo_number)
+ elif self.tomo_type == 2:
+ # Golden ratio tomography
+ previous_subtomo_number = -1
+ if projection_number == None:
+ ii = 0
+ else:
+ ii = projection_number
+ while True:
+ angle, subtomo_number = self._golden(
+ ii, self.golden_ratio_bunch_size, 180, 1
+ )
+ if previous_subtomo_number != subtomo_number:
+ self._write_subtomo_to_scilog(subtomo_number)
+ if (
+ subtomo_number % 2 == 1
+ and ii > 10
+ and self.golden_projections_at_0_deg_for_damage_estimation == 1
+ ):
+ self._tomo_scan_at_angle(0, subtomo_number)
+ previous_subtomo_number = subtomo_number
+ self.progress["tomo_type"] = "Golden ratio tomography"
+ self.progress["subtomo"] = subtomo_number
+ self.progress["projection"] = ii
+ self.progress["angle"] = angle
+ if self.golden_ratio_bunch_size > 0:
+ self.progress["subtomo_total_projections"] = (
+ self.golden_ratio_bunch_size
+ )
+ self.progress["subtomo_projection"] = (
+ ii - (subtomo_number - 1) * self.golden_ratio_bunch_size
+ )
+ else:
+ self.progress["subtomo_total_projections"] = 0
+ self.progress["subtomo_projection"] = 0
+
+ if self.golden_max_number_of_projections > 0:
+ self.progress["total_projections"] = (
+ self.golden_max_number_of_projections
+ )
+ else:
+ self.progress["total_projections"] = 0
+
+ self._tomo_scan_at_angle(angle, subtomo_number)
+ ii += 1
if (
- subtomo_number % 2 == 1
- and ii > 10
- and self.golden_projections_at_0_deg_for_damage_estimation == 1
+ ii > self.golden_max_number_of_projections
+ and self.golden_max_number_of_projections > 0
):
- self._tomo_scan_at_angle(0, subtomo_number)
- previous_subtomo_number = subtomo_number
- self.progress["tomo_type"] = "Golden ratio tomography"
- self.progress["subtomo"] = subtomo_number
- self.progress["projection"] = ii
- self.progress["angle"] = angle
- if self.golden_ratio_bunch_size > 0:
- self.progress["subtomo_total_projections"] = self.golden_ratio_bunch_size
+ print(
+ f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections"
+ )
+ break
+
+ elif self.tomo_type == 3:
+ # Equally spaced tomography, golden ratio starting angle
+ previous_subtomo_number = -1
+ if projection_number == None:
+ ii = 0
+ else:
+ ii = projection_number
+ while True:
+ angle, subtomo_number = self._golden_equally_spaced(
+ ii, int(180 / self.tomo_angle_stepsize), 180, 1, 0
+ )
+ if previous_subtomo_number != subtomo_number:
+ self._write_subtomo_to_scilog(subtomo_number)
+ if (
+ subtomo_number % 2 == 1
+ and ii > 10
+ and self.golden_projections_at_0_deg_for_damage_estimation == 1
+ ):
+ self._tomo_scan_at_angle(0, subtomo_number)
+ previous_subtomo_number = subtomo_number
+ self.progress["tomo_type"] = (
+ "Equally spaced tomography, golden ratio starting angle"
+ )
+ self.progress["subtomo"] = subtomo_number
+ self.progress["projection"] = ii
+ self.progress["angle"] = angle
+
+ self.progress["subtomo_total_projections"] = 180 / self.tomo_angle_stepsize
self.progress["subtomo_projection"] = (
- ii - (subtomo_number - 1) * self.golden_ratio_bunch_size
+ ii - (subtomo_number - 1) * self.progress["subtomo_total_projections"]
)
- else:
- self.progress["subtomo_total_projections"] = 0
- self.progress["subtomo_projection"] = 0
- if self.golden_max_number_of_projections > 0:
- self.progress["total_projections"] = self.golden_max_number_of_projections
- else:
- self.progress["total_projections"] = 0
-
- self._tomo_scan_at_angle(angle, subtomo_number)
- ii += 1
- if (
- ii > self.golden_max_number_of_projections
- and self.golden_max_number_of_projections > 0
- ):
- print(
- f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections"
- )
- break
-
- elif self.tomo_type == 3:
- # Equally spaced tomography, golden ratio starting angle
- previous_subtomo_number = -1
- if projection_number == None:
- ii = 0
- else:
- ii = projection_number
- while True:
- angle, subtomo_number = self._golden_equally_spaced(
- ii, int(180 / self.tomo_angle_stepsize), 180, 1, 0
- )
- if previous_subtomo_number != subtomo_number:
- self._write_subtomo_to_scilog(subtomo_number)
+ if self.golden_max_number_of_projections > 0:
+ self.progress["total_projections"] = (
+ self.golden_max_number_of_projections
+ )
+ else:
+ self.progress["total_projections"] = 0
+ self._tomo_scan_at_angle(angle, subtomo_number)
+ ii += 1
if (
- subtomo_number % 2 == 1
- and ii > 10
- and self.golden_projections_at_0_deg_for_damage_estimation == 1
+ ii > self.golden_max_number_of_projections
+ and self.golden_max_number_of_projections > 0
):
- self._tomo_scan_at_angle(0, subtomo_number)
- previous_subtomo_number = subtomo_number
- self.progress["tomo_type"] = (
- "Equally spaced tomography, golden ratio starting angle"
- )
- self.progress["subtomo"] = subtomo_number
- self.progress["projection"] = ii
- self.progress["angle"] = angle
+ print(
+ f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections"
+ )
+ break
+ else:
+ raise OMNYError("undefined tomo type")
+ finally:
+ # Cleared on every exit path (normal completion, exception, or
+ # interrupt) -- not just at the next scan's start above -- so the
+ # GUI's busy-detectors see this immediately on their next poll
+ # instead of waiting out the heartbeat-staleness timeout after a
+ # tomogram that's actually already done. Mirrors flomni's/lamni's
+ # identical fix.
+ self.progress["heartbeat"] = None
- self.progress["subtomo_total_projections"] = 180 / self.tomo_angle_stepsize
- self.progress["subtomo_projection"] = (
- ii - (subtomo_number - 1) * self.progress["subtomo_total_projections"]
- )
+ def tomo_scan_resume(self) -> None:
+ """Resume a tomo_scan() that crashed or was interrupted, picking up
+ automatically from wherever ``progress`` last reported -- no need to
+ read the last subtomo/angle/projection off the progress printout by
+ hand and pass it to tomo_scan() yourself.
- if self.golden_max_number_of_projections > 0:
- self.progress["total_projections"] = self.golden_max_number_of_projections
- else:
- self.progress["total_projections"] = 0
- self._tomo_scan_at_angle(angle, subtomo_number)
- ii += 1
- if (
- ii > self.golden_max_number_of_projections
- and self.golden_max_number_of_projections > 0
- ):
- print(
- f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections"
- )
- break
- else:
- raise OMNYError("undefined tomo type")
+ Re-attempts the exact angle/projection that was in progress at the
+ moment of interruption, rather than skipping ahead to the next one:
+ from here we can't be sure whether that last point was actually
+ acquired before the crash, and re-acquiring one extra projection is
+ harmless.
+
+ Works standalone for a normal tomo_scan(), independent of the tomo
+ queue. tomo_queue_execute() (see TomoQueueMixin) also calls this
+ internally when resuming a job that previously failed partway
+ through, so a failed queue job picks up mid-scan rather than
+ restarting from subtomo 1 / angle 0. Direct port of flomni's/lamni's
+ identical method, generalized to OMNY's three equally-spaced-
+ sub-tomogram flavors (tomo_type 1/4/5) via _TOMO_TYPE_1_LIKE.
+ """
+ if self.progress.get("tomo_start_time") is None:
+ print("No tomo scan in progress to resume -- nothing to do.")
+ return
+
+ if self.tomo_type in self._TOMO_TYPE_1_LIKE:
+ subtomo_start = self.progress["subtomo"]
+ start_angle = self.progress["angle"]
+ if subtomo_start < 1:
+ print("No tomo scan in progress to resume -- nothing to do.")
+ return
+ print(f"Resuming tomo scan at subtomo {subtomo_start}, angle {start_angle:.3f} deg.")
+ self.tomo_scan(subtomo_start=subtomo_start, start_angle=start_angle)
+ elif self.tomo_type in (2, 3):
+ projection_number = self.progress["projection"]
+ print(f"Resuming tomo scan at projection {projection_number}.")
+ self.tomo_scan(projection_number=projection_number)
+ else:
+ raise OMNYError("undefined tomo type")
+
+ def _resolve_type1_projection(self, projection_number: int) -> tuple[int, float]:
+ """Map a flat 0-indexed projection number to (subtomo_start,
+ start_angle), for tomo_queue_reacquire() (TomoQueueMixin) on any
+ tomo_type in _TOMO_TYPE_1_LIKE (1/4/5).
+
+ Reuses _equally_spaced_subtomo_angles() (the single, already-verified
+ source of truth for this angle grid -- see
+ omny/AI_docs/OPEN_ISSUES.md) rather than re-deriving the math here.
+ """
+ n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type]
+ N = int(180.0 / self.tomo_angle_stepsize)
+ total = N * n_subtomos
+ if not 0 <= projection_number < total:
+ raise ValueError(
+ f"projection_number must be in [0, {total}) for tomo_type "
+ f"{self.tomo_type} ({n_subtomos} sub-tomograms, {N} projections each), "
+ f"got {projection_number}."
+ )
+ subtomo_start = projection_number // N + 1
+ projection_within_subtomo = projection_number % N
+ angles = self._equally_spaced_subtomo_angles(
+ subtomo_start, n_subtomos, self.tomo_angle_stepsize
+ )
+ start_angle = float(angles[projection_within_subtomo])
+ return subtomo_start, start_angle
def _print_progress(self):
print("\x1b[95mProgress report:")
@@ -1210,6 +1497,12 @@ class OMNY(
return tomo_number
def _at_each_angle(self, angle: float) -> None:
+ hook_name = self.at_each_angle_hook
+ if hook_name:
+ hook = self._resolve_at_each_angle_hook(hook_name, OMNYError)
+ hook(self, angle)
+ return
+
if "omny_at_each_angle" in builtins.__dict__:
# pylint: disable=undefined-variable
omny_at_each_angle(self, angle)
@@ -1555,6 +1848,15 @@ class OMNY(
fzp_details = self._get_user_param_safe("ofzpx", "fzp_details")
except Exception:
fzp_details = "N/A"
+ # The energy readback device is "ccm_energy", not "mokev" -- OMNY has
+ # no mokev device in any config (real or simulated). Read it
+ # defensively so a missing/misbehaving device doesn't crash the
+ # whole report instead of just omitting one line.
+ try:
+ energy_kev = dev.ccm_energy.read(cached=True)[dev.ccm_energy.name]["value"]
+ energy_str = f"{energy_kev:>{padding}.4f}"
+ except Exception:
+ energy_str = f"{'N/A':>{padding}}"
content = [
f"{'Sample Name:':<{padding}}{self.sample_name:>{padding}}\n",
f"{'Measurement ID:':<{padding}}{str(self.tomo_id):>{padding}}\n",
@@ -1564,7 +1866,7 @@ class OMNY(
f"{'Number of projections:':<{padding}}{int(180 / self.tomo_angle_stepsize * 8):>{padding}}\n",
f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number:>{padding}}\n",
f"{'Last scan number approx.:':<{padding}}{self.client.queue.next_scan_number + int(180 / self.tomo_angle_stepsize * 8) + 10:>{padding}}\n",
- f"{'Current photon energy:':<{padding}}{dev.mokev.read()['mokev']['value']:>{padding}.4f}\n",
+ f"{'Current photon energy:':<{padding}}{energy_str}\n",
f"{'Exposure time:':<{padding}}{self.tomo_countingtime:>{padding}.2f}\n",
f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:>{padding}.2f}\n",
f"{'FOV:':<{padding}}{fovxy:>{padding}}\n",
@@ -1583,12 +1885,19 @@ class OMNY(
"xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True
)
# status = subprocess.run(f"cp /tmp/spec-e20131-specES1.pdf {user_target}", shell=True)
- msg = bec.logbook.LogbookMessage()
- logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LamNI_logo.png")
- msg.add_file(logo_path).add_text("".join(content).replace("\n", "
")).add_tag(
- ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "LamNI", self.sample_name]
- )
- self.client.logbook.send_logbook_message(msg)
+ # Same tolerance as write_to_scilog(): a session without scilog/
+ # logbook configured (e.g. a dev/sim session) must not crash report
+ # generation over the logbook upload -- the PDF itself is already
+ # written above.
+ try:
+ msg = bec.logbook.LogbookMessage()
+ logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "OMNY.png")
+ msg.add_file(logo_path).add_text("".join(content).replace("\n", "
")).add_tag(
+ ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "OMNY", self.sample_name]
+ )
+ self.client.logbook.send_logbook_message(msg)
+ except Exception:
+ logger.warning("Failed to write PDF report to scilog.")
def vcs_show_all():
dev.omny_vcs.show_all()
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
new file mode 100644
index 00000000..6ad9cca9
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
@@ -0,0 +1,317 @@
+"""Unit tests for the tomo-queue / at-each-angle-hook backend ported to
+OMNY from flomni/lamni (see TomoQueueMixin in
+csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py).
+
+Builds an OMNY instance without running __init__'s heavy side effects
+(device init, gui_tools, x-ray-eye alignment, ...) -- only what the
+tomo-queue backend actually needs: self.client + _init_tomo_queue().
+Mirrors test_lamni_tomo_queue.py's structure exactly, plus OMNY-specific
+coverage for its three "equally spaced sub-tomograms" tomo_type flavors
+(1/4/5, via _TOMO_TYPE_1_LIKE) that flomni/lamni don't have.
+"""
+
+import types
+
+import numpy as np
+
+import csaxs_bec.bec_ipython_client.plugins.omny.omny as omny_module
+import csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin as tomo_queue_mixin
+from csaxs_bec.bec_ipython_client.plugins.omny.omny import OMNY, OMNYError, _ProgressProxy
+
+
+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
+
+
+class FakeOMNYTools:
+ def __init__(self, answer=True):
+ self.answer = answer
+
+ def yesno(self, *args, **kwargs):
+ return self.answer
+
+
+def make_omny(fake_client=None):
+ # sample_name reads the module-level `dev` (set at import time inside a
+ # real BEC session only) -- stand in a fake so tomo_queue_execute()'s
+ # confirmation prompt (which reads self.sample_name) doesn't NameError.
+ omny_module.dev = types.SimpleNamespace(
+ omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ )
+
+ obj = object.__new__(OMNY)
+ obj.client = fake_client or FakeClient()
+ obj.OMNYTools = FakeOMNYTools()
+ obj._progress_proxy = _ProgressProxy(obj.client)
+ obj._init_tomo_queue()
+ return obj
+
+
+def test_tomo_scan_param_names_have_no_flomni_only_concepts():
+ """OMNY mirrors flomni's names for shared concepts, but has no
+ 180/360 toggle, no single-point acquisition, no zero-deg reference,
+ and no frames_per_trigger -- none of these exist as OMNY properties."""
+ names = OMNY._TOMO_SCAN_PARAM_NAMES
+ assert "tomo_angle_range" not in names
+ assert "single_point_instead_of_fermat_scan" not in names
+ assert "single_point_random_shift_max" not in names
+ assert "zero_deg_reference_at_each_subtomo" not in names
+ assert "frames_per_trigger" not in names
+ # shared-with-flomni names are present, using OMNY's own property names
+ assert "fovx" in names
+ assert "stitch_x" in names
+ assert "tomo_angle_stepsize" in names
+
+
+def test_tomo_queue_add_snapshots_all_param_names():
+ omny = make_omny()
+ omny.tomo_countingtime = 0.2
+ omny.fovx = 42.0
+
+ index = omny.tomo_queue_add(label="my job")
+
+ assert index == 0
+ jobs = omny._tomo_queue_proxy.as_list()
+ assert len(jobs) == 1
+ job = jobs[0]
+ assert job["label"] == "my job"
+ assert job["kind"] == "tomo"
+ assert job["status"] == "pending"
+ assert set(job["params"]) == set(OMNY._TOMO_SCAN_PARAM_NAMES)
+ assert job["params"]["tomo_countingtime"] == 0.2
+ assert job["params"]["fovx"] == 42.0
+
+
+def test_tomo_queue_show_survives_missing_param_key(capsys):
+ """A job snapshotted before some key existed in _TOMO_SCAN_PARAM_NAMES
+ won't carry it in its persisted params dict -- tomo_queue_show() must
+ still print a line for it instead of raising KeyError."""
+ omny = make_omny()
+ omny.fovx = 15.0
+ omny.tomo_queue_add(label="job1")
+ jobs = omny._tomo_queue_proxy.as_list()
+ del jobs[0]["params"]["fovx"]
+ omny.client.set_global_var("tomo_queue", jobs)
+
+ result = omny.tomo_queue_show()
+
+ assert result[0]["label"] == "job1"
+ capsys.readouterr() # just confirm no crash while printing
+
+
+def test_tomo_queue_execute_runs_fresh_job_then_marks_done():
+ omny = make_omny()
+ calls = []
+ omny.tomo_scan = lambda *a, **k: calls.append(("scan", a, k))
+ omny.tomo_scan_resume = lambda *a, **k: calls.append(("resume", a, k))
+
+ omny.tomo_queue_add(label="job1")
+ omny.tomo_queue_execute()
+
+ assert calls == [("scan", (), {"interactive": False})]
+ job = omny._tomo_queue_proxy.as_list()[0]
+ assert job["status"] == "done"
+
+
+def test_tomo_queue_execute_resumes_incomplete_job():
+ omny = make_omny()
+ calls = []
+ omny.tomo_scan = lambda *a, **k: calls.append(("scan", a, k))
+ omny.tomo_scan_resume = lambda *a, **k: calls.append(("resume", a, k))
+
+ index = omny.tomo_queue_add(label="job1")
+ omny._tomo_queue_proxy.update(index, status="incomplete")
+
+ omny.tomo_queue_execute()
+
+ assert calls == [("resume", (), {})]
+ job = omny._tomo_queue_proxy.as_list()[0]
+ assert job["status"] == "done"
+
+
+def test_tomo_queue_add_command_move_action(monkeypatch):
+ omny = make_omny()
+ fake_dev = {"ccm_energy": "ccm-energy-device"}
+ moved = []
+ monkeypatch.setattr(tomo_queue_mixin, "dev", fake_dev, raising=False)
+ monkeypatch.setattr(tomo_queue_mixin, "umv", lambda *a: moved.append(a), raising=False)
+
+ omny.tomo_queue_add_command(
+ {"action": "move", "kwargs": {"positions": {"ccm_energy": 6.2}}}, label="reconfigure"
+ )
+ omny.tomo_queue_execute()
+
+ assert moved == [("ccm-energy-device", 6.2)]
+ job = omny._tomo_queue_proxy.as_list()[0]
+ assert job["kind"] == "command"
+ assert job["status"] == "done"
+
+
+def test_tomo_queue_add_command_rejects_non_movable_device():
+ omny = make_omny()
+ try:
+ omny.tomo_queue_add_command({"action": "move", "kwargs": {"positions": {"fzp": 1.0}}})
+ assert False, "expected ValueError for a non-allow-listed device"
+ except ValueError as exc:
+ assert "not queue-movable" in str(exc)
+
+
+def test_register_at_each_angle_hook_dispatches_from_at_each_angle():
+ omny = make_omny()
+ seen = []
+ omny.register_at_each_angle_hook("record", lambda self, angle: seen.append(angle))
+ omny.at_each_angle_hook = "record"
+
+ omny._at_each_angle(12.5)
+
+ assert seen == [12.5]
+ assert omny.client.get_global_var("tomo_at_each_angle_hooks") == ["record"]
+
+
+def test_at_each_angle_default_path_unchanged_without_hook():
+ """No hook registered -- must fall through to the exact same default
+ behaviour omny had before this port: tomo_scan_projection + tomo_reconstruct."""
+ omny = make_omny()
+ calls = []
+ omny.tomo_scan_projection = lambda angle: calls.append(("projection", angle))
+ omny.tomo_reconstruct = lambda: calls.append(("reconstruct",))
+
+ omny._at_each_angle(30.0)
+
+ assert calls == [("projection", 30.0), ("reconstruct",)]
+
+
+def test_at_each_angle_falls_through_to_legacy_builtins_hook(monkeypatch):
+ """The pre-existing omny_at_each_angle builtins hook must still work as
+ the second-tier fallback, when no at_each_angle_hook is registered."""
+ import builtins
+
+ omny = make_omny()
+ calls = []
+ monkeypatch.setitem(
+ builtins.__dict__, "omny_at_each_angle", lambda setup, angle: calls.append(angle)
+ )
+
+ omny._at_each_angle(7.0)
+
+ assert calls == [7.0]
+
+
+def test_at_each_angle_raises_for_unregistered_hook_name():
+ omny = make_omny()
+ omny.at_each_angle_hook = "missing_hook"
+ try:
+ omny._at_each_angle(0.0)
+ assert False, "expected OMNYError for an unregistered hook name"
+ except OMNYError as exc:
+ assert "missing_hook" in str(exc)
+ assert "not registered" in str(exc)
+
+
+# --- OMNY-specific: tomo_scan_resume() over three sub-tomogram flavors -------
+
+
+def test_tomo_scan_resume_dispatches_subtomo_angle_for_type_1_4_5():
+ for tomo_type, expected_subtomo, expected_angle in [(1, 2, 55.0), (4, 3, 20.0), (5, 7, 90.0)]:
+ omny = make_omny()
+ omny.tomo_type = tomo_type
+ omny.progress["tomo_start_time"] = "2026-01-01T00:00:00"
+ omny.progress["subtomo"] = expected_subtomo
+ omny.progress["angle"] = expected_angle
+ calls = []
+ omny.tomo_scan = lambda *a, **k: calls.append(("scan", a, k))
+
+ omny.tomo_scan_resume()
+
+ assert calls == [
+ ("scan", (), {"subtomo_start": expected_subtomo, "start_angle": expected_angle})
+ ], tomo_type
+
+
+def test_tomo_scan_resume_dispatches_projection_number_for_type_2_3():
+ for tomo_type in (2, 3):
+ omny = make_omny()
+ omny.tomo_type = tomo_type
+ omny.progress["tomo_start_time"] = "2026-01-01T00:00:00"
+ omny.progress["projection"] = 42
+ calls = []
+ omny.tomo_scan = lambda *a, **k: calls.append(("scan", a, k))
+
+ omny.tomo_scan_resume()
+
+ assert calls == [("scan", (), {"projection_number": 42})], tomo_type
+
+
+def test_tomo_scan_resume_noop_when_nothing_in_progress(capsys):
+ omny = make_omny()
+ calls = []
+ omny.tomo_scan = lambda *a, **k: calls.append(a)
+
+ omny.tomo_scan_resume()
+
+ assert calls == []
+ assert "nothing to do" in capsys.readouterr().out
+
+
+# --- OMNY-specific: _resolve_type1_projection() for n_subtomos in {2,4,8} ---
+
+
+def test_resolve_type1_projection_matches_equally_spaced_subtomo_angles():
+ """Cross-check against _equally_spaced_subtomo_angles() itself -- the
+ single source of truth for this angle grid -- rather than
+ re-deriving/duplicating the math in the test."""
+ for tomo_type, n_subtomos in [(1, 2), (4, 4), (5, 8)]:
+ omny = make_omny()
+ omny.tomo_type = tomo_type
+ omny.tomo_angle_stepsize = 10.0
+ N = int(180.0 / omny.tomo_angle_stepsize)
+
+ for projection_number in range(N * n_subtomos):
+ subtomo_start, start_angle = omny._resolve_type1_projection(projection_number)
+ expected_subtomo = projection_number // N + 1
+ expected_angle = OMNY._equally_spaced_subtomo_angles(
+ expected_subtomo, n_subtomos, omny.tomo_angle_stepsize
+ )[projection_number % N]
+ assert subtomo_start == expected_subtomo
+ assert np.isclose(start_angle, expected_angle)
+
+
+def test_resolve_type1_projection_rejects_out_of_range():
+ omny = make_omny()
+ omny.tomo_type = 1
+ omny.tomo_angle_stepsize = 10.0
+ N = int(180.0 / omny.tomo_angle_stepsize)
+ total = N * 2 # n_subtomos=2 for tomo_type 1
+
+ try:
+ omny._resolve_type1_projection(total)
+ assert False, "expected ValueError for an out-of-range projection number"
+ except ValueError:
+ pass
+
+
+def test_tomo_queue_reacquire_dispatches_type1_like_for_types_1_4_5():
+ """tomo_queue_reacquire() (TomoQueueMixin) hardcodes tomo_type == 1 by
+ default; OMNY overrides _TOMO_TYPE_1_LIKE to {1, 4, 5} so reacquiring a
+ type-4/5 job also resolves via _resolve_type1_projection() instead of
+ hitting the mixin's "unknown tomo_type" branch."""
+ for tomo_type, n_subtomos in [(1, 2), (4, 4), (5, 8)]:
+ omny = make_omny()
+ omny.tomo_type = tomo_type
+ omny.tomo_angle_stepsize = 10.0
+ omny.tomo_queue_add(label="job1")
+
+ omny.tomo_queue_reacquire(0, projection_number=1)
+
+ job = omny._tomo_queue_proxy.as_list()[0]
+ assert job["status"] == "incomplete", tomo_type
+ assert omny.progress["subtomo"] in range(1, n_subtomos + 1), tomo_type
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
new file mode 100644
index 00000000..ade871ba
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
@@ -0,0 +1,183 @@
+"""Unit tests for tomo_scan()'s new-scan account handling, progress-GUI
+auto-show, and heartbeat-clearing -- all part of porting the tomo-queue
+backend (TomoQueueMixin) to OMNY. Mirrors test_lamni_tomo_angles.py's
+equivalent tests exactly (same bug shapes, same fixes, direct ports of
+flomni's/lamni's already-fixed code).
+
+bec.active_account used to be passed through .decode() (as if it were
+bytes) -- it's a plain str, so this raised AttributeError on every real
+tomo_scan() call with a real account. Unlike flomni/lamni (which always
+attempt sample-database registration, letting it decide the empty-account
+case), OMNY's existing "if bec.active_account != '': ... else: tomo_id = 0"
+branch structure is preserved as-is here -- only the .decode() bug itself
+is fixed, not the branch structure (no TomoIDManager/test-server fallback
+exists in OMNY's own add_sample_database()).
+"""
+
+import builtins
+import types
+
+import pytest
+
+import csaxs_bec.bec_ipython_client.plugins.omny.omny as omny_module
+from csaxs_bec.bec_ipython_client.plugins.omny.omny import OMNY, _ProgressProxy
+
+
+class FakeGlobalVarClient:
+ """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
+
+
+class _FakeContextManager:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc_info):
+ return False
+
+
+class _FakeScans:
+ dataset_id_on_hold = _FakeContextManager()
+
+
+def make_omny_for_tomo_scan(monkeypatch, tomo_angle_stepsize: float, active_account: str) -> OMNY:
+ """Bare OMNY instance with tomo_scan()'s "new scan" branch reachable,
+ everything downstream of it stubbed out (sample database, PDF report,
+ per-sub-tomogram scanning) so only the logic under test actually runs."""
+ # sample_name reads the module-level `dev` (set at import time inside a
+ # real BEC session only) -- stand in a fake so tomo_scan()'s sample-
+ # database registration (which reads self.sample_name) doesn't NameError.
+ omny_module.dev = types.SimpleNamespace(
+ omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ )
+
+ obj = object.__new__(OMNY)
+ obj.client = FakeGlobalVarClient()
+ obj._progress_proxy = _ProgressProxy(obj.client)
+ obj.tomo_type = 1
+ obj.tomo_angle_stepsize = tomo_angle_stepsize
+ obj.special_angles = []
+ obj._current_special_angles = []
+ obj.write_pdf_report = lambda: None
+ obj.sub_tomo_scan = lambda subtomo_number, n_subtomos=2, start_angle=None: None
+ obj.omnygui_show_progress = lambda: None
+ monkeypatch.setitem(
+ builtins.__dict__,
+ "bec",
+ types.SimpleNamespace(
+ active_account=active_account, queue=types.SimpleNamespace(next_scan_number=1)
+ ),
+ )
+ monkeypatch.setitem(builtins.__dict__, "scans", _FakeScans())
+ return obj
+
+
+def test_tomo_scan_skips_sample_database_without_active_account(monkeypatch):
+ """Empty active_account (e.g. a dev/sim session) must not crash and must
+ not attempt sample-database registration -- OMNY's own add_sample_database()
+ has no test-server/empty-account fallback, unlike flomni's/lamni's."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ called = []
+ omny.add_sample_database = lambda *a, **k: called.append(a) or 99
+
+ omny.tomo_scan()
+
+ assert called == []
+ assert omny.tomo_id == 0
+
+
+def test_tomo_scan_registers_sample_with_plain_string_account(monkeypatch):
+ """A real active_account must be passed through as-is -- calling
+ .decode() on it (a plain str, not bytes) raises AttributeError."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="e12345")
+ recorded = {}
+
+ def _fake_add_sample_database(samplename, date, eaccount, scan_number, setup, info, user):
+ recorded["eaccount"] = eaccount
+ return 42
+
+ omny.add_sample_database = _fake_add_sample_database
+
+ omny.tomo_scan()
+
+ assert recorded["eaccount"] == "e12345"
+ assert omny.tomo_id == 42
+
+
+def test_tomo_scan_shows_progress_gui(monkeypatch):
+ """tomo_scan() must open/show the progress GUI unconditionally at the
+ start of every scan -- previously only shown per-angle inside
+ _tomo_scan_at_angle(), never at scan start."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ calls = []
+ omny.omnygui_show_progress = lambda: calls.append("shown")
+
+ omny.tomo_scan()
+
+ assert calls == ["shown"]
+
+
+def test_tomo_scan_clears_heartbeat_on_normal_completion(monkeypatch):
+ """The busy-detector heartbeat must be cleared as soon as the scan
+ finishes, not left for the next poll to time out before the GUI stops
+ showing "beamline busy" for an already-finished scan.
+
+ sub_tomo_scan() is stubbed to set a fresh heartbeat before returning,
+ the same way the real _tomo_scan_at_angle() does at the start of every
+ projection -- without that, this test would pass even without the fix,
+ since tomo_scan()'s own "new scan" branch already resets the heartbeat
+ to None *before* the scan body runs.
+ """
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+
+ def _fake_sub_tomo_scan(subtomo_number, n_subtomos=2, start_angle=None):
+ omny.progress["heartbeat"] = "2026-01-01T00:00:00"
+
+ omny.sub_tomo_scan = _fake_sub_tomo_scan
+
+ omny.tomo_scan()
+
+ assert omny.progress["heartbeat"] is None
+
+
+def test_tomo_scan_clears_heartbeat_even_if_scan_raises(monkeypatch):
+ """The heartbeat must be cleared on every exit path, including a
+ mid-scan exception -- otherwise a crashed scan looks permanently "busy"
+ to the GUI until the staleness timeout, or forever if polled more often
+ than that."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+
+ def _boom(subtomo_number, n_subtomos=2, start_angle=None):
+ omny.progress["heartbeat"] = "2026-01-01T00:00:00"
+ raise RuntimeError("simulated crash mid-scan")
+
+ omny.sub_tomo_scan = _boom
+
+ with pytest.raises(RuntimeError):
+ omny.tomo_scan()
+
+ assert omny.progress["heartbeat"] is None
+
+
+def test_tomo_scan_sets_timing_fields_on_new_scan(monkeypatch):
+ """A fresh tomo_scan() must stamp tomo_start_time and reset stale
+ ETA/idle-time estimates from any previous scan."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ omny.progress["estimated_remaining_time"] = "stale"
+ omny.progress["estimated_finish_time"] = "stale"
+ omny.progress["accumulated_idle_time"] = 123.0
+
+ omny.tomo_scan()
+
+ assert omny.progress["tomo_start_time"] is not None
+ assert omny.progress["estimated_remaining_time"] is None
+ assert omny.progress["estimated_finish_time"] is None
+ assert omny.progress["accumulated_idle_time"] == 0.0
From 2eaa609d296b516077d5a58c937c7d091fa50e13 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 17:46:14 +0200
Subject: [PATCH 025/102] fix(omny): migrate scilog writes to
bec.messaging.scilog, checking _enabled
write_to_scilog()/write_pdf_report() used the old bec.logbook/client.logbook.
send_logbook_message() API, which has no "is scilog even configured" check.
Confirmed live: on a session without scilog credentials (this dev deployment
has no /etc/bec/secrets/.scilog.env at all), send() blocks indefinitely
instead of failing fast -- a real tomo_scan() hung inside
_write_subtomo_to_scilog() for 29+ minutes during Phase 1 live testing.
flomni already migrated off this API to bec.messaging.scilog, which gates
every send behind scilog._enabled (published from the deployment's actual
scilog config) -- per Mirko's direction, ported the same mechanism to both
OMNY call sites, matching flomni's write_pdf_report()/_scilog_write()
exactly rather than merely adding an active_account check.
Added 3 unit tests locking in the _enabled gate (skip when disabled, send
when enabled, tolerate a send() exception).
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../bec_ipython_client/plugins/omny/omny.py | 71 ++++++++++++------
.../test_omny_tomo_scan.py | 74 +++++++++++++++++++
2 files changed, 123 insertions(+), 22 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index c40e74b1..231194a4 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -1066,17 +1066,36 @@ class OMNY(
def sample_name(self):
return dev.omny_samples.get_sample_name_in_samplestage()
- def write_to_scilog(self, content, tags: list = None):
+ def write_to_scilog(self, content, tags: list = None) -> bool:
+ """Write a text entry to scilog, tolerating scilog being disabled or
+ unreachable. Mirrors flomni's _scilog_write(): checks
+ scilog._enabled before attempting a send, rather than trying and
+ catching whatever exception (or hang) an unconfigured/unreachable
+ scilog produces -- the old bec.logbook/client.logbook.
+ send_logbook_message() API used here previously had no such check,
+ so a session without scilog credentials configured would block
+ indefinitely on send() instead of failing fast (confirmed live: a
+ real tomo_scan() hung inside _write_subtomo_to_scilog() on a
+ session with no /etc/bec/secrets/.scilog.env at all).
+
+ Returns True if the entry was sent, False if it was skipped/failed
+ (already logged as a warning either way -- callers that don't care
+ about the outcome can ignore the return value).
+ """
+ scilog = getattr(bec.messaging, "scilog", None)
+ if scilog is None or not getattr(scilog, "_enabled", False):
+ logger.warning("scilog is not enabled; skipping scilog entry.")
+ return False
+ if tags is not None:
+ tags.append("BEC")
+ else:
+ tags = ["BEC"]
try:
- if tags is not None:
- tags.append("BEC")
- else:
- tags = ["BEC"]
- msg = bec.logbook.LogbookMessage()
- msg.add_text(content).add_tag(tags)
- self.client.logbook.send_logbook_message(msg)
- except Exception:
- logger.warning("Failed to write to scilog.")
+ scilog.new().add_text(content.replace("\n", "
")).add_tags(tags).send()
+ return True
+ except Exception as exc:
+ logger.warning(f"Failed to write to scilog: {exc}")
+ return False
def tomo_alignment_scan(self):
"""
@@ -1885,19 +1904,27 @@ class OMNY(
"xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True
)
# status = subprocess.run(f"cp /tmp/spec-e20131-specES1.pdf {user_target}", shell=True)
- # Same tolerance as write_to_scilog(): a session without scilog/
- # logbook configured (e.g. a dev/sim session) must not crash report
- # generation over the logbook upload -- the PDF itself is already
- # written above.
- try:
- msg = bec.logbook.LogbookMessage()
+ # bec.logbook/client.logbook.send_logbook_message() was the old,
+ # pre-migration scilog API -- it has no "is scilog even configured"
+ # check, so on a session without scilog credentials (e.g. this dev
+ # sandbox has no /etc/bec/secrets/.scilog.env at all) the send()
+ # call blocks indefinitely instead of failing fast, hanging the
+ # whole tomo scan. flomni's write_pdf_report() uses
+ # bec.messaging.scilog instead, which is enabled/disabled based on
+ # the deployment's actual published scilog config -- mirrored here.
+ scilog = getattr(bec.messaging, "scilog", None)
+ if scilog is None or not getattr(scilog, "_enabled", False):
+ logger.warning("scilog is not enabled; skipping PDF report entry.")
+ else:
logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "OMNY.png")
- msg.add_file(logo_path).add_text("".join(content).replace("\n", "
")).add_tag(
- ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "OMNY", self.sample_name]
- )
- self.client.logbook.send_logbook_message(msg)
- except Exception:
- logger.warning("Failed to write PDF report to scilog.")
+ try:
+ scilog.new().add_attachment(logo_path, width=200).add_text(
+ content.replace("\n", "
")
+ ).add_tags(
+ ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "OMNY", self.sample_name]
+ ).send()
+ except Exception as exc:
+ logger.warning(f"Failed to write PDF report to scilog: {exc}")
def vcs_show_all():
dev.omny_vcs.show_all()
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
index ade871ba..202d3a20 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
@@ -181,3 +181,77 @@ def test_tomo_scan_sets_timing_fields_on_new_scan(monkeypatch):
assert omny.progress["estimated_remaining_time"] is None
assert omny.progress["estimated_finish_time"] is None
assert omny.progress["accumulated_idle_time"] == 0.0
+
+
+# --- write_to_scilog(): checks scilog._enabled before attempting a send -----
+#
+# The old bec.logbook/client.logbook.send_logbook_message() API had no such
+# check -- on a session without scilog credentials configured (e.g. no
+# /etc/bec/secrets/.scilog.env at all) its send() call blocked indefinitely
+# instead of failing fast, hanging a real tomo_scan() inside
+# _write_subtomo_to_scilog() (confirmed live). Mirrors flomni's
+# _scilog_write(), which gates on bec.messaging.scilog._enabled instead.
+
+
+class _FakeSciLogMessage:
+ def __init__(self, calls):
+ self._calls = calls
+
+ def add_text(self, text):
+ self._calls.append(("add_text", text))
+ return self
+
+ def add_tags(self, tags):
+ self._calls.append(("add_tags", tags))
+ return self
+
+ def send(self):
+ self._calls.append(("send",))
+
+
+def _make_omny_with_scilog(monkeypatch, enabled: bool, raise_on_send: bool = False):
+ omny = object.__new__(OMNY)
+ calls = []
+
+ def _new():
+ if raise_on_send:
+
+ class _Boom(_FakeSciLogMessage):
+ def send(self):
+ raise RuntimeError("scilog unreachable")
+
+ return _Boom(calls)
+ return _FakeSciLogMessage(calls)
+
+ fake_scilog = types.SimpleNamespace(_enabled=enabled, new=_new)
+ fake_bec = types.SimpleNamespace(messaging=types.SimpleNamespace(scilog=fake_scilog))
+ monkeypatch.setattr(omny_module, "bec", fake_bec, raising=False)
+ return omny, calls
+
+
+def test_write_to_scilog_skips_when_scilog_disabled(monkeypatch):
+ omny, calls = _make_omny_with_scilog(monkeypatch, enabled=False)
+
+ result = omny.write_to_scilog("hello")
+
+ assert result is False
+ assert calls == []
+
+
+def test_write_to_scilog_sends_when_scilog_enabled(monkeypatch):
+ omny, calls = _make_omny_with_scilog(monkeypatch, enabled=True)
+
+ result = omny.write_to_scilog("hello", tags=["mytag"])
+
+ assert result is True
+ assert ("add_text", "hello") in calls
+ assert ("add_tags", ["mytag", "BEC"]) in calls
+ assert ("send",) in calls
+
+
+def test_write_to_scilog_tolerates_send_exception(monkeypatch):
+ omny, calls = _make_omny_with_scilog(monkeypatch, enabled=True, raise_on_send=True)
+
+ result = omny.write_to_scilog("hello")
+
+ assert result is False
From f6fc8476fe13f0a5caa8c44a7c1ba6b51f68ad3c Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 17:48:08 +0200
Subject: [PATCH 026/102] feat(sample_storage): add OMNY support to
OMNY_SampleStorage widget
Extends the sample-storage bookkeeping widget, previously FlOMNI-only, to
also work against dev.omny_samples: shuttles A/B/C (6 slots each), the
sparse fixed-O positions, stage, gripper, and a per-shuttle installed/active
parking status (mirrors the oparkz-driven "active parking slot" concept
from omny_sample_transfer_mixin.py). Setup detection follows the
SETUP_PROFILES/_detect_setup() convention already used by tomo_params.py.
Also fixes SimOMNYSampleStorage, which never cleared the fixed-O/shuttle-B-C
/parking slots, so they read back as the mock-PV default "0" instead of the
real empty sentinels ("-"/"none") -- SimFlomniSampleStorage already does
this correctly for the same reason.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../widgets/sample_storage/sample_storage.py | 780 +++++++++++++++---
csaxs_bec/devices/sim/sim_omny.py | 26 +
.../test_omny_sample_storage_widget_math.py | 59 ++
3 files changed, 761 insertions(+), 104 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py
diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
index ac1c1304..0bec937c 100644
--- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
+++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
@@ -1,21 +1,43 @@
"""
-OMNY_SampleStorage – BEC widget for viewing and correcting the FlOMNI
-sample-storage bookkeeping.
+OMNY_SampleStorage – BEC widget for viewing and correcting sample-storage
+bookkeeping.
-This widget is a GUI replacement for the ``ftransfer_show`` /
-``ftransfer_modify`` CLI commands. It shows the sample magazine (slots
-1–20), the sample stage (slot 0) and the gripper (slot 100), and lets the
-user correct the stored records via a right-click context menu on each slot.
+This widget is a GUI replacement for the CLI sample-storage bookkeeping
+commands (FlOMNI's ``ftransfer_show``/``ftransfer_modify``, OMNY's
+``dev.omny_samples.show_all()``/``help()``). It supports two structurally
+different setups, detected at construction time (see ``SETUP_PROFILES``,
+``_detect_setup()``) — a BEC session is always exactly one setup, never
+both at once:
+
+FlOMNI (``dev.flomni_samples``)
+ A flat magazine: slots 1-20, the sample stage (slot 0) and the gripper
+ (slot 100). Each slot has a name *and* an owner, packed into one EPICS
+ DESC field (see ``sample_desc_codec``).
+
+OMNY (``dev.omny_samples``)
+ A richer structure: three shuttles (A/B/C) of 6 slots each, a sparse
+ set of "fixed O positions" ({10,11,12,13,14,32,33,34,100,101}), a
+ separate sample stage and gripper, and a 6-slot "parking" carousel
+ recording which shuttle letter is docked at each physical parking
+ position. No owner field -- names are raw strings. Whichever shuttle's
+ parking slot matches the ``oparkz`` motor's current readback is
+ "active", i.e. at the transfer-ready position (see
+ ``oparkz_active_parking_slot()``).
+
+A LamNI session has neither device; the widget shows a "no known setup
+detected" message in that case.
IMPORTANT – bookkeeping only
----------------------------
-This widget never moves hardware. It only reads and writes the sample
-*records* held on the ``flomni_samples`` device — exactly the same records
-that ``flomni_modify_storage_non_interactive()`` writes. It does NOT perform
-sample transfers; those remain CLI-only (``flomni.ftransfer_sample_change``
-etc.), because they are blocking hardware sequences with interactive
-confirmations. Editing a record here changes only what BEC *believes* is in
-a slot, so entries must be kept in sync with physical reality by the operator.
+This widget never moves hardware. It only reads and writes the sample
+*records* held on the setup's samples device — for FlOMNI, exactly the same
+records that ``flomni_modify_storage_non_interactive()`` writes. It does
+NOT perform sample transfers; those remain CLI-only, because they are
+blocking hardware sequences with interactive confirmations. Editing a
+record here changes only what BEC *believes* is in a slot, so entries must
+be kept in sync with physical reality by the operator. This also applies to
+OMNY's parking-slot assignments, which (like the sample slots) are pure
+bookkeeping never touched by the transfer code.
Because a transfer and a manual record edit would both happen at the same
workstation, there is deliberately no software interlock against editing
@@ -24,20 +46,22 @@ while a transfer they started is running).
Backend
-------
-All state lives on ``dev.flomni_samples`` (not BEC global vars), which is the
-single source of truth also consulted by the transfer state machine. The
-widget reads/writes it directly, mirroring
-``Flomni.flomni_modify_storage_non_interactive()``:
+All state lives on the setup's samples device (not BEC global vars), which
+is the single source of truth also consulted by the transfer state machine.
+The widget reads/writes it directly:
- slots 0–20 : sample_placed.sample{N}.set(used)
- sample_names.sample{N}.set(name)
- gripper : sample_in_gripper.set(used)
- sample_in_gripper_name.set(name)
+FlOMNI, slots 0-20 : sample_placed.sample{N}.set(used)
+ sample_names.sample{N}.set(name)
+FlOMNI, gripper : sample_in_gripper.set(used)
+ sample_in_gripper_name.set(name)
+OMNY, stage/gripper/fixed-O/shuttle slots -- see ``_write_slot_omny()``.
+OMNY, parking : parking_placed.parking{N}.set(used)
+ parking_names.parking{N}.set(letter or "none")
Polling
-------
BEC publishes no push notifications for these signals, so a ``QTimer`` polls
-every 2 s to reflect CLI-driven changes. Writes are immediate (on the
+every 2 s to reflect CLI-driven changes. Writes are immediate (on the
affirmative button of each context-menu dialog); there is no batched
edit/submit — every mutating action is individually confirmed instead.
"""
@@ -48,7 +72,7 @@ import datetime
import os
import subprocess
import tempfile
-from typing import Optional
+from typing import Any, Optional
from bec_lib import bec_logger
from bec_lib.pdf_writer import PDFWriter
@@ -57,6 +81,7 @@ from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import (
QFrame,
QGridLayout,
+ QGroupBox,
QHBoxLayout,
QInputDialog,
QLabel,
@@ -75,7 +100,7 @@ logger = bec_logger.logger
# ── constants ────────────────────────────────────────────────────────────────
-# Magazine slot numbers, laid out to match the CLI printout:
+# FlOMNI magazine slot numbers, laid out to match the CLI printout:
# 1 2 3 4 5
# 6 7 8 9 10
# 11 12 13 14 15
@@ -84,12 +109,21 @@ STORAGE_ROWS = 4
STORAGE_COLS = 5
STORAGE_SLOTS = tuple(range(1, STORAGE_ROWS * STORAGE_COLS + 1)) # 1..20
-STAGE_SLOT = 0 # sample stage (in the beam)
-GRIPPER_SLOT = 100 # gripper pseudo-slot
+STAGE_SLOT = 0 # FlOMNI sample stage (in the beam)
+GRIPPER_SLOT = 100 # FlOMNI gripper pseudo-slot
-EMPTY_NAME = "-" # backend convention for an empty slot's name
+EMPTY_NAME = "-" # backend convention for an empty sample slot's name (both setups)
EMPTY_LABEL = "— empty —" # what an empty cell shows in the GUI
+# OMNY-specific layout
+OMNY_SHUTTLE_LETTERS = ("A", "B", "C")
+OMNY_SHUTTLE_SLOTS = tuple(range(1, 7))
+OMNY_FIXED_POSITIONS = (10, 11, 12, 13, 14, 32, 33, 34, 100, 101)
+OMNY_PARKING_SLOTS = tuple(range(1, 7))
+# OMNYSampleStorage.unset_shuttle_slot()'s own empty sentinel -- unlike sample
+# slots (which use EMPTY_NAME = "-"), parking slots are cleared to "none".
+PARKING_EMPTY_SENTINEL = "none"
+
# colours
COLOR_OCCUPIED = "#1b1b1b"
COLOR_EMPTY = "#9e9e9e"
@@ -103,6 +137,8 @@ COLOR_SLOT_BORDER = "#c0c0c0"
# already-transferred template (confirmed working) -- NOT the generic
# Text1/Text2/Text6 names that OMNY_shared/flomni.lbx's own objectName
# attributes happen to use for its (different) template source file.
+# FlOMNI-only: OMNY has no measured-sample-log integration (see module
+# docstring), so it never drives this printer.
PTOUCH_PRINTER_HOST = "BRN94DDF8AAB8EC.psi.ch"
PTOUCH_TEMPLATE_NUMBER = 1
PTOUCH_FIELD_ACCOUNT = "ACCOUNT"
@@ -114,13 +150,70 @@ PTOUCH_FIELD_SAMPLENAME = "SAMPLENAME"
# call site handles the "queue not found" failure instead of pre-checking.
PRINTER_NAME = "WSLA_X12SA"
+# Which setup's samples device is used to detect that setup, mirroring the
+# established multi-setup convention in
+# bec_widgets/widgets/tomo_params/tomo_params.py's SETUP_PROFILES. Unlike
+# tomo_params (which needs a separate proxy discriminator such as
+# "fsamroy", since it doesn't itself depend on the samples device), this
+# widget's whole purpose IS that device, so the discriminator is simply the
+# device this widget will go on to read/write.
+SETUP_PROFILES: dict[str, dict[str, str]] = {
+ "flomni": {"discriminator_attr": "flomni_samples"},
+ "omny": {"discriminator_attr": "omny_samples"},
+}
+
+# oparkz motor target positions (mm) for each drivable OMNY parking slot,
+# duplicated from omny_sample_transfer_mixin.py's _oparkz_slot_check() /
+# otransfer_park_slot() rather than imported -- that mixin is a
+# CLI-session-only plugin (module-level `dev`/`bec` builtins, OMNYTools
+# dependency) that would be a heavier and more fragile coupling to pull into
+# this widget's gui-server process, exactly like _MeasuredSampleLog below is
+# deliberately duplicated rather than imported from flomni.py. Slot 1 has no
+# drivable transfer target and is never "active".
+_OPARKZ_TARGET_POSITIONS: dict[int, float] = {
+ 2: -0.9 - 29 * 3 - 42 - 0.05,
+ 3: -0.9 - 29 * 3,
+ 4: -0.9 - 29 * 2,
+ 5: -0.9 - 29,
+ 6: -0.9,
+}
+_OPARKZ_TOLERANCE = 0.05
+
+
+def oparkz_active_parking_slot(readback: float, tol: float = _OPARKZ_TOLERANCE) -> Optional[int]:
+ """Return which parking slot (2-6) the oparkz motor is currently sitting
+ at, i.e. ready for a sample transfer, or None if it's between positions
+ (or at slot 1, which has no drivable transfer target). Pure function --
+ see the module-level docstring above ``_OPARKZ_TARGET_POSITIONS`` for
+ why this duplicates rather than imports omny_sample_transfer_mixin.py's
+ ``_oparkz_slot_check()``.
+ """
+ for slot, target in _OPARKZ_TARGET_POSITIONS.items():
+ if abs(readback - target) < tol:
+ return slot
+ return None
+
+
+def shuttle_status(
+ letter: str, parking_assignment: dict[int, str], active_slot: Optional[int]
+) -> dict[str, Any]:
+ """Where OMNY shuttle `letter` is parked (if anywhere), and whether that
+ parking slot is currently the "active" one (oparkz is sitting there,
+ ready for a transfer). Pure function of a snapshot of the parking
+ carousel and the current active slot -- see ``oparkz_active_parking_slot()``.
+
+ Returns {"parked_at": Optional[int], "active": bool}.
+ """
+ parked_at = next((slot for slot, occupant in parking_assignment.items() if occupant == letter), None)
+ return {"parked_at": parked_at, "active": parked_at is not None and parked_at == active_slot}
+
# ── measured-sample log ──────────────────────────────────────────────────────
class _MeasuredSampleLog:
"""Tracks which flomni samples have been measured, for the P-touch
- label-printing feature.
+ label-printing feature. FlOMNI-only -- OMNY has no equivalent yet.
Deliberately duplicated here rather than imported from
``flomni.py``'s ``_MeasuredSampleLog`` -- pulling in that whole plugin
@@ -168,14 +261,24 @@ class _MeasuredSampleLog:
class _SlotCell(QFrame):
"""A single storage slot: shows its number and either the sample name or
- an "empty" placeholder. Right-click opens a context menu whose actions
+ an "empty" placeholder. Right-click opens a context menu whose actions
depend on whether the slot is currently occupied.
The cell does not talk to the device itself; it calls back into the
- parent widget's mutation methods, which own all backend access.
+ parent widget's mutation methods, which own all backend access. `slot`
+ is an opaque key: FlOMNI cells use a plain int (0-20, 100); OMNY cells
+ use a tuple identifying which underlying signal pair to hit (see
+ ``OMNY_SampleStorage._write_slot_omny()``). Only one setup's cells are
+ ever live on a given widget instance.
"""
- def __init__(self, slot: int, owner: "OMNY_SampleStorage", border_color: str):
+ def __init__(
+ self,
+ slot: int | tuple,
+ owner: "OMNY_SampleStorage",
+ border_color: str,
+ caption: Optional[str] = None,
+ ):
super().__init__()
self._slot = slot
self._owner = owner
@@ -193,7 +296,7 @@ class _SlotCell(QFrame):
layout.setContentsMargins(6, 4, 6, 4)
layout.setSpacing(2)
- self._lbl_num = QLabel(self._slot_caption())
+ self._lbl_num = QLabel(caption if caption is not None else self._slot_caption())
self._lbl_num.setStyleSheet("color: #888888; font-size: 10px;")
self._lbl_num.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
@@ -221,6 +324,11 @@ class _SlotCell(QFrame):
# ── labels ────────────────────────────────────────────────────────────────
def _slot_caption(self) -> str:
+ """Fallback caption for FlOMNI's plain-int keys when no explicit
+ `caption` was given. OMNY cells always pass an explicit caption
+ (see OMNY_SampleStorage._build_ui_omny()), so this int-only logic
+ never runs for them.
+ """
if self._slot == STAGE_SLOT:
return "Stage (0)"
if self._slot == GRIPPER_SLOT:
@@ -234,6 +342,9 @@ class _SlotCell(QFrame):
`measured_status` is "started"/"completed" (from `_MeasuredSampleLog`)
or None if this slot's current occupant has no measured record.
+ OMNY cells always pass owner="" and measured_status=None (no owner
+ field, no measured-sample log for OMNY), which the owner/measured
+ labels below already render as blank.
"""
self._occupied = occupied
self._name = name if name else EMPTY_NAME
@@ -279,18 +390,74 @@ class _SlotCell(QFrame):
self._owner.action_new_sample(self._slot)
+# ── OMNY shuttle status label ────────────────────────────────────────────────
+
+
+class _ShuttleStatusLabel(QLabel):
+ """Shows where one OMNY shuttle is installed (which parking slot, if
+ any) and whether that slot is currently "active" (oparkz is sitting
+ there, ready for a transfer). Right-click lets the operator assign or
+ clear this shuttle's parking-slot bookkeeping -- a different action
+ pair from the sample-cell context menu, since a parking assignment is a
+ single enum-like field (a shuttle letter, or none), not an
+ occupied+name+owner record; see OMNY_SampleStorage.action_set_parking()/
+ action_clear_parking().
+ """
+
+ def __init__(self, letter: str, owner: "OMNY_SampleStorage"):
+ super().__init__("not parked")
+ self._letter = letter
+ self._owner = owner
+ self._parked_at: Optional[int] = None
+ self.setStyleSheet("color: #888888; font-size: 11px;")
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.customContextMenuRequested.connect(self._show_menu)
+
+ def set_status(self, parked_at: Optional[int], active: bool) -> None:
+ self._parked_at = parked_at
+ if parked_at is None:
+ self.setText("not parked")
+ self.setStyleSheet("color: #888888; font-size: 11px;")
+ elif active:
+ self.setText(f"Slot {parked_at} — ● ACTIVE (ready for transfer)")
+ self.setStyleSheet("color: #2e7d32; font-size: 11px; font-weight: bold;")
+ else:
+ self.setText(f"Slot {parked_at}")
+ self.setStyleSheet("color: #555555; font-size: 11px;")
+
+ def _show_menu(self, pos) -> None:
+ menu = QMenu(self)
+ act_set = menu.addAction("Set parking slot…")
+ act_clear = menu.addAction("Clear parking assignment")
+ act_clear.setEnabled(self._parked_at is not None)
+ chosen = menu.exec_(self.mapToGlobal(pos))
+ if chosen == act_set:
+ self._owner.action_set_parking(self._letter, self._parked_at)
+ elif chosen == act_clear:
+ self._owner.action_clear_parking(self._letter, self._parked_at)
+
+
# ── main widget ──────────────────────────────────────────────────────────────
class OMNY_SampleStorage(BECWidget, QWidget):
"""
- View and correct the FlOMNI sample-storage records.
+ View and correct the sample-storage records for whichever setup (FlOMNI
+ or OMNY -- see ``SETUP_PROFILES``) is active in this BEC session.
Layout
------
+ FlOMNI:
- Header: Stage (slot 0) and Gripper (slot 100), right-click correctable
- 4×5 grid: magazine slots 1–20, right-click correctable
- Caveat line: records only, not hardware
+ OMNY:
+ - Header: Stage and Gripper, right-click correctable
+ - Grid: the 10 fixed O positions, right-click correctable
+ - Three shuttle sections (A/B/C): installed/active status line
+ (right-click to assign/clear a parking slot) + a 6-slot grid,
+ right-click correctable
+ - Caveat line: records only, not hardware
All mutations go through a right-click context menu; each mutating action
is confirmed by its own dialog and written to the device immediately.
@@ -305,44 +472,54 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self.get_bec_shortcuts()
- self._cells: dict[int, _SlotCell] = {}
+ self._cells: dict[Any, _SlotCell] = {}
# last-seen (occupied, name, owner, measured_status) per slot, for
# change-detection in refresh()
- self._last_state: dict[int, tuple[bool, str, str, Optional[str]]] = {}
+ self._last_state: dict[Any, tuple[bool, str, str, Optional[str]]] = {}
+ self._shuttle_status_labels: dict[str, _ShuttleStatusLabel] = {}
self.measured_log = _MeasuredSampleLog(self.client)
self.label_printer = PTouchLabelPrinter(
host=PTOUCH_PRINTER_HOST, template_number=PTOUCH_TEMPLATE_NUMBER
)
- self._flomni_available = self._check_flomni_available()
+ self._setup = self._detect_setup()
self._build_ui()
self._poll_timer = QTimer(self)
self._poll_timer.setInterval(self._POLL_INTERVAL_MS)
self._poll_timer.timeout.connect(self.refresh)
- if self._flomni_available:
+ if self._setup is not None:
self._poll_timer.start()
self.refresh()
# ── setup detection ──────────────────────────────────────────────────────
- def _check_flomni_available(self) -> bool:
+ def _detect_setup(self) -> Optional[str]:
"""
- Return True if the FlOMNI setup is active in this BEC session.
- Uses ``fsamroy`` (the FlOMNI-specific rotation stage) as the
- discriminator — it is absent in OMNY and LamNI sessions.
+ Return the key of whichever known sample-storage setup (see
+ ``SETUP_PROFILES``) is active in this BEC session, or None if
+ neither is present (e.g. a LamNI session, which has no
+ sample-storage device at all). A BEC session is always exactly one
+ setup, never more than one at a time -- confirmed across every
+ device_configs/*.yaml, dev.flomni_samples and dev.omny_samples
+ never coexist.
"""
- try:
- return getattr(self.dev, "fsamroy", None) is not None
- except Exception:
- return False
+ for setup, profile in SETUP_PROFILES.items():
+ try:
+ if getattr(self.dev, profile["discriminator_attr"], None) is not None:
+ return setup
+ except Exception:
+ continue
+ return None
# ── device access ────────────────────────────────────────────────────────
@property
def _samples(self):
- """The flomni_samples device (single source of truth)."""
+ """The active setup's sample-storage device (single source of truth)."""
+ if self._setup == "omny":
+ return self.dev.omny_samples
return self.dev.flomni_samples
- def _read_all_slots(self) -> dict[int, tuple[bool, str, str, Optional[str]]]:
+ def _read_all_slots_flomni(self) -> dict[int, tuple[bool, str, str, Optional[str]]]:
"""Return {slot: (occupied, name, owner, measured_status)} for every
slot 0–20 and the gripper from a SINGLE bulk
``dev.flomni_samples.read()`` round-trip plus a single bulk
@@ -413,9 +590,100 @@ class OMNY_SampleStorage(BECWidget, QWidget):
return result
+ def _bulk_read_omny(self) -> Optional[dict]:
+ """Single cached bulk read of dev.omny_samples, shared by
+ _refresh_omny() and _build_report_pdf_omny() so a report build
+ never issues a second round of signal access on top of the poll.
+ Every underlying signal is auto_monitor=True (see
+ ``OMNYSampleStorage``), so ``cached=True`` returns the monitored
+ values instead of a live EPICS round-trip per signal -- same
+ rationale as _read_all_slots_flomni().
+ """
+ try:
+ return self._samples.read(cached=True)
+ except Exception as exc:
+ logger.warning(f"OMNY_SampleStorage: bulk read failed: {exc}")
+ return None
+
+ def _parse_sample_state_omny(self, data: dict) -> dict[tuple, tuple[bool, str, str, Optional[str]]]:
+ """Parse a dev.omny_samples.read(cached=True) dict into
+ {key: (occupied, name, owner, measured_status)} for every
+ sample-kind slot -- stage, gripper, the 10 fixed O positions, and
+ the 18 shuttle slots. OMNY has no owner field and no
+ measured-sample-log equivalent (see module docstring), so those two
+ are always ""/None -- this keeps the tuple shape identical to
+ FlOMNI's so _SlotCell.set_state() and _draw_report_cell() need no
+ OMNY-specific branch.
+ """
+
+ def _val(key, default=None):
+ entry = data.get(key)
+ if isinstance(entry, dict):
+ return entry.get("value", default)
+ return default
+
+ state: dict[tuple, tuple[bool, str, str, Optional[str]]] = {}
+
+ used = bool(_val("omny_samples_sample_in_samplestage", 0))
+ name = _val("omny_samples_sample_in_samplestage_name", EMPTY_NAME) or EMPTY_NAME
+ state[("stage",)] = (used, name, "", None)
+
+ used = bool(_val("omny_samples_sample_in_gripper", 0))
+ name = _val("omny_samples_sample_in_gripper_name", EMPTY_NAME) or EMPTY_NAME
+ state[("gripper",)] = (used, name, "", None)
+
+ for n in OMNY_FIXED_POSITIONS:
+ used = bool(_val(f"omny_samples_sample_placed_sample{n}", 0))
+ name = _val(f"omny_samples_sample_names_sample{n}", EMPTY_NAME) or EMPTY_NAME
+ state[("O", n)] = (used, name, "", None)
+
+ for letter in OMNY_SHUTTLE_LETTERS:
+ for n in OMNY_SHUTTLE_SLOTS:
+ used = bool(_val(f"omny_samples_sample_shuttle_{letter}_placed_sample{n}", 0))
+ name = _val(f"omny_samples_sample_shuttle_{letter}_names_sample{n}", EMPTY_NAME) or EMPTY_NAME
+ state[("shuttle", letter, n)] = (used, name, "", None)
+
+ return state
+
+ def _parse_parking_assignment_omny(self, data: dict) -> dict[int, str]:
+ """{parking_slot_nr: shuttle_letter} for parking slots that
+ currently have a shuttle recorded, from the same bulk-read dict as
+ _parse_sample_state_omny(). Treats unset_shuttle_slot()'s "none"
+ sentinel (and, defensively, "-"/EMPTY_NAME) as "not parked".
+ """
+
+ def _val(key, default=None):
+ entry = data.get(key)
+ if isinstance(entry, dict):
+ return entry.get("value", default)
+ return default
+
+ assignment: dict[int, str] = {}
+ for n in OMNY_PARKING_SLOTS:
+ raw = _val(f"omny_samples_parking_names_parking{n}", "")
+ letter = str(raw).strip() if raw else ""
+ if letter and letter not in (PARKING_EMPTY_SENTINEL, EMPTY_NAME, ""):
+ assignment[n] = letter
+ return assignment
+
+ def _read_active_parking_slot(self) -> Optional[int]:
+ """Which parking slot (2-6) the oparkz motor is currently sitting
+ at, i.e. ready for a sample transfer -- see
+ ``oparkz_active_parking_slot()``. A separate cached device read
+ from ``_bulk_read_omny()`` since it lives on a different device
+ (``dev.oparkz``, not ``dev.omny_samples``).
+ """
+ try:
+ readback = self.dev.oparkz.readback.get(cached=True)
+ except Exception as exc:
+ logger.warning(f"OMNY_SampleStorage: oparkz readback failed: {exc}")
+ return None
+ return oparkz_active_parking_slot(readback)
+
def _offer_print_label_for_previous_occupant(self, slot: int) -> None:
"""If `slot` currently holds a measured sample, offer to print an
account/date/name label for it before its record gets overwritten.
+ FlOMNI-only (see module docstring); never called for OMNY writes.
Unlike flomni.py's equivalent check, this can run unconditionally
for every slot ``_write_slot()`` touches (stage, gripper, and
@@ -482,8 +750,9 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def _write_slot(self, slot: int, used: int, name: str, owner: str = "") -> bool:
"""
- Write (used, name, owner) to a storage slot 0–20 or the gripper,
- mirroring ``Flomni.flomni_modify_storage_non_interactive()`` exactly.
+ Write (used, name, owner) to a FlOMNI storage slot 0–20 or the
+ gripper, mirroring ``Flomni.flomni_modify_storage_non_interactive()``
+ exactly.
"""
self._offer_print_label_for_previous_occupant(slot)
try:
@@ -508,8 +777,91 @@ class OMNY_SampleStorage(BECWidget, QWidget):
)
return False
- @staticmethod
- def _slot_title(slot: int) -> str:
+ def _write_slot_omny(self, key: tuple, used: int, name: str) -> bool:
+ """Write (used, name) to an OMNY sample-kind slot -- stage, gripper,
+ a fixed O position, or a shuttle slot -- mirroring the direct-signal
+ style of ``_write_slot()``. No owner field and no measured-sample-log
+ integration exist for OMNY (see module docstring), so unlike
+ ``_write_slot`` this never offers to print a P-touch label for the
+ previous occupant.
+ """
+ try:
+ kind = key[0]
+ if kind == "stage":
+ self._samples.sample_in_samplestage.set(used)
+ self._samples.sample_in_samplestage_name.set(name)
+ elif kind == "gripper":
+ self._samples.sample_in_gripper.set(used)
+ self._samples.sample_in_gripper_name.set(name)
+ elif kind == "O":
+ _, n = key
+ getattr(self._samples.sample_placed, f"sample{n}").set(used)
+ getattr(self._samples.sample_names, f"sample{n}").set(name)
+ elif kind == "shuttle":
+ _, letter, n = key
+ getattr(getattr(self._samples, f"sample_shuttle_{letter}_placed"), f"sample{n}").set(used)
+ getattr(getattr(self._samples, f"sample_shuttle_{letter}_names"), f"sample{n}").set(name)
+ else:
+ raise ValueError(f"unknown OMNY slot key {key!r}")
+ self._last_state.pop(key, None)
+ return True
+ except Exception as exc:
+ logger.warning(f"OMNY_SampleStorage: write of slot {key} failed: {exc}")
+ QMessageBox.critical(
+ self,
+ "Write failed",
+ f"Could not write slot {self._slot_title(key)} to the device:\n{exc}",
+ )
+ return False
+
+ def _write_parking_omny(self, slot_nr: int, letter: Optional[str]) -> bool:
+ """Assign (or clear) which shuttle letter is recorded as parked at
+ OMNY parking slot `slot_nr` (1-6), mirroring
+ ``OMNYSampleStorage.set_shuttle_slot()``/``unset_shuttle_slot()``
+ field-for-field -- including its own empty sentinel, "none", which
+ differs from sample slots' "-" (see ``PARKING_EMPTY_SENTINEL``).
+ Pure operator bookkeeping: never touched by the transfer code, so
+ (like ``flomni_modify_storage_non_interactive()``) there's no
+ interlock against editing a slot that's also being changed by an
+ in-progress transfer.
+ """
+ try:
+ used = 1 if letter else 0
+ value = letter if letter else PARKING_EMPTY_SENTINEL
+ getattr(self._samples.parking_placed, f"parking{slot_nr}").set(used)
+ getattr(self._samples.parking_names, f"parking{slot_nr}").set(value)
+ return True
+ except Exception as exc:
+ logger.warning(f"OMNY_SampleStorage: write of parking slot {slot_nr} failed: {exc}")
+ QMessageBox.critical(
+ self,
+ "Write failed",
+ f"Could not write parking slot {slot_nr} to the device:\n{exc}",
+ )
+ return False
+
+ def _write_sample(self, slot, used: int, name: str, owner: str = "") -> bool:
+ """Shared write dispatcher used by the sample-cell mutation actions
+ (action_change_name/action_new_sample/action_clear) for both
+ setups: OMNY keys are tuples (see _SlotCell), FlOMNI keys are
+ plain ints.
+ """
+ if isinstance(slot, tuple):
+ return self._write_slot_omny(slot, used, name)
+ return self._write_slot(slot, used, name, owner)
+
+ def _slot_title(self, slot) -> str:
+ if isinstance(slot, tuple):
+ kind = slot[0]
+ if kind == "stage":
+ return "stage"
+ if kind == "gripper":
+ return "gripper"
+ if kind == "O":
+ return f"O:{slot[1]}"
+ if kind == "shuttle":
+ return f"shuttle {slot[1]}, slot {slot[2]}"
+ return str(slot)
if slot == STAGE_SLOT:
return "stage (0)"
if slot == GRIPPER_SLOT:
@@ -518,16 +870,15 @@ class OMNY_SampleStorage(BECWidget, QWidget):
# ── UI construction ───────────────────────────────────────────────────────
- def _build_ui(self):
+ def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setSpacing(8)
- if not self._flomni_available:
+ if self._setup is None:
lbl = QLabel(
- "⚠ FlOMNI setup not detected in this BEC session.\n\n"
- "This widget is only meaningful when the FlOMNI\n"
- "BEC session is active (fsamroy device present).\n\n"
- "OMNY support is not implemented yet."
+ "⚠ No known sample-storage setup detected in this BEC session.\n\n"
+ "This widget needs dev.flomni_samples (FlOMNI) or\n"
+ "dev.omny_samples (OMNY) to be present."
)
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl.setWordWrap(True)
@@ -536,6 +887,12 @@ class OMNY_SampleStorage(BECWidget, QWidget):
root.addStretch()
return
+ if self._setup == "flomni":
+ self._build_ui_flomni(root)
+ else:
+ self._build_ui_omny(root)
+
+ def _build_ui_flomni(self, root: QVBoxLayout) -> None:
# header: stage + gripper
header = QHBoxLayout()
header.setSpacing(8)
@@ -560,7 +917,61 @@ class OMNY_SampleStorage(BECWidget, QWidget):
grid.addWidget(cell, r, c)
root.addWidget(grid_box, stretch=1)
- # caveat line
+ self._build_caveat(root)
+ self._build_button_row(root)
+
+ def _build_ui_omny(self, root: QVBoxLayout) -> None:
+ # header: stage + gripper
+ header = QHBoxLayout()
+ header.setSpacing(8)
+ self._cells[("stage",)] = _SlotCell(("stage",), self, COLOR_STAGE_BORDER, caption="Stage")
+ self._cells[("gripper",)] = _SlotCell(("gripper",), self, COLOR_GRIPPER_BORDER, caption="Gripper")
+ header.addWidget(self._cells[("stage",)])
+ header.addWidget(self._cells[("gripper",)])
+ root.addLayout(header)
+
+ # fixed O positions
+ fixed_box = QFrame()
+ fixed_grid = QGridLayout(fixed_box)
+ fixed_grid.setSpacing(6)
+ fixed_grid.setContentsMargins(0, 0, 0, 0)
+ for idx, n in enumerate(OMNY_FIXED_POSITIONS):
+ r, c = divmod(idx, 5)
+ key = ("O", n)
+ cell = _SlotCell(key, self, COLOR_SLOT_BORDER, caption=f"O:{n}")
+ self._cells[key] = cell
+ fixed_grid.addWidget(cell, r, c)
+ root.addWidget(fixed_box)
+
+ # shuttles A/B/C: each a status line (installed/active, editable)
+ # plus a 6-slot grid
+ shuttle_row = QHBoxLayout()
+ shuttle_row.setSpacing(8)
+ for letter in OMNY_SHUTTLE_LETTERS:
+ group = QGroupBox(f"Shuttle {letter}")
+ group_layout = QVBoxLayout(group)
+
+ status_label = _ShuttleStatusLabel(letter, self)
+ self._shuttle_status_labels[letter] = status_label
+ group_layout.addWidget(status_label)
+
+ slot_grid = QGridLayout()
+ slot_grid.setSpacing(4)
+ for idx, n in enumerate(OMNY_SHUTTLE_SLOTS):
+ r, c = divmod(idx, 3)
+ key = ("shuttle", letter, n)
+ cell = _SlotCell(key, self, COLOR_SLOT_BORDER, caption=f"{letter}-{n}")
+ self._cells[key] = cell
+ slot_grid.addWidget(cell, r, c)
+ group_layout.addLayout(slot_grid)
+
+ shuttle_row.addWidget(group)
+ root.addLayout(shuttle_row, stretch=1)
+
+ self._build_caveat(root)
+ self._build_button_row(root)
+
+ def _build_caveat(self, root: QVBoxLayout) -> None:
caveat = QLabel(
"Records only — this does not move any hardware. "
"Ensure the entries here match physical reality."
@@ -569,7 +980,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
caveat.setStyleSheet("color: #FF9800; font-size: 11px;")
root.addWidget(caveat)
- # button row
+ def _build_button_row(self, root: QVBoxLayout) -> None:
button_row = QHBoxLayout()
button_row.addStretch()
self.print_button = QPushButton("Print")
@@ -581,18 +992,23 @@ class OMNY_SampleStorage(BECWidget, QWidget):
@SafeSlot()
def refresh(self) -> None:
- """Re-read all slots (one bulk device round-trip) and update only the
- cells whose state actually changed.
+ """Re-read all slots (one bulk device round-trip) and update only
+ the cells whose state actually changed. Dispatches to the active
+ setup's own refresh method.
+ """
+ if self._setup == "flomni":
+ self._refresh_flomni()
+ elif self._setup == "omny":
+ self._refresh_omny()
- Repainting every cell on every 2 s poll — even when nothing changed,
+ def _refresh_flomni(self) -> None:
+ """Repainting every cell on every 2 s poll — even when nothing changed,
which is almost always the case for a sample magazine — was needless
work on the GUI thread. We diff against the last-seen state and only
call set_state() on cells that differ, so a steady-state poll does no
UI work at all.
"""
- if not self._flomni_available:
- return
- state = self._read_all_slots()
+ state = self._read_all_slots_flomni()
if not state:
return # bulk read failed; leave the current display untouched
for slot, (occupied, name, owner, measured_status) in state.items():
@@ -603,10 +1019,39 @@ class OMNY_SampleStorage(BECWidget, QWidget):
cell.set_state(occupied, name, owner, measured_status)
self._last_state[slot] = (occupied, name, owner, measured_status)
+ def _refresh_omny(self) -> None:
+ """Same diff-and-repaint-only-what-changed approach as
+ ``_refresh_flomni()``, for the sample-kind cells, plus an update of
+ each shuttle's installed/active status label.
+ """
+ data = self._bulk_read_omny()
+ if data is None:
+ return # bulk read failed; leave the current display untouched
+
+ state = self._parse_sample_state_omny(data)
+ for key, entry in state.items():
+ if self._last_state.get(key) == entry:
+ continue
+ cell = self._cells.get(key)
+ if cell is not None:
+ cell.set_state(*entry)
+ self._last_state[key] = entry
+
+ parking_assignment = self._parse_parking_assignment_omny(data)
+ active_slot = self._read_active_parking_slot()
+ for letter in OMNY_SHUTTLE_LETTERS:
+ status = shuttle_status(letter, parking_assignment, active_slot)
+ label = self._shuttle_status_labels.get(letter)
+ if label is not None:
+ label.set_status(status["parked_at"], status["active"])
+
# ── mutation actions (called from _SlotCell context menu) ─────────────────
- def action_change_name(self, slot: int, current_name: str, current_owner: str = "") -> None:
- """Rename an occupied slot (stays occupied)."""
+ def action_change_name(self, slot, current_name: str, current_owner: str = "") -> None:
+ """Rename an occupied slot (stays occupied). Shared by both setups
+ (see ``_SlotCell``); the owner prompt only applies to FlOMNI's
+ plain-int keys -- OMNY has no owner field.
+ """
name, ok = QInputDialog.getText(
self,
"Change sample name",
@@ -624,25 +1069,29 @@ class OMNY_SampleStorage(BECWidget, QWidget):
"Use “Clear (set empty)” to empty the slot instead.",
)
return
- owner, ok = QInputDialog.getText(
- self,
- "Sample owner",
- f"Owner for slot {self._slot_title(slot)}:",
- text=current_owner,
- )
- if not ok:
- return
- owner = owner.strip()
- if not owner:
- QMessageBox.warning(
- self, "Invalid owner", "An occupied slot needs a non-empty owner."
+ owner = current_owner
+ if not isinstance(slot, tuple):
+ owner, ok = QInputDialog.getText(
+ self,
+ "Sample owner",
+ f"Owner for slot {self._slot_title(slot)}:",
+ text=current_owner,
)
- return
- if self._write_slot(slot, 1, name, owner):
+ if not ok:
+ return
+ owner = owner.strip()
+ if not owner:
+ QMessageBox.warning(
+ self, "Invalid owner", "An occupied slot needs a non-empty owner."
+ )
+ return
+ if self._write_sample(slot, 1, name, owner):
self.refresh()
- def action_new_sample(self, slot: int) -> None:
- """Mark an empty slot occupied and give it a name."""
+ def action_new_sample(self, slot) -> None:
+ """Mark an empty slot occupied and give it a name. Shared by both
+ setups (see ``action_change_name``).
+ """
name, ok = QInputDialog.getText(
self, "New sample", f"Sample name for slot {self._slot_title(slot)}:"
)
@@ -652,22 +1101,26 @@ class OMNY_SampleStorage(BECWidget, QWidget):
if not name or name == EMPTY_NAME:
QMessageBox.warning(self, "Invalid name", "Please enter a non-empty sample name.")
return
- owner, ok = QInputDialog.getText(
- self, "Sample owner", f"Owner for slot {self._slot_title(slot)}:"
- )
- if not ok:
- return
- owner = owner.strip()
- if not owner:
- QMessageBox.warning(
- self, "Invalid owner", "An occupied slot needs a non-empty owner."
+ owner = ""
+ if not isinstance(slot, tuple):
+ owner, ok = QInputDialog.getText(
+ self, "Sample owner", f"Owner for slot {self._slot_title(slot)}:"
)
- return
- if self._write_slot(slot, 1, name, owner):
+ if not ok:
+ return
+ owner = owner.strip()
+ if not owner:
+ QMessageBox.warning(
+ self, "Invalid owner", "An occupied slot needs a non-empty owner."
+ )
+ return
+ if self._write_sample(slot, 1, name, owner):
self.refresh()
- def action_clear(self, slot: int) -> None:
- """Mark a slot empty (destructive; confirmed)."""
+ def action_clear(self, slot) -> None:
+ """Mark a slot empty (destructive; confirmed). Shared by both
+ setups (see ``action_change_name``).
+ """
reply = QMessageBox.question(
self,
"Clear slot",
@@ -678,7 +1131,48 @@ class OMNY_SampleStorage(BECWidget, QWidget):
)
if reply != QMessageBox.StandardButton.Yes:
return
- if self._write_slot(slot, 0, EMPTY_NAME):
+ if self._write_sample(slot, 0, EMPTY_NAME):
+ self.refresh()
+
+ def action_set_parking(self, letter: str, current_slot: Optional[int]) -> None:
+ """Assign OMNY shuttle `letter` to a parking slot (1-6), via
+ ``_ShuttleStatusLabel``'s context menu. Overwrites that slot's
+ previous occupant, if any, without clearing wherever `letter` was
+ previously parked -- mirrors ``set_shuttle_slot()``'s own
+ semantics exactly; keeping the two in sync is operator discipline,
+ same as everywhere else in this widget (see module docstring).
+ """
+ slot_nr, ok = QInputDialog.getInt(
+ self,
+ "Set parking slot",
+ f"Parking slot (1-6) for shuttle {letter}:",
+ current_slot or 1,
+ 1,
+ 6,
+ 1,
+ )
+ if not ok:
+ return
+ if self._write_parking_omny(slot_nr, letter):
+ self.refresh()
+
+ def action_clear_parking(self, letter: str, current_slot: Optional[int]) -> None:
+ """Clear OMNY shuttle `letter`'s parking-slot assignment (destructive;
+ confirmed), via ``_ShuttleStatusLabel``'s context menu.
+ """
+ if current_slot is None:
+ return
+ reply = QMessageBox.question(
+ self,
+ "Clear parking assignment",
+ f"Clear shuttle {letter}'s parking slot {current_slot}?\n\n"
+ "Confirm the shuttle has actually been removed from that slot — "
+ "this only changes the record, not the hardware.",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
+ )
+ if reply != QMessageBox.StandardButton.Yes:
+ return
+ if self._write_parking_omny(current_slot, None):
self.refresh()
# ── print report ─────────────────────────────────────────────────────────
@@ -727,14 +1221,20 @@ class OMNY_SampleStorage(BECWidget, QWidget):
return False
def _build_report_pdf(self) -> str:
+ """Dispatch to the active setup's own report builder."""
+ if self._setup == "omny":
+ return self._build_report_pdf_omny()
+ return self._build_report_pdf_flomni()
+
+ def _build_report_pdf_flomni(self) -> str:
"""Render a page mirroring the on-screen grid (stage + gripper
- header, 4x5 magazine, same layout/orientation as ``_build_ui()``)
- from a fresh device read, and return the path of the temp PDF file
- it was written to.
+ header, 4x5 magazine, same layout/orientation as
+ ``_build_ui_flomni()``) from a fresh device read, and return the
+ path of the temp PDF file it was written to.
"""
from fpdf import XPos, YPos
- state = self._read_all_slots()
+ state = self._read_all_slots_flomni()
fd, path = tempfile.mkstemp(prefix="flomni_sample_storage_", suffix=".pdf")
os.close(fd)
with PDFWriter(path) as file:
@@ -759,7 +1259,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
)
# magazine grid, same row/col + right-to-left mirroring as the
- # on-screen grid (sample_storage.py's _build_ui())
+ # on-screen grid (sample_storage.py's _build_ui_flomni())
grid_y0 = y0 + cell_h + 4
for idx, slot in enumerate(STORAGE_SLOTS):
r, c = divmod(idx, STORAGE_COLS)
@@ -769,10 +1269,82 @@ class OMNY_SampleStorage(BECWidget, QWidget):
self._draw_report_cell(pdf, x, y, cell_w, cell_h, slot, state.get(slot))
return path
+ def _build_report_pdf_omny(self) -> str:
+ """Render a page mirroring the on-screen OMNY layout (stage +
+ gripper header, fixed-O grid, 3 shuttle sections with their
+ installed/active status text) from a fresh device read, and return
+ the path of the temp PDF file it was written to.
+ """
+ from fpdf import XPos, YPos
+
+ data = self._bulk_read_omny()
+ state = self._parse_sample_state_omny(data) if data else {}
+ parking_assignment = self._parse_parking_assignment_omny(data) if data else {}
+ active_slot = self._read_active_parking_slot()
+
+ fd, path = tempfile.mkstemp(prefix="omny_sample_storage_", suffix=".pdf")
+ os.close(fd)
+ with PDFWriter(path) as file:
+ pdf = file._pdf
+ pdf.set_font("Helvetica", "B", 16)
+ pdf.cell(0, 10, "OMNY Sample Storage", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C")
+ pdf.set_font("Helvetica", "", 10)
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ pdf.cell(0, 6, timestamp, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C")
+ pdf.ln(6)
+
+ margin = pdf.l_margin
+ usable_w = pdf.w - 2 * margin
+ cell_w = usable_w / 5
+ cell_h = 28
+
+ # header: stage + gripper, same widths as two fixed-O cells
+ y0 = pdf.get_y()
+ self._draw_report_cell(pdf, margin, y0, cell_w, cell_h, ("stage",), state.get(("stage",)))
+ self._draw_report_cell(
+ pdf, margin + cell_w, y0, cell_w, cell_h, ("gripper",), state.get(("gripper",))
+ )
+
+ # fixed O positions, same 5-wide grid as the on-screen layout
+ fixed_y0 = y0 + cell_h + 4
+ for idx, n in enumerate(OMNY_FIXED_POSITIONS):
+ r, c = divmod(idx, 5)
+ x = margin + c * cell_w
+ y = fixed_y0 + r * cell_h
+ key = ("O", n)
+ self._draw_report_cell(pdf, x, y, cell_w, cell_h, key, state.get(key))
+
+ # shuttles A/B/C: status line + 3-wide slot grid, side by side
+ shuttle_y0 = fixed_y0 + 2 * cell_h + 8
+ shuttle_w = usable_w / 3
+ for si, letter in enumerate(OMNY_SHUTTLE_LETTERS):
+ x0 = margin + si * shuttle_w
+ status = shuttle_status(letter, parking_assignment, active_slot)
+ if status["parked_at"] is None:
+ status_text = f"Shuttle {letter}: not parked"
+ else:
+ status_text = f"Shuttle {letter}: slot {status['parked_at']}"
+ if status["active"]:
+ status_text += " (ACTIVE)"
+ pdf.set_xy(x0, shuttle_y0)
+ pdf.set_font("Helvetica", "B", 9)
+ pdf.cell(shuttle_w, 5, status_text, align="L")
+
+ slot_w = shuttle_w / 3
+ for idx, n in enumerate(OMNY_SHUTTLE_SLOTS):
+ r, c = divmod(idx, 3)
+ x = x0 + c * slot_w
+ y = shuttle_y0 + 6 + r * cell_h
+ key = ("shuttle", letter, n)
+ self._draw_report_cell(pdf, x, y, slot_w, cell_h, key, state.get(key))
+ return path
+
def _draw_report_cell(self, pdf, x, y, w, h, slot, entry) -> None:
"""Draw one slot's box, mirroring what ``_SlotCell.set_state()``
shows on screen: slot caption, name (or "empty"), owner and measured
- marker.
+ marker. Shared by both setups' report builders -- OMNY entries
+ always carry owner="" and measured_status=None, which render as
+ blank without any OMNY-specific branch here.
"""
pdf.rect(x, y, w, h)
occupied, name, owner, measured_status = entry if entry else (False, EMPTY_NAME, "", None)
@@ -839,4 +1411,4 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def cleanup(self) -> None:
self._poll_timer.stop()
- super().cleanup()
\ No newline at end of file
+ super().cleanup()
diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py
index f0574620..d5e60ead 100644
--- a/csaxs_bec/devices/sim/sim_omny.py
+++ b/csaxs_bec/devices/sim/sim_omny.py
@@ -254,6 +254,32 @@ class SimOMNYSampleStorage(OMNYSampleStorage):
def __new__(cls, *args, sim_samples: dict | None = None, **kwargs):
with patched_device(OMNYSampleStorage, *args, **kwargs) as storage:
samples = _DEFAULT_OMNY_SAMPLES if sim_samples is None else sim_samples
+ # patched_device()'s mock PVs default to the string "0", not the
+ # real backend's own empty sentinels ("-" for sample slots, "none"
+ # for parking) -- explicitly unset every slot first, the same way
+ # SimFlomniSampleStorage does, so untouched slots read back as
+ # genuinely empty instead of a stray "0" name/shuttle-letter.
+ #
+ # Writes go directly to the Dcpt signals rather than through
+ # unset_sample_slot()/set_sample_slot() -- those reject any
+ # slot_nr > 20 (a pre-existing bug: it's copied from Flomni's
+ # 0-20-only range check, but OMNY's own "O" fixed positions
+ # legitimately include 32-34/100/101), which would raise here
+ # for exactly the positions this needs to clear.
+ for shuttle_placed, shuttle_names in (
+ (storage.sample_shuttle_A_placed, storage.sample_shuttle_A_names),
+ (storage.sample_shuttle_B_placed, storage.sample_shuttle_B_names),
+ (storage.sample_shuttle_C_placed, storage.sample_shuttle_C_names),
+ ):
+ for slot_nr in shuttle_placed.component_names:
+ getattr(shuttle_placed, slot_nr).set(0)
+ getattr(shuttle_names, slot_nr).set("-")
+ for slot_nr in storage.sample_placed.component_names:
+ getattr(storage.sample_placed, slot_nr).set(0)
+ getattr(storage.sample_names, slot_nr).set("-")
+ for slot_nr in storage.parking_placed.component_names:
+ getattr(storage.parking_placed, slot_nr).set(0)
+ getattr(storage.parking_names, slot_nr).set("none")
storage.unset_sample_in_gripper()
storage.set_sample_in_samplestage("sim_stage_sample")
for slot_nr, name in samples.items():
diff --git a/tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py b/tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py
new file mode 100644
index 00000000..b91c833c
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py
@@ -0,0 +1,59 @@
+"""Unit tests for OMNY_SampleStorage's OMNY-side pure helper functions:
+where a shuttle is "installed" (which parking slot) and whether that slot
+is the "active" one, i.e. the oparkz motor is currently sitting there,
+ready for a sample transfer.
+
+These duplicate omny_sample_transfer_mixin.py's _oparkz_slot_check() target
+-position table by design (see sample_storage.py's module docstring) --
+this test guards against that duplicated table silently drifting out of
+sync with the transfer mixin's own copy.
+"""
+
+import pytest
+
+from csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage import (
+ oparkz_active_parking_slot,
+ shuttle_status,
+)
+
+
+@pytest.mark.parametrize(
+ "readback,expected_slot",
+ [
+ (-0.9, 6),
+ (-0.9 - 29, 5),
+ (-0.9 - 29 * 2, 4),
+ (-0.9 - 29 * 3, 3),
+ (-0.9 - 29 * 3 - 42 - 0.05, 2),
+ ],
+)
+def test_oparkz_active_parking_slot_matches_each_target(readback, expected_slot):
+ assert oparkz_active_parking_slot(readback) == expected_slot
+
+
+def test_oparkz_active_parking_slot_none_when_far_from_any_target():
+ assert oparkz_active_parking_slot(0.0) is None
+
+
+def test_oparkz_active_parking_slot_within_tolerance():
+ assert oparkz_active_parking_slot(-0.9 + 0.04) == 6
+
+
+def test_oparkz_active_parking_slot_outside_tolerance():
+ assert oparkz_active_parking_slot(-0.9 + 0.06) is None
+
+
+def test_shuttle_status_parked_and_active():
+ assert shuttle_status("A", {3: "A"}, active_slot=3) == {"parked_at": 3, "active": True}
+
+
+def test_shuttle_status_not_parked_anywhere():
+ assert shuttle_status("B", {3: "A"}, active_slot=3) == {"parked_at": None, "active": False}
+
+
+def test_shuttle_status_parked_but_not_active():
+ assert shuttle_status("A", {2: "A"}, active_slot=3) == {"parked_at": 2, "active": False}
+
+
+def test_shuttle_status_no_active_slot_at_all():
+ assert shuttle_status("A", {2: "A"}, active_slot=None) == {"parked_at": 2, "active": False}
From c79d3dc0a036b3055b69ff953a26f3aa49b92214 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 18:32:11 +0200
Subject: [PATCH 027/102] feat(omny): add otransfer_* CLI wrappers; fix widget
contrast + add owner requirement
Adds omny.otransfer_show_all() (renamed from otransfer_storage) plus thin
otransfer_* wrappers for every dev.omny_samples command, so they all
tab-complete under omny.* the same way flomni.py's ftransfer_* commands do.
Also fixes two issues found in first widget testing:
- OMNY_SampleStorage's occupied-slot text was hardcoded to a near-black
colour, invisible against BEC's dark theme -- now inherits the
application palette's default text colour instead.
- OMNY sample slots now require a sample owner, same as FlOMNI, packed via
the existing sample_desc_codec (name+owner in the one raw name field).
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../omny/omny_sample_transfer_mixin.py | 63 +++++++-
.../widgets/sample_storage/sample_storage.py | 144 ++++++++++--------
docs/user/ptychography/omny.md | 8 +-
3 files changed, 141 insertions(+), 74 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index b0f59611..2c00948f 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -882,7 +882,7 @@ class OMNYSampleTransferMixin:
self._otransfer_check_sample_available_at_position(pin_position, 1)
self._otransfer_shield_close(pin_position)
- self.otransfer_storage()
+ self.otransfer_show_all()
def _otransfer_stage_to_ST(self, pin_position):
self._otransfer_gripper_to_park_z()
@@ -1064,9 +1064,64 @@ class OMNYSampleTransferMixin:
# }'
- def otransfer_storage(self):
+ def otransfer_show_all(self):
dev.omny_samples.show_all()
+ # Thin dev.omny_samples wrappers below, so every sample-storage command
+ # tab-completes under omny.otransfer_* -- same reason flomni.py exposes
+ # dev.flomni_samples via ftransfer_* -- instead of only being reachable
+ # via dev.omny_samples.* directly.
+ def otransfer_is_sample_slot_used(self, container: str, slot_nr: int) -> bool:
+ return dev.omny_samples.is_sample_slot_used(container, slot_nr)
+
+ def otransfer_is_sample_in_gripper(self) -> bool:
+ return dev.omny_samples.is_sample_in_gripper()
+
+ def otransfer_set_sample_slot(self, container: str, slot_nr: int, name: str) -> bool:
+ return dev.omny_samples.set_sample_slot(container, slot_nr, name)
+
+ def otransfer_unset_sample_slot(self, container: str, slot_nr: int) -> bool:
+ return dev.omny_samples.unset_sample_slot(container, slot_nr)
+
+ def otransfer_set_sample_in_gripper(self, name: str) -> bool:
+ return dev.omny_samples.set_sample_in_gripper(name)
+
+ def otransfer_unset_sample_in_gripper(self) -> bool:
+ return dev.omny_samples.unset_sample_in_gripper()
+
+ def otransfer_set_sample_in_samplestage(self, name: str) -> bool:
+ return dev.omny_samples.set_sample_in_samplestage(name)
+
+ def otransfer_unset_sample_in_samplestage(self) -> bool:
+ return dev.omny_samples.unset_sample_in_samplestage()
+
+ def otransfer_get_sample_name_in_samplestage(self) -> str:
+ return dev.omny_samples.get_sample_name_in_samplestage()
+
+ def otransfer_get_sample_name_in_gripper(self) -> str:
+ return dev.omny_samples.get_sample_name_in_gripper()
+
+ def otransfer_get_sample_name(self, container: str, slot_nr: int) -> str:
+ return dev.omny_samples.get_sample_name(container, slot_nr)
+
+ def otransfer_is_sample_in_samplestage(self) -> bool:
+ return dev.omny_samples.is_sample_in_samplestage()
+
+ def otransfer_set_shuttle_slot(self, container: str, slot_nr: int) -> bool:
+ return dev.omny_samples.set_shuttle_slot(container, slot_nr)
+
+ def otransfer_unset_shuttle_slot(self, slot_nr: int) -> bool:
+ return dev.omny_samples.unset_shuttle_slot(slot_nr)
+
+ def otransfer_get_shuttle_name_slot(self, slot_nr: int) -> str:
+ return dev.omny_samples.get_shuttle_name_slot(slot_nr)
+
+ def otransfer_is_shuttle_slot_used(self, slot_nr: int) -> bool:
+ return dev.omny_samples.is_shuttle_slot_used(slot_nr)
+
+ def otransfer_search_shuttle_in_slot(self, shuttle: str) -> int:
+ return dev.omny_samples.search_shuttle_in_slot(shuttle)
+
# def _otransfer_modify_sample(_ocontainer, _oposition, _ostatus, _oname):
# #_ocontainer is A, B, C for the pin containers
# #it can also be FZP, OSA and omny
@@ -1443,4 +1498,6 @@ class OMNYSampleTransferMixin:
)
print("omny.otransfer_get_sample(position) pick with the gripper from ")
print("omny.otransfer_put_sample(position) put with the gripper to ")
- print("To modify the storage information see dev.omny_samples.help()")
+ print("omny.otransfer_show_all() show the full sample storage overview")
+ print("For the other storage commands (set/unset slots, shuttles, gripper, sample")
+ print("stage) see the omny.otransfer_* commands, or dev.omny_samples.help()")
diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
index 0bec937c..1501623a 100644
--- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
+++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
@@ -9,20 +9,23 @@ different setups, detected at construction time (see ``SETUP_PROFILES``,
``_detect_setup()``) — a BEC session is always exactly one setup, never
both at once:
+Both setups require every sample slot to carry a name *and* an owner,
+packed into one EPICS DESC field (see ``sample_desc_codec``) since neither
+backend has a separate owner PV.
+
FlOMNI (``dev.flomni_samples``)
A flat magazine: slots 1-20, the sample stage (slot 0) and the gripper
- (slot 100). Each slot has a name *and* an owner, packed into one EPICS
- DESC field (see ``sample_desc_codec``).
+ (slot 100).
OMNY (``dev.omny_samples``)
A richer structure: three shuttles (A/B/C) of 6 slots each, a sparse
set of "fixed O positions" ({10,11,12,13,14,32,33,34,100,101}), a
separate sample stage and gripper, and a 6-slot "parking" carousel
recording which shuttle letter is docked at each physical parking
- position. No owner field -- names are raw strings. Whichever shuttle's
- parking slot matches the ``oparkz`` motor's current readback is
- "active", i.e. at the transfer-ready position (see
- ``oparkz_active_parking_slot()``).
+ position (parking slots are a shuttle-letter assignment, not a sample,
+ so they carry no name/owner). Whichever shuttle's parking slot matches
+ the ``oparkz`` motor's current readback is "active", i.e. at the
+ transfer-ready position (see ``oparkz_active_parking_slot()``).
A LamNI session has neither device; the widget shows a "no known setup
detected" message in that case.
@@ -125,7 +128,12 @@ OMNY_PARKING_SLOTS = tuple(range(1, 7))
PARKING_EMPTY_SENTINEL = "none"
# colours
-COLOR_OCCUPIED = "#1b1b1b"
+# An occupied slot's name label intentionally has no fixed foreground colour
+# here (see _SlotCell.set_state()) -- it was previously hardcoded to
+# "#1b1b1b" (near-black), which is fine on a light theme but is effectively
+# invisible against BEC's dark theme. Clearing the stylesheet instead lets
+# the label fall back to the application palette's default text colour,
+# which is correct in both themes.
COLOR_EMPTY = "#9e9e9e"
COLOR_STAGE_BORDER = "#2196F3"
COLOR_GRIPPER_BORDER = "#FF9800"
@@ -342,16 +350,19 @@ class _SlotCell(QFrame):
`measured_status` is "started"/"completed" (from `_MeasuredSampleLog`)
or None if this slot's current occupant has no measured record.
- OMNY cells always pass owner="" and measured_status=None (no owner
- field, no measured-sample log for OMNY), which the owner/measured
- labels below already render as blank.
+ OMNY cells always pass measured_status=None (no measured-sample log
+ for OMNY -- see module docstring), which the measured label below
+ already renders as blank.
"""
self._occupied = occupied
self._name = name if name else EMPTY_NAME
self._sample_owner = owner
if occupied:
self._lbl_name.setText(self._name)
- self._lbl_name.setStyleSheet(f"color: {COLOR_OCCUPIED};")
+ # no fixed colour here -- inherit the application palette's
+ # default text colour, which is correct in both light and dark
+ # themes (see the COLOR_EMPTY comment above)
+ self._lbl_name.setStyleSheet("")
self._lbl_owner.setText(f"owner: {owner}" if owner else "")
if measured_status == "completed":
self._lbl_measured.setText("✓ measured")
@@ -609,11 +620,10 @@ class OMNY_SampleStorage(BECWidget, QWidget):
"""Parse a dev.omny_samples.read(cached=True) dict into
{key: (occupied, name, owner, measured_status)} for every
sample-kind slot -- stage, gripper, the 10 fixed O positions, and
- the 18 shuttle slots. OMNY has no owner field and no
- measured-sample-log equivalent (see module docstring), so those two
- are always ""/None -- this keeps the tuple shape identical to
- FlOMNI's so _SlotCell.set_state() and _draw_report_cell() need no
- OMNY-specific branch.
+ the 18 shuttle slots. Like FlOMNI, OMNY has no separate owner PV, so
+ the name+owner pair is packed into the one raw name field (see
+ ``sample_desc_codec``); measured_status is always None -- OMNY has
+ no measured-sample-log equivalent (see module docstring).
"""
def _val(key, default=None):
@@ -622,26 +632,32 @@ class OMNY_SampleStorage(BECWidget, QWidget):
return entry.get("value", default)
return default
+ def _name_owner(raw):
+ name, owner = unpack_desc(raw if raw else EMPTY_NAME)
+ return (name if name else EMPTY_NAME), owner
+
state: dict[tuple, tuple[bool, str, str, Optional[str]]] = {}
used = bool(_val("omny_samples_sample_in_samplestage", 0))
- name = _val("omny_samples_sample_in_samplestage_name", EMPTY_NAME) or EMPTY_NAME
- state[("stage",)] = (used, name, "", None)
+ name, owner = _name_owner(_val("omny_samples_sample_in_samplestage_name", EMPTY_NAME))
+ state[("stage",)] = (used, name, owner, None)
used = bool(_val("omny_samples_sample_in_gripper", 0))
- name = _val("omny_samples_sample_in_gripper_name", EMPTY_NAME) or EMPTY_NAME
- state[("gripper",)] = (used, name, "", None)
+ name, owner = _name_owner(_val("omny_samples_sample_in_gripper_name", EMPTY_NAME))
+ state[("gripper",)] = (used, name, owner, None)
for n in OMNY_FIXED_POSITIONS:
used = bool(_val(f"omny_samples_sample_placed_sample{n}", 0))
- name = _val(f"omny_samples_sample_names_sample{n}", EMPTY_NAME) or EMPTY_NAME
- state[("O", n)] = (used, name, "", None)
+ name, owner = _name_owner(_val(f"omny_samples_sample_names_sample{n}", EMPTY_NAME))
+ state[("O", n)] = (used, name, owner, None)
for letter in OMNY_SHUTTLE_LETTERS:
for n in OMNY_SHUTTLE_SLOTS:
used = bool(_val(f"omny_samples_sample_shuttle_{letter}_placed_sample{n}", 0))
- name = _val(f"omny_samples_sample_shuttle_{letter}_names_sample{n}", EMPTY_NAME) or EMPTY_NAME
- state[("shuttle", letter, n)] = (used, name, "", None)
+ name, owner = _name_owner(
+ _val(f"omny_samples_sample_shuttle_{letter}_names_sample{n}", EMPTY_NAME)
+ )
+ state[("shuttle", letter, n)] = (used, name, owner, None)
return state
@@ -777,30 +793,32 @@ class OMNY_SampleStorage(BECWidget, QWidget):
)
return False
- def _write_slot_omny(self, key: tuple, used: int, name: str) -> bool:
- """Write (used, name) to an OMNY sample-kind slot -- stage, gripper,
- a fixed O position, or a shuttle slot -- mirroring the direct-signal
- style of ``_write_slot()``. No owner field and no measured-sample-log
- integration exist for OMNY (see module docstring), so unlike
- ``_write_slot`` this never offers to print a P-touch label for the
- previous occupant.
+ def _write_slot_omny(self, key: tuple, used: int, name: str, owner: str = "") -> bool:
+ """Write (used, name, owner) to an OMNY sample-kind slot -- stage,
+ gripper, a fixed O position, or a shuttle slot -- mirroring the
+ direct-signal style of ``_write_slot()``, including packing
+ name+owner into the one raw name field the same way (see
+ ``sample_desc_codec``). No measured-sample-log integration exists
+ for OMNY (see module docstring), so unlike ``_write_slot`` this
+ never offers to print a P-touch label for the previous occupant.
"""
try:
+ packed_name = pack_desc(name, owner)
kind = key[0]
if kind == "stage":
self._samples.sample_in_samplestage.set(used)
- self._samples.sample_in_samplestage_name.set(name)
+ self._samples.sample_in_samplestage_name.set(packed_name)
elif kind == "gripper":
self._samples.sample_in_gripper.set(used)
- self._samples.sample_in_gripper_name.set(name)
+ self._samples.sample_in_gripper_name.set(packed_name)
elif kind == "O":
_, n = key
getattr(self._samples.sample_placed, f"sample{n}").set(used)
- getattr(self._samples.sample_names, f"sample{n}").set(name)
+ getattr(self._samples.sample_names, f"sample{n}").set(packed_name)
elif kind == "shuttle":
_, letter, n = key
getattr(getattr(self._samples, f"sample_shuttle_{letter}_placed"), f"sample{n}").set(used)
- getattr(getattr(self._samples, f"sample_shuttle_{letter}_names"), f"sample{n}").set(name)
+ getattr(getattr(self._samples, f"sample_shuttle_{letter}_names"), f"sample{n}").set(packed_name)
else:
raise ValueError(f"unknown OMNY slot key {key!r}")
self._last_state.pop(key, None)
@@ -847,7 +865,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
plain ints.
"""
if isinstance(slot, tuple):
- return self._write_slot_omny(slot, used, name)
+ return self._write_slot_omny(slot, used, name, owner)
return self._write_slot(slot, used, name, owner)
def _slot_title(self, slot) -> str:
@@ -1049,8 +1067,8 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def action_change_name(self, slot, current_name: str, current_owner: str = "") -> None:
"""Rename an occupied slot (stays occupied). Shared by both setups
- (see ``_SlotCell``); the owner prompt only applies to FlOMNI's
- plain-int keys -- OMNY has no owner field.
+ (see ``_SlotCell``) -- both require a name and an owner (see module
+ docstring).
"""
name, ok = QInputDialog.getText(
self,
@@ -1069,22 +1087,18 @@ class OMNY_SampleStorage(BECWidget, QWidget):
"Use “Clear (set empty)” to empty the slot instead.",
)
return
- owner = current_owner
- if not isinstance(slot, tuple):
- owner, ok = QInputDialog.getText(
- self,
- "Sample owner",
- f"Owner for slot {self._slot_title(slot)}:",
- text=current_owner,
- )
- if not ok:
- return
- owner = owner.strip()
- if not owner:
- QMessageBox.warning(
- self, "Invalid owner", "An occupied slot needs a non-empty owner."
- )
- return
+ owner, ok = QInputDialog.getText(
+ self,
+ "Sample owner",
+ f"Owner for slot {self._slot_title(slot)}:",
+ text=current_owner,
+ )
+ if not ok:
+ return
+ owner = owner.strip()
+ if not owner:
+ QMessageBox.warning(self, "Invalid owner", "An occupied slot needs a non-empty owner.")
+ return
if self._write_sample(slot, 1, name, owner):
self.refresh()
@@ -1101,19 +1115,15 @@ class OMNY_SampleStorage(BECWidget, QWidget):
if not name or name == EMPTY_NAME:
QMessageBox.warning(self, "Invalid name", "Please enter a non-empty sample name.")
return
- owner = ""
- if not isinstance(slot, tuple):
- owner, ok = QInputDialog.getText(
- self, "Sample owner", f"Owner for slot {self._slot_title(slot)}:"
- )
- if not ok:
- return
- owner = owner.strip()
- if not owner:
- QMessageBox.warning(
- self, "Invalid owner", "An occupied slot needs a non-empty owner."
- )
- return
+ owner, ok = QInputDialog.getText(
+ self, "Sample owner", f"Owner for slot {self._slot_title(slot)}:"
+ )
+ if not ok:
+ return
+ owner = owner.strip()
+ if not owner:
+ QMessageBox.warning(self, "Invalid owner", "An occupied slot needs a non-empty owner.")
+ return
if self._write_sample(slot, 1, name, owner):
self.refresh()
diff --git a/docs/user/ptychography/omny.md b/docs/user/ptychography/omny.md
index e910c811..8c5719d0 100644
--- a/docs/user/ptychography/omny.md
+++ b/docs/user/ptychography/omny.md
@@ -12,7 +12,7 @@ OMNY is a microscope setup for 3D mesurements via ptychographic X-ray computed t
The sample storage, shuttles and parking positions are described in detail (here)[user.ptychography.omny.samples].
1. `omny.otransfer_get_sample(0)` remove current sample from sample stage. Watch gripper action and be ready to ctrl+c in case something is wrong.
-_If in doubt check that the correct sample shutte is in the active position by calling `omny.otransfer_storage()`. The slot can be selected by `omny.otransfer_park_slot(slot)`._
+_If in doubt check that the correct sample shutte is in the active position by calling `omny.otransfer_show_all()`. The slot can be selected by `omny.otransfer_park_slot(slot)`._
1. `omny.otransfer_put_sample(position)` put the sample to the selected shuttle
1. `omny.otransfer_get_sample(position) ` get the new sample from a shuttle
1. `omny.otransfer_put_sample(0)`, mount the sample in the sample stage
@@ -57,7 +57,7 @@ Try moving up and down a bit `umvr(dev.otransy,0.5)`, `umvr(dev.otransy,-0.5)`,
Then, `umv(dev.ootransy,-1.5)` followed by
`omny._otransfer_gripper_to_park_z()`, which will move the gripper to its parking position.
-If this error happens after a sample was mounted or unmounted, it is important to chech that the sample storage is correct `omny.otransfer_storage()`.
+If this error happens after a sample was mounted or unmounted, it is important to chech that the sample storage is correct `omny.otransfer_show_all()`.
(user.ptychography.omny.samples)=
### Sample storage and transfer
@@ -88,7 +88,7 @@ This loading status is handled via a OMNY samples device: `dev.omny_samples`
Within the BEC client session `dev.omny_samples.help()` will display all required commands with a short explanation.
-To get an overview use `dev.omny_samples.show_all()` or `omny.otransfer_storage()`
+To get an overview use `dev.omny_samples.show_all()` or `omny.otransfer_show_all()`
Modify a slot position of systems "A", "B", "C", "O":
`dev.omny_samples.unset_sample_slot('system',position)` free a sample position.
@@ -110,7 +110,7 @@ Here is an _example_: dev.omny_samples.set_shuttle_slot('A',2)
#### Sample transfer
-Once the sample places are set correctly and checked by using `omny.otransfer_storage()`, the following commands are available for sample change. To pick a sample from a parking slot, the parking slot stage has to be moved to the correct parking slot position. If a parking position is active, this information is displayed in sample storage overview.
+Once the sample places are set correctly and checked by using `omny.otransfer_show_all()`, the following commands are available for sample change. To pick a sample from a parking slot, the parking slot stage has to be moved to the correct parking slot position. If a parking position is active, this information is displayed in sample storage overview.
| Command | Explanation |
| --- | --- |
From f9a1f4a7d2ad4e26447204d3daca7c98751147b0 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Wed, 2 Sep 2026 18:37:37 +0200
Subject: [PATCH 028/102] fix(omny): warn about too-few Fermat points; fix
live-testing crashes
Live-testing findings from Mirko's own session, four real bugs:
- tomo_parameters() now warns when the current fovx/fovy/tomo_shellstep
would yield fewer than OmnyFermatScan.MIN_POSITIONS (20) Fermat scan
points, matching Flomni.tomo_parameters()'s identical warning -- the scan
server already aborted (ScanAbortion) on too few points, but nothing told
the user ahead of time. Needed converting
OmnyFermatScan.get_omny_fermat_spiral_pos() to a @staticmethod (was an
instance method reading self.cenx/ceny/zshift) so it's callable from
tomo_parameters() without a full scan instance, matching flomni's
already-static version. Also promoted the hardcoded "20" in
prepare_scan()'s abort check to a MIN_POSITIONS class constant.
- OMNY.tomo_scan() didn't accept interactive=, so every
tomo_queue_execute() run (which always calls tomo_scan(interactive=False)
for unattended queued runs) failed immediately with TypeError. Added the
parameter, accepted but unused, matching flomni's own docstring reasoning
(no fine-alignment gate on either setup).
- write_to_scilog()/write_pdf_report() used the old bec.logbook API with no
"is scilog configured" check, so a session without scilog credentials
hung indefinitely instead of failing fast (confirmed live: 29+ minutes).
Migrated both to bec.messaging.scilog, gated on _enabled, matching
flomni's actual current mechanism.
- Stale ~/Data10/specES1/ paths (predating the current ~/data/raw/ deployment
layout) crashed _write_tomo_scan_number() with FileNotFoundError on a
fresh session. Fixed that plus write_pdf_report()'s PDF target and
tomo_reconstruct()'s default base_path, all three sharing the same stale
root -- now also explicitly create their target directory first, since
flomni's own equivalents assume it already exists.
16 new tests across test_omny_fermat_scan.py, test_omny_tomo_angles.py, and
test_omny_tomo_scan.py. Full suite: 725 passed.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 63 ++++++++++++++-
.../bec_ipython_client/plugins/omny/omny.py | 81 +++++++++++++++++--
csaxs_bec/scans/omny_fermat_scan.py | 30 +++----
.../test_omny_tomo_angles.py | 63 +++++++++++++++
.../test_omny_tomo_scan.py | 39 +++++++++
tests/tests_scans/test_omny_fermat_scan.py | 59 ++++++++++++++
6 files changed, 312 insertions(+), 23 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 971f1d3f..34851dd5 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -30,7 +30,10 @@ as a wear limit).
OMNY) — `OMNY` now has a real job queue, `at_each_angle_hook` registration, `tomo_scan_resume()`,
and scan progress visible outside the CLI session (previously a plain in-memory dict). See "Tomo-
queue backend ported to OMNY" below. Phase 2 (the widget itself) is a separate follow-up, not yet
-started — not live-tested against a running session yet either; that's next, once Mirko pulls.
+started. Live-tested by Mirko against his own session (not a sim harness), which surfaced and
+fixed several more real bugs — see "scilog writes hung / migrated to bec.messaging.scilog",
+"tomo_scan() rejected TomoQueueMixin's interactive= kwarg", "stale ~/Data10/ paths crashed on a
+fresh session", and "tomo_parameters() now warns about too few Fermat scan points" below.
**Still open / deferred** (unchanged from before, not touched this pass):
- `x_ray_eye_align.py` is still LamNI-derived, unadapted code beyond the two call-site renames
@@ -40,6 +43,10 @@ started — not live-tested against a running session yet either; that's next, o
dewar/temperature methods, sample storage bookkeeping, and `omny_alignment_mixin.py`/
`omny_optics_mixin.py` beyond what scans exercise — see "Checklist" section below.
- The `TomoParamsWidget`/`OMNY_TomoParams` GUI widget itself (Phase 2 of the tomo-queue port).
+- `omny_alignment_mixin.py`/`x_ray_eye_align.py` still reference the old `~/Data10/specES1/internal/`
+ path for alignment-offset data (unlike the tomo-scan-path fixes below, these already tolerate a
+ missing path gracefully — confirmed live, no crash, just "No data available" -- so left alone for
+ now, but worth the same `~/data/raw/` treatment eventually for consistency).
## Standing question: OMNY should follow flOMNI, not LamNI
@@ -372,6 +379,60 @@ the local bec-server may be in active use by Mirko's own parallel session; a ful
numbers) -- next step is Mirko pulling and testing live himself, same pattern as this whole
engagement.
+## Live-testing findings after the Phase 1 port (fixed)
+
+Mirko live-tested Phase 1 against his own session (not a sim harness) and found four more real
+bugs, all fixed in a follow-up pass:
+
+**scilog writes hung indefinitely.** `write_to_scilog()`/`write_pdf_report()` used the old
+`bec.logbook`/`client.logbook.send_logbook_message()` API, which has no "is scilog even
+configured" check -- on a session without scilog credentials, `send()` blocks forever instead of
+failing fast. Confirmed live: a real `tomo_scan()` hung for 29+ minutes inside
+`_write_subtomo_to_scilog()`. Per Mirko's direction ("Scilog Messaging should check the account.
+In flomni we are doing this I believe"): flomni's actual mechanism turned out to be
+`bec.messaging.scilog._enabled` (not literally an account check, but the same effect -- gates the
+send behind whether scilog is actually configured/reachable). Migrated both OMNY call sites onto
+the same `bec.messaging.scilog` API, matching flomni's `_scilog_write()`/`write_pdf_report()`
+exactly. 3 new unit tests lock in the `_enabled` gate.
+
+**`tomo_scan()` didn't accept `interactive=`.** `TomoQueueMixin.tomo_queue_execute()`
+unconditionally calls `self.tomo_scan(interactive=False)` for unattended queued runs (the same
+call convention flomni/lamni already support) -- `OMNY.tomo_scan()`'s signature never had this
+parameter at all, so every `tomo_queue_execute()` run failed immediately with `TypeError: got an
+unexpected keyword argument 'interactive'`. Fixed by adding `interactive: bool = True`, accepted
+but currently unused -- matching flomni's own docstring reasoning exactly ("FlOMNI has no
+fine-alignment gate"; OMNY doesn't either).
+
+**Stale `~/Data10/specES1/` paths crashed on a fresh session.** Confirmed live (`FileNotFoundError`
+on Mirko's Mac): `_write_tomo_scan_number()` wrote to
+`~/Data10/specES1/dat-files/tomography_scannumbers.txt`, a layout that doesn't exist in the current
+deployment at all -- flomni's own equivalent method already uses `~/data/raw/logs/`. Same fix
+applied to `write_pdf_report()`'s PDF target (`~/Data10/documentation/` ->
+`~/data/raw/documentation/`) and `tomo_reconstruct()`'s default `base_path` (`~/Data10/specES1` ->
+`~/data/raw`), all three of which shared the identical stale root. Unlike flomni's own
+`_write_tomo_scan_number()`/`write_pdf_report()` (which assume the target directory already
+exists, and would hit this same crash on a machine where it doesn't), OMNY's versions now
+explicitly `os.makedirs(..., exist_ok=True)` first -- a small hardening beyond a pure mirror,
+directly motivated by this exact crash.
+
+**`tomo_parameters()` now warns about too few Fermat scan points.** `OmnyFermatScan.prepare_scan()`
+already aborted (`ScanAbortion`) when the fovx/fovy/step combination yields fewer than
+`MIN_POSITIONS` (20) points -- that abort was intentional, but nothing warned ahead of time; the
+scan would just silently fail once actually run. Direct port of `Flomni.tomo_parameters()`'s
+identical warning line, added right after "Stitching overlap" in the printout: "Estimated Fermat
+scan points per projection: N (WARNING: below the minimum of 20 -- the scan will abort when run)".
+Needed a small supporting refactor: `OmnyFermatScan.get_omny_fermat_spiral_pos()` was an instance
+method reading `self.cenx`/`self.ceny`/`self.zshift` (unlike flomni's already-`@staticmethod`
+version), so it couldn't be called from `tomo_parameters()` without constructing a full scan
+instance -- converted to a `@staticmethod` taking `cenx`/`ceny`/`zshift` as explicit keyword args
+(default `0.0`, behavior-preserving), matching flomni's signature exactly. Also promoted the
+hardcoded `20` in `prepare_scan()`'s abort check to a `MIN_POSITIONS = 20` class constant (mirrors
+`FlomniFermatScan.MIN_POSITIONS`), so the CLI warning and the scan server's actual enforcement can
+never drift out of sync. 13 new tests across `test_omny_fermat_scan.py` (scan-level:
+`MIN_POSITIONS` value, static-method callability, cenx/ceny/zshift not affecting the count,
+`prepare_scan()` still aborts) and `test_omny_tomo_angles.py` (CLI-level: warning appears/doesn't
+appear).
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index 231194a4..4653fc98 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -1265,8 +1265,18 @@ class OMNY(
for scan_nr in range(start_scan_number, end_scan_number):
self._write_tomo_scan_number(scan_nr, angle, subtomo_number)
- def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None):
- """start a tomo scan"""
+ def tomo_scan(
+ self, subtomo_start=1, start_angle=None, projection_number=None, interactive: bool = True
+ ):
+ """start a tomo scan
+
+ Args:
+ interactive: accepted for signature compatibility with
+ TomoQueueMixin.tomo_queue_execute(), which always calls
+ self.tomo_scan(interactive=False) for unattended queued runs
+ (matching flomni's/lamni's call convention) -- unused here,
+ OMNY has no fine-alignment gate.
+ """
self.omnygui_show_progress()
bec = builtins.__dict__.get("bec")
@@ -1578,7 +1588,7 @@ class OMNY(
return angle, subtomo_number
- def tomo_reconstruct(self, base_path="~/Data10/specES1"):
+ def tomo_reconstruct(self, base_path="~/data/raw"):
"""write the tomo reconstruct file for the reconstruction queue"""
bec = builtins.__dict__.get("bec")
base_path = os.path.expanduser(base_path)
@@ -1596,9 +1606,16 @@ class OMNY(
queue_file.write("p.check_nextscan_started 1\n")
def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None:
- tomo_scan_numbers_file = os.path.expanduser(
- "~/Data10/specES1/dat-files/tomography_scannumbers.txt"
- )
+ # ~/data/raw/logs/ matches flomni's own _write_tomo_scan_number() --
+ # the old ~/Data10/specES1/dat-files/ layout doesn't exist in the
+ # current deployment (confirmed live: FileNotFoundError on a fresh
+ # session). Unlike flomni's version, explicitly create the
+ # directory here rather than assuming something else already has --
+ # flomni's own identical open("a+") call has the same latent
+ # assumption, just usually gets away with it because the directory
+ # already exists on deployed systems.
+ tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt")
+ os.makedirs(os.path.dirname(tomo_scan_numbers_file), exist_ok=True)
with open(tomo_scan_numbers_file, "a+") as out_file:
# pylint: disable=undefined-variable
out_file.write(
@@ -1672,6 +1689,39 @@ class OMNY(
account = os.getenv("USER") or ""
return bool(account) and account in _SUPERUSER_ACCOUNTS
+ def _expected_fermat_position_count(self) -> int:
+ """Estimate the number of Fermat scan points the current
+ fovx/fovy/tomo_shellstep settings would produce, 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().
+ Direct port of Flomni._expected_fermat_position_count().
+
+ cenx/ceny/zshift are deliberately left at their defaults: they only
+ shift the final position, applied after the FOV keep-or-discard
+ check, so they can never affect the resulting count.
+ """
+ from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan
+
+ positions = OmnyFermatScan.get_omny_fermat_spiral_pos(
+ -abs(self.fovx / 2),
+ abs(self.fovx / 2),
+ -abs(self.fovy / 2),
+ abs(self.fovy / 2),
+ step=self.tomo_shellstep,
+ spiral_type=0,
+ center=False,
+ )
+ return len(positions)
+
+ @staticmethod
+ def _fermat_min_positions() -> int:
+ """OmnyFermatScan's own minimum-position threshold -- see
+ _expected_fermat_position_count()."""
+ from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan
+
+ return OmnyFermatScan.MIN_POSITIONS
+
def tomo_parameters(self):
"""print and update the tomo parameters"""
print("Current settings:")
@@ -1680,6 +1730,16 @@ class OMNY(
print(f"FOV (200/100) = {self.fovx}, {self.fovy}")
print(f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}")
print(f"Stitching overlap = {self.tomo_stitch_overlap}")
+ 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}")
print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}")
print(f" _manual_shift_y = {self.manual_shift_y}")
print("")
@@ -1896,7 +1956,14 @@ class OMNY(
"Add current temperature and pressure status",
]
content = "".join(content)
- user_target = os.path.expanduser(f"~/Data10/documentation/tomo_scan_ID_{self.tomo_id}.pdf")
+ # ~/data/raw/documentation/ matches flomni's/lamni's own
+ # write_pdf_report() target -- the old ~/Data10/documentation/
+ # layout doesn't exist in the current deployment. PDFWriter doesn't
+ # create its own parent directory, so do it explicitly here.
+ user_target = os.path.expanduser(
+ f"~/data/raw/documentation/tomo_scan_ID_{self.tomo_id}.pdf"
+ )
+ os.makedirs(os.path.dirname(user_target), exist_ok=True)
with PDFWriter(user_target) as file:
file.write(header)
file.write(content)
diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py
index 11f84007..c5a92e19 100644
--- a/csaxs_bec/scans/omny_fermat_scan.py
+++ b/csaxs_bec/scans/omny_fermat_scan.py
@@ -46,6 +46,7 @@ class OmnyFermatScan(ScanBase):
# It must be a valid Python identifier, that is, it can only contain letters, numbers, and underscores, and must not start with a number.
scan_name = "omny_fermat_scan"
+ MIN_POSITIONS = 20 # Minimum number of positions required for a valid scan
MAX_CORRIDOR_SIZE = 3 # Corridor size is capped at this value (in um) for stability
gui_config = {
@@ -150,9 +151,10 @@ class OmnyFermatScan(ScanBase):
center=False,
)
- if len(positions) < 20:
+ if len(positions) < self.MIN_POSITIONS:
raise ScanAbortion(
- f"The number of positions must exceed 20. Currently: {len(positions)}."
+ f"The number of positions must exceed {self.MIN_POSITIONS}. Currently:"
+ f" {len(positions)}."
)
corridor_size = self.corridor_size
@@ -285,8 +287,8 @@ class OmnyFermatScan(ScanBase):
)
return val
+ @staticmethod
def get_omny_fermat_spiral_pos(
- self,
m1_start: float,
m1_stop: float,
m2_start: float,
@@ -294,6 +296,9 @@ class OmnyFermatScan(ScanBase):
step: float = 1,
spiral_type: int = 0,
center: bool = False,
+ cenx: float = 0.0,
+ ceny: float = 0.0,
+ zshift: float = 0.0,
):
"""
Calculate positions for a Fermat spiral scan.
@@ -306,6 +311,9 @@ class OmnyFermatScan(ScanBase):
step(float): stepsize
spiral_type(int): 0 for traditional Fermat spiral
center(bool): whether to include the center position
+ cenx(float): center offset in x
+ ceny(float): center offset in y
+ zshift(float): shift in z
Returns:
positions(array): positions
@@ -319,7 +327,7 @@ class OmnyFermatScan(ScanBase):
length_axis2 = np.abs(m2_stop - m2_start)
n_max = int(length_axis1 * length_axis2 * 3.2 / step / step)
- z_pos = self.zshift
+ z_pos = zshift
for ii in range(start, n_max):
radius = step * 0.57 * np.sqrt(ii)
@@ -330,17 +338,9 @@ class OmnyFermatScan(ScanBase):
continue
x = radius * np.sin(ii * phi)
y = radius * np.cos(ii * phi)
- positions.append([x + self.cenx, y + self.ceny, z_pos])
- left_lower_corner = [
- min(m1_start, m1_stop) + self.cenx,
- min(m2_start, m2_stop) + self.ceny,
- z_pos,
- ]
- right_upper_corner = [
- max(m1_start, m1_stop) + self.cenx,
- max(m2_start, m2_stop) + self.ceny,
- z_pos,
- ]
+ positions.append([x + cenx, y + ceny, z_pos])
+ left_lower_corner = [min(m1_start, m1_stop) + cenx, min(m2_start, m2_stop) + ceny, z_pos]
+ right_upper_corner = [max(m1_start, m1_stop) + cenx, max(m2_start, m2_stop) + ceny, z_pos]
positions.append(left_lower_corner)
positions.append(right_upper_corner)
return np.array(positions)
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
index a9671af1..a3e1a411 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
@@ -387,3 +387,66 @@ def test_tomo_parameters_clamps_type_3_step_size_to_max_2_5_degrees(monkeypatch,
assert omny.tomo_type == 3
assert omny.tomo_angle_stepsize == pytest.approx(2.5)
+
+
+# --- tomo_parameters(): warns early about too few Fermat scan points --------
+#
+# omny_fermat_scan.OmnyFermatScan.prepare_scan() aborts (ScanAbortion) when
+# the FOV/step combination yields fewer than MIN_POSITIONS points -- that was
+# already the intended behavior, but nothing warned the user ahead of time;
+# the scan would just silently fail once actually run. Direct port of
+# Flomni.tomo_parameters()'s identical warning line.
+
+
+def test_tomo_parameters_warns_when_fermat_positions_below_minimum(monkeypatch, fake_bec, capsys):
+ fake_bec.active_account = "e00001"
+ omny = make_omny()
+ omny.tomo_countingtime = 1.0
+ omny.tomo_shellstep = 50.0 # coarse step + tiny FOV -> too few points
+ omny.fovx = 1
+ omny.fovy = 1
+ omny.stitch_x = 0
+ omny.stitch_y = 0
+ omny.tomo_stitch_overlap = 0.2
+ omny.ptycho_reconstruct_foldername = "queue"
+ omny.manual_shift_y = 0.0
+ omny.tomo_angle_stepsize = 10.0
+ omny.tomo_type = 1
+ fake_dev = types.SimpleNamespace(
+ omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ )
+ monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
+ omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: True) # accept as-is, no edits
+
+ omny.tomo_parameters()
+
+ out = capsys.readouterr().out
+ assert "WARNING: below the minimum" in out
+ assert "the scan will abort when run" in out
+
+
+def test_tomo_parameters_no_warning_when_fermat_positions_sufficient(monkeypatch, fake_bec, capsys):
+ fake_bec.active_account = "e00001"
+ omny = make_omny()
+ omny.tomo_countingtime = 1.0
+ omny.tomo_shellstep = 0.5
+ omny.fovx = 20
+ omny.fovy = 20
+ omny.stitch_x = 0
+ omny.stitch_y = 0
+ omny.tomo_stitch_overlap = 0.2
+ omny.ptycho_reconstruct_foldername = "queue"
+ omny.manual_shift_y = 0.0
+ omny.tomo_angle_stepsize = 10.0
+ omny.tomo_type = 1
+ fake_dev = types.SimpleNamespace(
+ omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ )
+ monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
+ omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: True)
+
+ omny.tomo_parameters()
+
+ out = capsys.readouterr().out
+ assert "WARNING: below the minimum" not in out
+ assert "Estimated Fermat scan points per projection:" in out
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
index 202d3a20..2bda299d 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
@@ -125,6 +125,18 @@ def test_tomo_scan_shows_progress_gui(monkeypatch):
assert calls == ["shown"]
+def test_tomo_scan_accepts_interactive_kwarg(monkeypatch):
+ """TomoQueueMixin.tomo_queue_execute() always calls
+ self.tomo_scan(interactive=False) for unattended queued runs -- confirmed
+ live: OMNY.tomo_scan() didn't accept this kwarg at all, so every queued
+ job failed with 'got an unexpected keyword argument interactive'."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+
+ omny.tomo_scan(interactive=False) # must not raise TypeError
+
+ assert omny.progress["tomo_start_time"] is not None
+
+
def test_tomo_scan_clears_heartbeat_on_normal_completion(monkeypatch):
"""The busy-detector heartbeat must be cleared as soon as the scan
finishes, not left for the next poll to time out before the GUI stops
@@ -255,3 +267,30 @@ def test_write_to_scilog_tolerates_send_exception(monkeypatch):
result = omny.write_to_scilog("hello")
assert result is False
+
+
+# --- _write_tomo_scan_number(): ~/data/raw/logs/, creating it if missing ----
+#
+# Confirmed live: the old ~/Data10/specES1/dat-files/ path didn't exist at
+# all on a fresh session (a Mac dev machine), crashing with
+# FileNotFoundError the moment a real tomo scan tried to log its first
+# projection. Moved to ~/data/raw/logs/ (matching flomni's own
+# _write_tomo_scan_number()) and, unlike flomni's identical open("a+") call,
+# explicitly create the directory first rather than assume it already
+# exists.
+
+
+def test_write_tomo_scan_number_creates_missing_directory(monkeypatch, tmp_path):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ omny = object.__new__(OMNY)
+ omny.tomo_id = 1
+ fake_dev = types.SimpleNamespace(
+ osamroy=types.SimpleNamespace(read=lambda: {"osamroy": {"value": 12.5}}),
+ omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample"),
+ )
+ monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
+
+ omny._write_tomo_scan_number(844, 12.5, 1)
+
+ written = (tmp_path / "data" / "raw" / "logs" / "tomography_scannumbers.txt").read_text()
+ assert written == "844 12.5 12.500 1 1 0 test_sample\n"
diff --git a/tests/tests_scans/test_omny_fermat_scan.py b/tests/tests_scans/test_omny_fermat_scan.py
index f403fcad..f17c7498 100644
--- a/tests/tests_scans/test_omny_fermat_scan.py
+++ b/tests/tests_scans/test_omny_fermat_scan.py
@@ -1,6 +1,7 @@
from unittest import mock
import numpy as np
+import pytest
from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan
@@ -74,3 +75,61 @@ def test_estimate_corridor_size_scales_with_factor():
doubled_factor = OmnyFermatScan._estimate_corridor_size(positions, factor=3.0)
assert np.isclose(doubled_factor, 2 * default_factor)
+
+
+def test_min_positions_matches_flomni():
+ """OmnyFermatScan.MIN_POSITIONS must exist as a class constant (not a
+ hardcoded literal in prepare_scan()) so OMNY.tomo_parameters() can warn
+ early against the same threshold the scan server actually enforces,
+ mirroring FlomniFermatScan.MIN_POSITIONS."""
+ assert OmnyFermatScan.MIN_POSITIONS == 20
+
+
+def test_get_omny_fermat_spiral_pos_is_static_and_callable_without_an_instance():
+ """Ported to a @staticmethod (was an instance method reading
+ self.cenx/self.ceny/self.zshift) so OMNY.tomo_parameters() can call it
+ directly (via the class, like Flomni._expected_fermat_position_count()
+ does with FlomniFermatScan.get_flomni_fermat_spiral_pos()) without
+ constructing a full scan instance."""
+ positions = OmnyFermatScan.get_omny_fermat_spiral_pos(-5, 5, -5, 5, step=1, spiral_type=0)
+ assert len(positions) > 0
+
+
+def test_get_omny_fermat_spiral_pos_count_ignores_cenx_ceny_zshift():
+ """cenx/ceny/zshift only shift the final position (applied after the
+ FOV keep-or-discard check), so they must never change the resulting
+ position count -- same invariant flomni's version relies on."""
+ baseline = OmnyFermatScan.get_omny_fermat_spiral_pos(-5, 5, -5, 5, step=1, spiral_type=0)
+ shifted = OmnyFermatScan.get_omny_fermat_spiral_pos(
+ -5, 5, -5, 5, step=1, spiral_type=0, cenx=100.0, ceny=-50.0, zshift=7.0
+ )
+
+ assert len(shifted) == len(baseline)
+ # the offset is applied uniformly to every returned position
+ assert np.allclose(shifted[:, 0] - baseline[:, 0], 100.0)
+ assert np.allclose(shifted[:, 1] - baseline[:, 1], -50.0)
+ assert np.allclose(shifted[:, 2], 7.0)
+
+
+def test_prepare_scan_aborts_below_min_positions():
+ """Regression guard for the MIN_POSITIONS refactor: prepare_scan() must
+ still reject a too-small FOV/step combination the same way it did with
+ the old hardcoded literal."""
+ from bec_server.scan_server.errors import ScanAbortion
+
+ scan = mock.MagicMock()
+ scan.fovx = 1
+ scan.fovy = 1
+ scan.step = 1
+ scan.zshift = 0
+ scan.cenx = 0
+ scan.ceny = 0
+ scan.corridor_size = None
+ scan.MIN_POSITIONS = OmnyFermatScan.MIN_POSITIONS
+ # get_omny_fermat_spiral_pos is now a @staticmethod -- bind the real
+ # function so prepare_scan()'s self.get_omny_fermat_spiral_pos(...) call
+ # runs the actual math instead of an auto-mocked stand-in.
+ scan.get_omny_fermat_spiral_pos = OmnyFermatScan.get_omny_fermat_spiral_pos
+
+ with pytest.raises(ScanAbortion):
+ OmnyFermatScan.prepare_scan(scan)
From 52c1806fe22ba6e8e811591654d0d6ccef465190 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 02:35:34 +0200
Subject: [PATCH 029/102] fix(sample_storage): enforce unique sample names in
the widget
Renaming or newly naming a slot now rejects a name already used by another
occupied slot in the active setup (FlOMNI or OMNY), so bookkeeping can't
end up with two same-named samples that are impossible to tell apart later
(including in the FlOMNI measured-sample log, which is keyed by name+owner).
Renaming a slot to its own existing name is still allowed.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../widgets/sample_storage/sample_storage.py | 46 ++++++++++++++++++-
1 file changed, 44 insertions(+), 2 deletions(-)
diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
index 1501623a..8b4bc0b1 100644
--- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
+++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
@@ -1065,10 +1065,34 @@ class OMNY_SampleStorage(BECWidget, QWidget):
# ── mutation actions (called from _SlotCell context menu) ─────────────────
+ def _find_duplicate_name(self, slot, name: str):
+ """Return the slot key that already holds `name`, if any OTHER
+ occupied slot of the active setup currently has this exact name, or
+ None. Used by action_change_name()/action_new_sample() to enforce
+ that sample names are unique -- otherwise the measured-sample log
+ (FlOMNI, keyed by name+owner) and operators reading the storage
+ overview could no longer tell two same-named samples apart.
+
+ Reads from ``self._last_state``, the same poll-refreshed snapshot
+ the cells themselves are painted from, rather than issuing a fresh
+ device read -- consistent with the rest of this widget's
+ eventual-consistency model (a write immediately after a poll could
+ in principle race a concurrent CLI rename, same as any other
+ interaction here; see module docstring on operator discipline).
+ """
+ for key, entry in self._last_state.items():
+ if key == slot:
+ continue
+ occupied, existing_name = entry[0], entry[1]
+ if occupied and existing_name == name:
+ return key
+ return None
+
def action_change_name(self, slot, current_name: str, current_owner: str = "") -> None:
"""Rename an occupied slot (stays occupied). Shared by both setups
(see ``_SlotCell``) -- both require a name and an owner (see module
- docstring).
+ docstring), and both require the name to be unique across the
+ active setup's slots (see ``_find_duplicate_name``).
"""
name, ok = QInputDialog.getText(
self,
@@ -1087,6 +1111,15 @@ class OMNY_SampleStorage(BECWidget, QWidget):
"Use “Clear (set empty)” to empty the slot instead.",
)
return
+ dup = self._find_duplicate_name(slot, name)
+ if dup is not None:
+ QMessageBox.warning(
+ self,
+ "Duplicate name",
+ f"'{name}' is already used by slot {self._slot_title(dup)}.\n"
+ "Sample names must be unique.",
+ )
+ return
owner, ok = QInputDialog.getText(
self,
"Sample owner",
@@ -1104,7 +1137,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def action_new_sample(self, slot) -> None:
"""Mark an empty slot occupied and give it a name. Shared by both
- setups (see ``action_change_name``).
+ setups (see ``action_change_name``), including the uniqueness check.
"""
name, ok = QInputDialog.getText(
self, "New sample", f"Sample name for slot {self._slot_title(slot)}:"
@@ -1115,6 +1148,15 @@ class OMNY_SampleStorage(BECWidget, QWidget):
if not name or name == EMPTY_NAME:
QMessageBox.warning(self, "Invalid name", "Please enter a non-empty sample name.")
return
+ dup = self._find_duplicate_name(slot, name)
+ if dup is not None:
+ QMessageBox.warning(
+ self,
+ "Duplicate name",
+ f"'{name}' is already used by slot {self._slot_title(dup)}.\n"
+ "Sample names must be unique.",
+ )
+ return
owner, ok = QInputDialog.getText(
self, "Sample owner", f"Owner for slot {self._slot_title(slot)}:"
)
From afc13df24ddf28e09918e090aa2d70ea72db0c3c Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 02:45:59 +0200
Subject: [PATCH 030/102] feat(omny): add third progress ring, idle-time
tracking, timing summary
Mirko: "the flomni progress bar has three rings. here i am seing only
two... at the end of a tomogram in flomni a summary of the measurement
time and time lost is displayed. omny seems not to have that, maybe not
even measuring the time."
Both traced back to the same root gap left by the Phase-1 tomo-queue port:
nothing computed estimated_remaining_time/estimated_finish_time, and
nothing refreshed progress["heartbeat"] *during* a running scan (only
cleared it to None on completion) -- so accumulated_idle_time stayed 0.0
forever and there was no ETA data to show or summarize.
- _tomo_scan_at_angle() now refreshes the heartbeat every angle and
attributes any gap beyond a normal-cadence heuristic to
accumulated_idle_time (mirrors flomni's identical formula, minus its
frames_per_trigger factor which OMNY has no property for).
- _print_progress() computes/stores/prints estimated_remaining_time and
estimated_finish_time once the scan rate has stabilized.
- tomo_scan() prints an end-of-scan "Total measurement time"/"...excluding
detected gaps"/"...lost to detected gaps" summary and sends it to scilog,
direct port of flomni's block (minus its measured_log call, which OMNY
has no equivalent of).
- gui_tools.py: omnygui_show_progress() adds flomni's third, scan-linked
auto-updating ring; _omnygui_update_progress()'s center label now shows
start time / ETA / estimated finish / active hook, matching flomni's
_flomnigui_update_progress().
_describe_active_hook()/_active_hook_source() needed no porting -- already
provided by TomoQueueMixin since Phase 1. Not ported: flomni's separate
persistent timing-statistics-log subsystem (_log_tomogram_timing()) --
out of scope for what was actually asked (the printed summary + ring
count).
9 new tests (test_omny_tomo_scan.py, test_omny_gui_tools.py). Full suite:
731 passed.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 77 +++++++++----
.../plugins/omny/gui_tools.py | 67 +++++++++--
.../bec_ipython_client/plugins/omny/omny.py | 107 +++++++++++++++++-
.../test_omny_gui_tools.py | 72 ++++++++++++
.../test_omny_tomo_scan.py | 90 ++++++++++++++-
5 files changed, 376 insertions(+), 37 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 34851dd5..4e69ef53 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -1,4 +1,4 @@
-# OMNY open issues (as of 2026-09-02)
+# OMNY open issues (as of 2026-09-03)
Tracks the live-testing effort for bringing OMNY up to flomni's/BEC's current standards on
branch `update-omny`: bugs found (fixed and unfixed) and what's still untested. Written so a
@@ -26,14 +26,19 @@ removed" below. Tomo type 3 also gained a hard cap of 2.5 degrees on the angular
only a minimum-100-projections floor, which happened to already satisfy this but wasn't expressed
as a wear limit).
-**Just landed**: the tomo-queue backend (Phase 1 of 2 of porting the tomo params GUI widget to
-OMNY) — `OMNY` now has a real job queue, `at_each_angle_hook` registration, `tomo_scan_resume()`,
-and scan progress visible outside the CLI session (previously a plain in-memory dict). See "Tomo-
-queue backend ported to OMNY" below. Phase 2 (the widget itself) is a separate follow-up, not yet
-started. Live-tested by Mirko against his own session (not a sim harness), which surfaced and
-fixed several more real bugs — see "scilog writes hung / migrated to bec.messaging.scilog",
-"tomo_scan() rejected TomoQueueMixin's interactive= kwarg", "stale ~/Data10/ paths crashed on a
-fresh session", and "tomo_parameters() now warns about too few Fermat scan points" below.
+**Just landed, confirmed working live**: the tomo-queue backend (Phase 1 of 2 of porting the tomo
+params GUI widget to OMNY) — `OMNY` now has a real job queue, `at_each_angle_hook` registration,
+`tomo_scan_resume()`, and scan progress visible outside the CLI session (previously a plain
+in-memory dict). See "Tomo-queue backend ported to OMNY" below. Live-tested by Mirko against his
+own session (not a sim harness), which surfaced and fixed several more real bugs along the way —
+see "scilog writes hung / migrated to bec.messaging.scilog", "tomo_scan() rejected
+TomoQueueMixin's interactive= kwarg", "stale ~/Data10/ paths crashed on a fresh session", and
+"tomo_parameters() now warns about too few Fermat scan points" below. Mirko confirmed: "tomo scan
+and tomo queue seems to work now for omny." Also just fixed following his feedback: the progress
+ring GUI only had 2 of flomni's 3 rings, and OMNY never tracked/printed measurement time or idle
+time the way flomni does at the end of a tomogram -- see "Progress ring GUI + ETA/idle-time
+tracking + end-of-scan timing summary" below. Phase 2 (the `TomoParamsWidget` GUI itself) is a
+separate follow-up, not yet started.
**Still open / deferred** (unchanged from before, not touched this pass):
- `x_ray_eye_align.py` is still LamNI-derived, unadapted code beyond the two call-site renames
@@ -150,19 +155,49 @@ Still untested (not attempted yet, not known-broken — just unexplored):
entry and its tomo-type selector/job-display logic is hardcoded to 3 tomo types (flomni's
and lamni's), not OMNY's 5 — needs its own plan once Phase 1 is live-tested.
-## gui_tools.py progress ETA/heartbeat line (still not rendered, data now exists)
+## Progress ring GUI + ETA/idle-time tracking + end-of-scan timing summary (fixed)
-`gui_tools.py` was modernized to flomni's current single-level `.new()` / `_is_deleted()`-guard
-pattern (window lifecycle, xray-eye-align GUI, cameras + hard-stop console, `RingProgressBar`).
-As of the tomo-queue backend port (see below), `self.progress` now DOES carry
-`tomo_start_time`/`heartbeat`/`estimated_remaining_time`/`estimated_finish_time` (global-var-backed,
-same shape as flomni's/lamni's) — what's still not ported is purely a `gui_tools.py` rendering
-detail: `_omnygui_update_progress()` was never updated to display an ETA/heartbeat line the way
-flomni's `_flomnigui_update_progress()`/`_describe_active_hook()` do, and still only builds its
-text from the original 7 fields. Not a crash risk either way — `_omnygui_update_progress()`
-ignores fields it doesn't read, and `omny_webpage_generator.py` (unwired/dead code, see below)
-already reads the ETA-ish fields defensively via `.get()`, degrading to "N/A" when absent. Revisit
-whenever the progress ring GUI itself gets an ETA line, independent of the Phase 2 widget work.
+Mirko live-tested Phase 1 and asked two related questions: why does OMNY's progress ring bar show
+only 2 rings where flomni shows 3, and why does OMNY not print a measurement-time/time-lost summary
+at the end of a tomogram the way flomni does. Both traced back to the same root gap: nothing in
+OMNY ever computed `estimated_remaining_time`/`estimated_finish_time`, and nothing ever refreshed
+`heartbeat` *during* a running scan (only cleared it to `None` on completion, from the Phase-1
+port) -- so `accumulated_idle_time` stayed `0.0` forever and there was no ETA data to show or
+summarize. All direct ports of flomni's already-working mechanism, all sharing the same underlying
+data (`self.progress`, global-var-backed since Phase 1):
+
+- **`_tomo_scan_at_angle()`** now refreshes `progress["heartbeat"]` at the start of every angle,
+ and -- if the gap since the previous heartbeat exceeds a normal-cadence heuristic
+ (`max(60s, 5 * tomo_countingtime)`, flomni's identical formula minus its `frames_per_trigger`
+ factor, which OMNY has no equivalent property for) -- attributes the excess to
+ `accumulated_idle_time` and prints a one-line note. This is what actually makes idle-time
+ tracking (and therefore both features below) possible; it didn't exist in Phase 1 at all.
+- **`_print_progress()`** now computes and stores `estimated_remaining_time`/
+ `estimated_finish_time` from elapsed time minus accumulated idle time (once past projection 9,
+ matching flomni's stabilization threshold), and prints them alongside the existing progress
+ report.
+- **`tomo_scan()`** now prints an end-of-scan summary ("Total measurement time", "... excluding
+ detected gaps", "... lost to detected gaps") after the try/finally, and sends the same content to
+ scilog via `write_to_scilog()` -- direct port of flomni's block, minus the flomni-only
+ `self.measured_log` sample-tracking call (OMNY has no equivalent).
+- **`gui_tools.py`**: `omnygui_show_progress()` now adds a third ring with `set_update("scan")`
+ (auto-updated from the currently running scan, no manual `.set_value()` needed -- matches
+ flomni's `flomnigui_show_progress()` exactly), and `_omnygui_update_progress()`'s center-label
+ text now includes start time / ETA / estimated finish / active-hook lines, matching flomni's
+ `_flomnigui_update_progress()`.
+
+`_format_duration()` (static, `"2h 03m 15s"`-style formatting) added to `omny.py` -- previously
+flomni-only. `_describe_active_hook()`/`_active_hook_source()` needed no porting: already provided
+by `TomoQueueMixin` since Phase 1.
+
+**Not ported**: flomni's separate `_log_tomogram_timing()`/persistent timing-statistics-log
+subsystem (writes a JSON record per tomogram to `~/data/raw/logs/timing_statistics`, backing
+`scilog_last_ptycho_scans()`/a timing overview command) -- Mirko asked specifically about the
+*printed* summary and the ring count, not this larger, separate logging feature. Flag if wanted.
+
+Test coverage: 9 new tests (`test_omny_tomo_scan.py`: idle-time-from-gap, no-idle-time-within-
+cadence, end-of-scan summary lines; `test_omny_gui_tools.py`: three-ring creation, center-label
+text with/without an active hook). Full suite: 731 passed.
## cam_xeye device (simulated only, real hardware config still needed)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
index 037a1eff..4a18db61 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
@@ -205,19 +205,26 @@ class OMNYGuiTools:
if self._omnygui_is_missing("progressbar"):
self.omnygui_remove_all_docks()
self.progressbar = self.gui.omny.new("RingProgressBar")
- # Two manually-updated rings: overall tomogram progress and
- # subtomo progress. Unlike flomni's third, auto-updating
- # "scan"-linked ring, and unlike flomni's ETA/heartbeat/
- # active-hook text, omny's progress dict doesn't carry that
- # data yet (see omny/AI_docs/OPEN_ISSUES.md) -- kept simple
- # here on purpose, revisit once that's ported.
+ # Two manually-updated rings (overall tomogram progress and
+ # subtomo progress) plus a third ring auto-updated from the
+ # currently running scan itself -- matches flomni's
+ # flomnigui_show_progress() exactly.
self.progressbar.add_ring().set_update("manual")
self.progressbar.add_ring().set_update("manual")
+ self.progressbar.add_ring().set_update("scan")
self._omnygui_update_progress()
except Exception as e:
logger.warning(f"Error in omnygui_show_progress: {e}")
def _omnygui_update_progress(self):
+ """Update the progress ring bar and center label from the current progress state.
+
+ ``self.progress`` is backed by the BEC global variable ``tomo_progress``
+ (see :class:`_ProgressProxy` in ``omny.py``), so this method reflects
+ the live state that is also accessible from other BEC client sessions via::
+
+ client.get_global_var("tomo_progress")
+ """
try:
if self.progressbar is None:
logger.warning("OMNY progress GUI update skipped: progressbar is not available.")
@@ -233,6 +240,43 @@ class OMNYGuiTools:
main_progress_ring.set_value(progress)
subtomo_progress_ring.set_value(subtomo_progress)
+ # --- format start time for display --------------------------------
+ start_str = self.progress.get("tomo_start_time")
+ if start_str is not None:
+ import datetime as _dt
+
+ start_display = _dt.datetime.fromisoformat(start_str).strftime("%Y-%m-%d %H:%M:%S")
+ else:
+ start_display = "N/A"
+
+ # --- format estimated remaining time ------------------------------
+ remaining_s = self.progress.get("estimated_remaining_time")
+ if remaining_s is not None and remaining_s >= 0:
+ remaining_s = int(remaining_s)
+ h, rem = divmod(remaining_s, 3600)
+ m, s = divmod(rem, 60)
+ if h > 0:
+ eta_display = f"{h}h {m:02d}m {s:02d}s"
+ elif m > 0:
+ eta_display = f"{m}m {s:02d}s"
+ else:
+ eta_display = f"{s}s"
+ else:
+ eta_display = "N/A"
+ # ------------------------------------------------------------------
+
+ # --- format estimated finish (wall-clock) time --------------------
+ finish_str = self.progress.get("estimated_finish_time")
+ if finish_str is not None:
+ import datetime as _dt
+
+ finish_display = _dt.datetime.fromisoformat(finish_str).strftime(
+ "%Y-%m-%d %H:%M:%S"
+ )
+ else:
+ finish_display = "N/A"
+ # ------------------------------------------------------------------
+
text = (
f"Progress report:\n"
f" Tomo type: {self.progress['tomo_type']}\n"
@@ -241,8 +285,17 @@ class OMNYGuiTools:
f" Angle: {self.progress['angle']:.1f}\n"
f" Current subtomo: {self.progress['subtomo']}\n"
f" Current projection within subtomo: {self.progress['subtomo_projection']}\n"
- f" Total projections per subtomo: {int(self.progress['subtomo_total_projections'])}"
+ f" Total projections per subtomo: {int(self.progress['subtomo_total_projections'])}\n"
+ f" Scan started: {start_display}\n"
+ f" Est. remaining: {eta_display}\n"
+ f" Est. finish: {finish_display}"
)
+ # self._describe_active_hook() comes from TomoQueueMixin (this
+ # is a mixin, always combined with it) -- shown only when a
+ # hook is actually active, same as tomo_parameters()'s CLI note.
+ hook_description = self._describe_active_hook()
+ if hook_description:
+ text += f"\n Hook: {hook_description}"
self.progressbar.set_center_label(text)
except Exception as exc:
logger.warning(f"OMNY progress GUI update skipped: {exc}")
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index 4653fc98..7fdc04ba 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -1230,6 +1230,31 @@ class OMNY(
successful = False
error_caught = False
if 0 <= angle < 180.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. Mirrors
+ # flomni's identical heuristic (minus its frames_per_trigger
+ # factor -- OMNY has no such property). 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)
+ 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 OMNY scan for angle {angle} in subtomo {subtomo_number}")
self._print_progress()
self.omnygui_show_progress()
@@ -1436,6 +1461,47 @@ class OMNY(
# identical fix.
self.progress["heartbeat"] = None
+ self.progress["projection"] = self.progress["total_projections"]
+ self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"]
+ self._print_progress()
+ self.OMNYTools.printgreenbold("Tomoscan finished")
+
+ idle_s = self.progress.get("accumulated_idle_time", 0.0)
+ start_str = self.progress.get("tomo_start_time")
+ elapsed_s = None
+ if start_str is not None:
+ try:
+ elapsed_s = (
+ datetime.datetime.now() - datetime.datetime.fromisoformat(start_str)
+ ).total_seconds()
+ except (ValueError, TypeError):
+ elapsed_s = None
+ timing_lines = [
+ "Tomoscan finished.",
+ f"Measurement ID: {self.tomo_id}",
+ f"Sample: {self.sample_name}",
+ ]
+ hook_description = self._describe_active_hook()
+ if hook_description:
+ timing_lines.append(f"At-each-angle hook: {hook_description}")
+ if elapsed_s is not None:
+ timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}")
+ timing_lines.append(
+ f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}"
+ )
+ timing_lines.append(
+ f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}"
+ )
+ for line in timing_lines[3:]:
+ print(line)
+ timing_content = "\n".join(timing_lines)
+ hook_source = self._active_hook_source()
+ if hook_source:
+ timing_content += (
+ f"\n\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}"
+ )
+ self.write_to_scilog(timing_content, ["tomoscan"])
+
def tomo_scan_resume(self) -> None:
"""Resume a tomo_scan() that crashed or was interrupted, picking up
automatically from wherever ``progress`` last reported -- no need to
@@ -1501,7 +1567,42 @@ class OMNY(
start_angle = float(angles[projection_within_subtomo])
return subtomo_start, start_angle
+ @staticmethod
+ def _format_duration(seconds: float) -> str:
+ """Format a duration in seconds as a human-readable string, e.g. '2h 03m 15s'."""
+ seconds = int(seconds)
+ h, remainder = divmod(seconds, 3600)
+ m, s = divmod(remainder, 60)
+ if h > 0:
+ return f"{h}h {m:02d}m {s:02d}s"
+ if m > 0:
+ return f"{m}m {s:02d}s"
+ return f"{s}s"
+
def _print_progress(self):
+ # --- compute and store estimated remaining time --------------------
+ start_str = self.progress.get("tomo_start_time")
+ projection = self.progress["projection"]
+ total = self.progress["total_projections"]
+ if start_str is not None and total > 0 and projection > 9:
+ now = datetime.datetime.now()
+ elapsed = (now - datetime.datetime.fromisoformat(start_str)).total_seconds()
+ # Exclude detected idle time (beamline-down pauses, a crash +
+ # restart gap, ...) so it doesn't make the scan look slower than
+ # it actually is while it's running.
+ elapsed -= self.progress.get("accumulated_idle_time", 0.0)
+ elapsed = max(elapsed, 1.0) # guard against a degenerate/negative denominator
+ rate = projection / elapsed # projections per second
+ remaining_s = (total - projection) / rate
+ self.progress["estimated_remaining_time"] = remaining_s
+ eta_str = self._format_duration(remaining_s)
+ finish_dt = now + datetime.timedelta(seconds=remaining_s)
+ self.progress["estimated_finish_time"] = finish_dt.isoformat()
+ finish_str = finish_dt.strftime("%Y-%m-%d %H:%M:%S")
+ else:
+ eta_str = "N/A"
+ finish_str = "N/A"
+ # ---------------------------------------------------------------------
print("\x1b[95mProgress report:")
print(f"Tomo type: ....................... {self.progress['tomo_type']}")
print(f"Projection: ...................... {self.progress['projection']:.0f}")
@@ -1509,9 +1610,9 @@ class OMNY(
print(f"Angle: ........................... {self.progress['angle']}")
print(f"Current subtomo: ................. {self.progress['subtomo']}")
print(f"Current projection within subtomo: {self.progress['subtomo_projection']}")
- print(
- f"Total projections per subtomo: ... {self.progress['subtomo_total_projections']}\x1b[0m"
- )
+ print(f"Total projections per subtomo: ... {self.progress['subtomo_total_projections']}")
+ print(f"Estimated remaining time: ........ {eta_str}")
+ print(f"Estimated finish time: ........... {finish_str}\x1b[0m")
def add_sample_database(
self, samplename, date, eaccount, scan_number, setup, sample_additional_info, user
diff --git a/tests/tests_bec_ipython_client/test_omny_gui_tools.py b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
index 5e02edc7..55e54dd5 100644
--- a/tests/tests_bec_ipython_client/test_omny_gui_tools.py
+++ b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
@@ -144,6 +144,78 @@ def test_omnygui_show_omnycam_parking_uses_current_image_widget_api():
assert tools.console is console
+def _fake_progress(**overrides):
+ base = {
+ "tomo_type": "Equally spaced sub-tomograms (2)",
+ "projection": 3,
+ "total_projections": 10,
+ "angle": 12.5,
+ "subtomo": 1,
+ "subtomo_projection": 3,
+ "subtomo_total_projections": 5,
+ "tomo_start_time": None,
+ "estimated_remaining_time": None,
+ "estimated_finish_time": None,
+ }
+ base.update(overrides)
+ return base
+
+
+def test_omnygui_show_progress_creates_three_rings_matching_flomni():
+ """Third ring is auto-updated from the running scan itself
+ (set_update("scan")), matching flomni's flomnigui_show_progress() --
+ omny previously only had the two manually-updated rings."""
+ tools, client = _make_gui_tools()
+ client.gui.windows = {"omny": mock.MagicMock()}
+ progressbar = mock.MagicMock()
+ client.gui.omny.new.return_value = progressbar
+ ring1, ring2, ring3 = mock.MagicMock(), mock.MagicMock(), mock.MagicMock()
+ progressbar.add_ring.side_effect = [ring1, ring2, ring3]
+ progressbar.rings = [ring1, ring2, ring3]
+ tools.progress = _fake_progress()
+ tools._describe_active_hook = lambda: None
+
+ tools.omnygui_show_progress()
+
+ assert progressbar.add_ring.call_count == 3
+ ring1.set_update.assert_called_once_with("manual")
+ ring2.set_update.assert_called_once_with("manual")
+ ring3.set_update.assert_called_once_with("scan")
+
+
+def test_omnygui_update_progress_text_includes_start_eta_finish():
+ tools, _ = _make_gui_tools()
+ tools.progressbar = mock.MagicMock()
+ tools.progressbar.rings = [mock.MagicMock(), mock.MagicMock()]
+ tools.progress = _fake_progress(
+ tomo_start_time="2026-01-01T00:00:00",
+ estimated_remaining_time=125.0,
+ estimated_finish_time="2026-01-01T01:00:00",
+ )
+ tools._describe_active_hook = lambda: None
+
+ tools._omnygui_update_progress()
+
+ text = tools.progressbar.set_center_label.call_args[0][0]
+ assert "Scan started: 2026-01-01 00:00:00" in text
+ assert "Est. remaining: 2m 05s" in text
+ assert "Est. finish: 2026-01-01 01:00:00" in text
+ assert "Hook:" not in text # no active hook
+
+
+def test_omnygui_update_progress_shows_active_hook():
+ tools, _ = _make_gui_tools()
+ tools.progressbar = mock.MagicMock()
+ tools.progressbar.rings = [mock.MagicMock(), mock.MagicMock()]
+ tools.progress = _fake_progress()
+ tools._describe_active_hook = lambda: "my_hook"
+
+ tools._omnygui_update_progress()
+
+ text = tools.progressbar.set_center_label.call_args[0][0]
+ assert "Hook: my_hook" in text
+
+
def test_omnygui_show_omnycam_samplestage_uses_current_image_widget_api():
tools, client = _make_gui_tools()
client.gui.windows = {"omny": mock.MagicMock()}
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
index 2bda299d..92ec7928 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
@@ -15,6 +15,7 @@ exists in OMNY's own add_sample_database()).
"""
import builtins
+import datetime
import types
import pytest
@@ -64,18 +65,27 @@ def make_omny_for_tomo_scan(monkeypatch, tomo_angle_stepsize: float, active_acco
obj._progress_proxy = _ProgressProxy(obj.client)
obj.tomo_type = 1
obj.tomo_angle_stepsize = tomo_angle_stepsize
+ obj.tomo_countingtime = 0.1
+ obj.tomo_id = 0
obj.special_angles = []
obj._current_special_angles = []
+ obj._at_each_angle_hooks = {} # normally set by _init_tomo_queue()
obj.write_pdf_report = lambda: None
obj.sub_tomo_scan = lambda subtomo_number, n_subtomos=2, start_angle=None: None
obj.omnygui_show_progress = lambda: None
- monkeypatch.setitem(
- builtins.__dict__,
- "bec",
- types.SimpleNamespace(
- active_account=active_account, queue=types.SimpleNamespace(next_scan_number=1)
- ),
+ obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
+
+ # write_to_scilog() reads the module-level `bec` (captured at import
+ # time), not builtins["bec"] -- both must point at the same fake so
+ # scilog._enabled resolves to False (no scilog configured) rather than
+ # AttributeError-ing on a stale/missing bec.messaging.
+ fake_bec = types.SimpleNamespace(
+ active_account=active_account,
+ queue=types.SimpleNamespace(next_scan_number=1),
+ messaging=types.SimpleNamespace(scilog=types.SimpleNamespace(_enabled=False)),
)
+ monkeypatch.setitem(builtins.__dict__, "bec", fake_bec)
+ monkeypatch.setattr(omny_module, "bec", fake_bec, raising=False)
monkeypatch.setitem(builtins.__dict__, "scans", _FakeScans())
return obj
@@ -195,6 +205,74 @@ def test_tomo_scan_sets_timing_fields_on_new_scan(monkeypatch):
assert omny.progress["accumulated_idle_time"] == 0.0
+# --- idle-time tracking + end-of-scan measurement-time summary --------------
+#
+# Mirrors flomni's identical _tomo_scan_at_angle() heartbeat-gap heuristic
+# and tomo_scan()'s end-of-scan timing summary -- omny previously tracked
+# neither (heartbeat was only ever cleared to None, never refreshed during
+# a running scan, so accumulated_idle_time stayed 0 forever and no summary
+# was ever printed).
+
+
+def test_tomo_scan_at_angle_tracks_idle_time_from_heartbeat_gap(monkeypatch, capsys):
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ omny.tomo_countingtime = 0.1 # normal_cadence = max(60, 0.5) = 60s
+ old_heartbeat = datetime.datetime.now() - datetime.timedelta(seconds=200)
+ omny.progress["heartbeat"] = old_heartbeat.isoformat()
+ omny.progress["accumulated_idle_time"] = 0.0
+ omny._print_progress = lambda: None
+ omny.special_angles = []
+ omny._current_special_angles = []
+ omny._start_beam_check = lambda: None
+ omny._was_beam_okay = lambda: True
+ omny._at_each_angle = lambda angle: None
+ omny._write_tomo_scan_number = lambda *a: None
+
+ omny._tomo_scan_at_angle(10.0, 1)
+
+ idle = omny.progress["accumulated_idle_time"]
+ assert idle > 100 # ~200s gap - 60s normal cadence
+ assert "Detected a" in capsys.readouterr().out
+ # heartbeat refreshed to "now" so the busy-detector sees a running scan
+ assert omny.progress["heartbeat"] != old_heartbeat.isoformat()
+
+
+def test_tomo_scan_at_angle_no_idle_time_within_normal_cadence(monkeypatch, capsys):
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ omny.tomo_countingtime = 0.1
+ recent_heartbeat = datetime.datetime.now() - datetime.timedelta(seconds=5)
+ omny.progress["heartbeat"] = recent_heartbeat.isoformat()
+ omny.progress["accumulated_idle_time"] = 0.0
+ omny._print_progress = lambda: None
+ omny.special_angles = []
+ omny._current_special_angles = []
+ omny._start_beam_check = lambda: None
+ omny._was_beam_okay = lambda: True
+ omny._at_each_angle = lambda angle: None
+ omny._write_tomo_scan_number = lambda *a: None
+
+ omny._tomo_scan_at_angle(10.0, 1)
+
+ assert omny.progress["accumulated_idle_time"] == 0.0
+ assert "Detected a" not in capsys.readouterr().out
+
+
+def test_tomo_scan_prints_measurement_time_summary(monkeypatch, capsys):
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+
+ def _fake_sub_tomo_scan(subtomo_number, n_subtomos=2, start_angle=None):
+ omny.progress["accumulated_idle_time"] = 10.0
+
+ omny.sub_tomo_scan = _fake_sub_tomo_scan
+
+ omny.tomo_scan()
+
+ out = capsys.readouterr().out
+ assert "Total measurement time:" in out
+ assert "Total measurement time excluding detected gaps:" in out
+ assert "Total measurement time lost to detected gaps:" in out
+
+
# --- write_to_scilog(): checks scilog._enabled before attempting a send -----
#
# The old bec.logbook/client.logbook.send_logbook_message() API had no such
From 68801cd901f633568ffb2a6a6309a50e4c618a95 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 02:52:49 +0200
Subject: [PATCH 031/102] fix(omny): stop re-raising the progress window on
every projection
Mirko, on macOS: the progress window "seems to close and recreate each
update, which is highly annoying" -- caused by _tomo_scan_at_angle()
calling self.omnygui_show_progress() (the full show-window-and-raise-to-
front routine) once per angle, on top of _print_progress(). flomni's own
_tomo_scan_at_angle() never does this: it only ever calls _print_progress(),
which calls self._flomnigui_update_progress() (values/text only) at its own
end -- the window is shown/raised exactly once, by tomo_scan()'s
unconditional call at scan start.
Mirrored that structure: _print_progress() now calls
self._omnygui_update_progress() itself, and the redundant per-angle
omnygui_show_progress() call was removed from _tomo_scan_at_angle().
1 new regression test. Full suite: 732 passed.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 17 +++++++++---
.../bec_ipython_client/plugins/omny/omny.py | 11 +++++++-
.../test_omny_tomo_scan.py | 27 +++++++++++++++++++
3 files changed, 51 insertions(+), 4 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 4e69ef53..d210c3ba 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -190,14 +190,25 @@ data (`self.progress`, global-var-backed since Phase 1):
flomni-only. `_describe_active_hook()`/`_active_hook_source()` needed no porting: already provided
by `TomoQueueMixin` since Phase 1.
+**Follow-up bug found while porting this**: Mirko reported that on macOS, the progress window
+appeared to close and reopen on every single projection during a scan -- "highly annoying".
+`_tomo_scan_at_angle()` was calling `self.omnygui_show_progress()` (the full show-window-and-raise-
+to-front routine) once per angle, not just `_print_progress()`/`_omnygui_update_progress()` (values
+and text only). flomni's own `_tomo_scan_at_angle()` never re-raises the window per angle either --
+it only ever calls `_print_progress()`, which internally calls `_flomnigui_update_progress()` at
+its own end. Fixed by mirroring that structure exactly: `_print_progress()` now calls
+`self._omnygui_update_progress()` itself, and the separate per-angle `omnygui_show_progress()` call
+was removed from `_tomo_scan_at_angle()` -- the window is still shown/raised exactly once, by
+`tomo_scan()`'s own unconditional call at scan start.
+
**Not ported**: flomni's separate `_log_tomogram_timing()`/persistent timing-statistics-log
subsystem (writes a JSON record per tomogram to `~/data/raw/logs/timing_statistics`, backing
`scilog_last_ptycho_scans()`/a timing overview command) -- Mirko asked specifically about the
*printed* summary and the ring count, not this larger, separate logging feature. Flag if wanted.
-Test coverage: 9 new tests (`test_omny_tomo_scan.py`: idle-time-from-gap, no-idle-time-within-
-cadence, end-of-scan summary lines; `test_omny_gui_tools.py`: three-ring creation, center-label
-text with/without an active hook). Full suite: 731 passed.
+Test coverage: 10 new tests (`test_omny_tomo_scan.py`: idle-time-from-gap, no-idle-time-within-
+cadence, end-of-scan summary lines, window not re-raised per angle; `test_omny_gui_tools.py`:
+three-ring creation, center-label text with/without an active hook). Full suite: 732 passed.
## cam_xeye device (simulated only, real hardware config still needed)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index 7fdc04ba..fbc4f402 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -1256,8 +1256,16 @@ class OMNY(
)
self.progress["heartbeat"] = now.isoformat()
print(f"Starting OMNY scan for angle {angle} in subtomo {subtomo_number}")
+ # _print_progress() refreshes the progress GUI itself (via
+ # _omnygui_update_progress()) -- do NOT also call
+ # omnygui_show_progress() here: that re-raises the window to
+ # front on every single projection (confirmed live: on macOS
+ # this looks like the window closing and reopening every
+ # update), not just updates its values/text. tomo_scan()'s own
+ # unconditional call at scan start already shows/raises it
+ # once. Mirrors flomni's _tomo_scan_at_angle(), which likewise
+ # only ever calls _print_progress() per angle.
self._print_progress()
- self.omnygui_show_progress()
while not successful:
self._start_beam_check()
if not self.special_angles:
@@ -1613,6 +1621,7 @@ class OMNY(
print(f"Total projections per subtomo: ... {self.progress['subtomo_total_projections']}")
print(f"Estimated remaining time: ........ {eta_str}")
print(f"Estimated finish time: ........... {finish_str}\x1b[0m")
+ self._omnygui_update_progress()
def add_sample_database(
self, samplename, date, eaccount, scan_number, setup, sample_additional_info, user
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
index 92ec7928..80a11719 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
@@ -214,6 +214,33 @@ def test_tomo_scan_sets_timing_fields_on_new_scan(monkeypatch):
# was ever printed).
+def test_tomo_scan_at_angle_updates_progress_without_reraising_window(monkeypatch):
+ """_tomo_scan_at_angle() must only refresh the progress GUI's values/text
+ (via _print_progress() -> _omnygui_update_progress()), never call
+ omnygui_show_progress() again per angle -- that re-raises the window to
+ front on every single projection (confirmed live: on macOS this looks
+ like the window closing and reopening on every update). tomo_scan()'s
+ own unconditional call at scan start already shows/raises it once,
+ matching flomni's _tomo_scan_at_angle(), which likewise only ever calls
+ _print_progress() per angle."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ show_calls = []
+ update_calls = []
+ omny.omnygui_show_progress = lambda: show_calls.append(1)
+ omny._omnygui_update_progress = lambda: update_calls.append(1)
+ omny.special_angles = []
+ omny._current_special_angles = []
+ omny._start_beam_check = lambda: None
+ omny._was_beam_okay = lambda: True
+ omny._at_each_angle = lambda angle: None
+ omny._write_tomo_scan_number = lambda *a: None
+
+ omny._tomo_scan_at_angle(10.0, 1)
+
+ assert show_calls == []
+ assert update_calls == [1]
+
+
def test_tomo_scan_at_angle_tracks_idle_time_from_heartbeat_gap(monkeypatch, capsys):
omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
omny.tomo_countingtime = 0.1 # normal_cadence = max(60, 0.5) = 60s
From 127b35565be6f68ed5b39a4f34867b989bf397bb Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 03:17:08 +0200
Subject: [PATCH 032/102] feat(omny): add real EPICS owner + measured-status
channel for sample storage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Gives OMNY sample storage a dedicated owner + measured-status channel
(paired with the new SAMPLE_DB IOC record in OMNY/OMNY.git, one per sample
position: VAL=status enum none/started/completed, DESC=owner), replacing
the pack_desc/DESC-packing hack the widget briefly used for OMNY owner
support. FlOMNI is untouched this pass -- deferred.
- OMNYSampleStorage: new sample_status/sample_owners Dcpt/Cpt groups;
set_sample_slot/set_sample_in_gripper/set_sample_in_samplestage take an
owner param and reset status on every place/rename/clear; new
set_sample_status/get_sample_status/get_sample_owner/
get_sample_name_and_owner (+ gripper/stage equivalents); show_all()/
help() updated.
- SimOMNYSampleStorage: seeding clears the new owner/status signals too.
- omny_sample_transfer_mixin.py: otransfer_* wrappers for all the new
methods, existing set_sample_slot/set_sample_in_gripper/
set_sample_in_samplestage wrappers pass owner through.
- sample_storage.py widget: OMNY cells read/write owner+status via the new
signals instead of packing; the "✓ measured"/"◐ started" badge now works
for OMNY too, plus a manual "Set measured status…" context-menu action.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../omny/omny_sample_transfer_mixin.py | 42 ++-
.../widgets/sample_storage/sample_storage.py | 170 ++++++++---
csaxs_bec/devices/omny/omny_sample_storage.py | 270 +++++++++++++++++-
csaxs_bec/devices/sim/sim_omny.py | 32 ++-
4 files changed, 457 insertions(+), 57 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 2c00948f..05ac44fc 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -1077,20 +1077,20 @@ class OMNYSampleTransferMixin:
def otransfer_is_sample_in_gripper(self) -> bool:
return dev.omny_samples.is_sample_in_gripper()
- def otransfer_set_sample_slot(self, container: str, slot_nr: int, name: str) -> bool:
- return dev.omny_samples.set_sample_slot(container, slot_nr, name)
+ def otransfer_set_sample_slot(self, container: str, slot_nr: int, name: str, owner: str = "") -> bool:
+ return dev.omny_samples.set_sample_slot(container, slot_nr, name, owner)
def otransfer_unset_sample_slot(self, container: str, slot_nr: int) -> bool:
return dev.omny_samples.unset_sample_slot(container, slot_nr)
- def otransfer_set_sample_in_gripper(self, name: str) -> bool:
- return dev.omny_samples.set_sample_in_gripper(name)
+ def otransfer_set_sample_in_gripper(self, name: str, owner: str = "") -> bool:
+ return dev.omny_samples.set_sample_in_gripper(name, owner)
def otransfer_unset_sample_in_gripper(self) -> bool:
return dev.omny_samples.unset_sample_in_gripper()
- def otransfer_set_sample_in_samplestage(self, name: str) -> bool:
- return dev.omny_samples.set_sample_in_samplestage(name)
+ def otransfer_set_sample_in_samplestage(self, name: str, owner: str = "") -> bool:
+ return dev.omny_samples.set_sample_in_samplestage(name, owner)
def otransfer_unset_sample_in_samplestage(self) -> bool:
return dev.omny_samples.unset_sample_in_samplestage()
@@ -1122,6 +1122,36 @@ class OMNYSampleTransferMixin:
def otransfer_search_shuttle_in_slot(self, shuttle: str) -> int:
return dev.omny_samples.search_shuttle_in_slot(shuttle)
+ def otransfer_get_sample_owner(self, container: str, slot_nr: int) -> str:
+ return dev.omny_samples.get_sample_owner(container, slot_nr)
+
+ def otransfer_get_sample_name_and_owner(self, container: str, slot_nr: int) -> tuple:
+ return dev.omny_samples.get_sample_name_and_owner(container, slot_nr)
+
+ def otransfer_set_sample_status(self, container: str, slot_nr: int, status: str) -> bool:
+ return dev.omny_samples.set_sample_status(container, slot_nr, status)
+
+ def otransfer_get_sample_status(self, container: str, slot_nr: int) -> str:
+ return dev.omny_samples.get_sample_status(container, slot_nr)
+
+ def otransfer_get_sample_owner_in_gripper(self) -> str:
+ return dev.omny_samples.get_sample_owner_in_gripper()
+
+ def otransfer_get_sample_owner_in_samplestage(self) -> str:
+ return dev.omny_samples.get_sample_owner_in_samplestage()
+
+ def otransfer_set_sample_status_in_gripper(self, status: str) -> bool:
+ return dev.omny_samples.set_sample_status_in_gripper(status)
+
+ def otransfer_get_sample_status_in_gripper(self) -> str:
+ return dev.omny_samples.get_sample_status_in_gripper()
+
+ def otransfer_set_sample_status_in_samplestage(self, status: str) -> bool:
+ return dev.omny_samples.set_sample_status_in_samplestage(status)
+
+ def otransfer_get_sample_status_in_samplestage(self) -> str:
+ return dev.omny_samples.get_sample_status_in_samplestage()
+
# def _otransfer_modify_sample(_ocontainer, _oposition, _ostatus, _oname):
# #_ocontainer is A, B, C for the pin containers
# #it can also be FZP, OSA and omny
diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
index 8b4bc0b1..e6120e13 100644
--- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
+++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
@@ -9,13 +9,13 @@ different setups, detected at construction time (see ``SETUP_PROFILES``,
``_detect_setup()``) — a BEC session is always exactly one setup, never
both at once:
-Both setups require every sample slot to carry a name *and* an owner,
-packed into one EPICS DESC field (see ``sample_desc_codec``) since neither
-backend has a separate owner PV.
+Both setups require every sample slot to carry a name *and* an owner, but
+via different backend mechanisms:
FlOMNI (``dev.flomni_samples``)
A flat magazine: slots 1-20, the sample stage (slot 0) and the gripper
- (slot 100).
+ (slot 100). Name and owner are packed into one EPICS DESC field (see
+ ``sample_desc_codec``) since this backend has no separate owner PV.
OMNY (``dev.omny_samples``)
A richer structure: three shuttles (A/B/C) of 6 slots each, a sparse
@@ -23,9 +23,13 @@ OMNY (``dev.omny_samples``)
separate sample stage and gripper, and a 6-slot "parking" carousel
recording which shuttle letter is docked at each physical parking
position (parking slots are a shuttle-letter assignment, not a sample,
- so they carry no name/owner). Whichever shuttle's parking slot matches
- the ``oparkz`` motor's current readback is "active", i.e. at the
- transfer-ready position (see ``oparkz_active_parking_slot()``).
+ so they carry no name/owner/status). Unlike FlOMNI, OMNY has a
+ dedicated owner channel (and a measured-status channel -- see
+ ``OMNY_SampleStorage.STATUS_NONE``/``STATUS_STARTED``/
+ ``STATUS_COMPLETED``) next to each sample slot's existing placed/name
+ channels, so no packing is needed there. Whichever shuttle's parking
+ slot matches the ``oparkz`` motor's current readback is "active", i.e.
+ at the transfer-ready position (see ``oparkz_active_parking_slot()``).
A LamNI session has neither device; the widget shows a "no known setup
detected" message in that case.
@@ -127,6 +131,14 @@ OMNY_PARKING_SLOTS = tuple(range(1, 7))
# slots (which use EMPTY_NAME = "-"), parking slots are cleared to "none".
PARKING_EMPTY_SENTINEL = "none"
+# Mirrors OMNYSampleStorage.STATUS_NONE/STATUS_STARTED/STATUS_COMPLETED's
+# int<->str mapping, duplicated locally rather than reaching into that
+# class's own (private) _status_str_to_val()/_status_val_to_str()
+# classmethods -- this widget already duplicates other device-side
+# conventions the same way (see EMPTY_NAME/PARKING_EMPTY_SENTINEL above).
+OMNY_STATUS_OPTIONS = ("none", "started", "completed")
+OMNY_STATUS_STR_TO_VAL = {"none": 0, "started": 1, "completed": 2}
+
# colours
# An occupied slot's name label intentionally has no fixed foreground colour
# here (see _SlotCell.set_state()) -- it was previously hardcoded to
@@ -388,10 +400,17 @@ class _SlotCell(QFrame):
menu = QMenu(self)
if self._occupied:
act_rename = menu.addAction("Change name…")
+ # OMNY-only (tuple key): measured status has a real EPICS
+ # channel there (see OMNY_SampleStorage), unlike FlOMNI's
+ # scan-lifecycle-driven redis log, which isn't manually
+ # editable from here.
+ act_status = menu.addAction("Set measured status…") if isinstance(self._slot, tuple) else None
act_clear = menu.addAction("Clear (set empty)")
chosen = menu.exec_(self.mapToGlobal(pos))
if chosen == act_rename:
self._owner.action_change_name(self._slot, self._name, self._sample_owner)
+ elif act_status is not None and chosen == act_status:
+ self._owner.action_set_status(self._slot)
elif chosen == act_clear:
self._owner.action_clear(self._slot)
else:
@@ -620,10 +639,11 @@ class OMNY_SampleStorage(BECWidget, QWidget):
"""Parse a dev.omny_samples.read(cached=True) dict into
{key: (occupied, name, owner, measured_status)} for every
sample-kind slot -- stage, gripper, the 10 fixed O positions, and
- the 18 shuttle slots. Like FlOMNI, OMNY has no separate owner PV, so
- the name+owner pair is packed into the one raw name field (see
- ``sample_desc_codec``); measured_status is always None -- OMNY has
- no measured-sample-log equivalent (see module docstring).
+ the 18 shuttle slots. Owner and measured status each have their own
+ dedicated OMNYSampleStorage signal (added alongside the existing
+ placed/name signals -- see that class's ``STATUS_NONE``/
+ ``STATUS_STARTED``/``STATUS_COMPLETED``), unlike FlOMNI which packs
+ name+owner into one field via ``sample_desc_codec``.
"""
def _val(key, default=None):
@@ -632,32 +652,43 @@ class OMNY_SampleStorage(BECWidget, QWidget):
return entry.get("value", default)
return default
- def _name_owner(raw):
- name, owner = unpack_desc(raw if raw else EMPTY_NAME)
- return (name if name else EMPTY_NAME), owner
+ def _status_str(raw) -> Optional[str]:
+ # mirrors OMNYSampleStorage._status_val_to_str(), without a live
+ # device round-trip: this widget only has the bulk-read dict.
+ try:
+ val = int(round(float(raw)))
+ except (TypeError, ValueError):
+ return None
+ return {1: "started", 2: "completed"}.get(val) # 0 ("none") -> None: no badge
state: dict[tuple, tuple[bool, str, str, Optional[str]]] = {}
used = bool(_val("omny_samples_sample_in_samplestage", 0))
- name, owner = _name_owner(_val("omny_samples_sample_in_samplestage_name", EMPTY_NAME))
- state[("stage",)] = (used, name, owner, None)
+ name = _val("omny_samples_sample_in_samplestage_name", EMPTY_NAME) or EMPTY_NAME
+ owner = _val("omny_samples_sample_in_samplestage_owner", "") or ""
+ status = _status_str(_val("omny_samples_sample_in_samplestage_status", 0))
+ state[("stage",)] = (used, name, owner, status)
used = bool(_val("omny_samples_sample_in_gripper", 0))
- name, owner = _name_owner(_val("omny_samples_sample_in_gripper_name", EMPTY_NAME))
- state[("gripper",)] = (used, name, owner, None)
+ name = _val("omny_samples_sample_in_gripper_name", EMPTY_NAME) or EMPTY_NAME
+ owner = _val("omny_samples_sample_in_gripper_owner", "") or ""
+ status = _status_str(_val("omny_samples_sample_in_gripper_status", 0))
+ state[("gripper",)] = (used, name, owner, status)
for n in OMNY_FIXED_POSITIONS:
used = bool(_val(f"omny_samples_sample_placed_sample{n}", 0))
- name, owner = _name_owner(_val(f"omny_samples_sample_names_sample{n}", EMPTY_NAME))
- state[("O", n)] = (used, name, owner, None)
+ name = _val(f"omny_samples_sample_names_sample{n}", EMPTY_NAME) or EMPTY_NAME
+ owner = _val(f"omny_samples_sample_owners_sample{n}", "") or ""
+ status = _status_str(_val(f"omny_samples_sample_status_sample{n}", 0))
+ state[("O", n)] = (used, name, owner, status)
for letter in OMNY_SHUTTLE_LETTERS:
for n in OMNY_SHUTTLE_SLOTS:
used = bool(_val(f"omny_samples_sample_shuttle_{letter}_placed_sample{n}", 0))
- name, owner = _name_owner(
- _val(f"omny_samples_sample_shuttle_{letter}_names_sample{n}", EMPTY_NAME)
- )
- state[("shuttle", letter, n)] = (used, name, owner, None)
+ name = _val(f"omny_samples_sample_shuttle_{letter}_names_sample{n}", EMPTY_NAME) or EMPTY_NAME
+ owner = _val(f"omny_samples_sample_shuttle_{letter}_owners_sample{n}", "") or ""
+ status = _status_str(_val(f"omny_samples_sample_shuttle_{letter}_status_sample{n}", 0))
+ state[("shuttle", letter, n)] = (used, name, owner, status)
return state
@@ -796,29 +827,41 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def _write_slot_omny(self, key: tuple, used: int, name: str, owner: str = "") -> bool:
"""Write (used, name, owner) to an OMNY sample-kind slot -- stage,
gripper, a fixed O position, or a shuttle slot -- mirroring the
- direct-signal style of ``_write_slot()``, including packing
- name+owner into the one raw name field the same way (see
- ``sample_desc_codec``). No measured-sample-log integration exists
- for OMNY (see module docstring), so unlike ``_write_slot`` this
- never offers to print a P-touch label for the previous occupant.
+ direct-signal style of ``_write_slot()``. Unlike FlOMNI, OMNY has a
+ dedicated owner signal (added alongside placed/name -- see
+ ``OMNYSampleStorage``), so name and owner are written separately,
+ no packing needed. Measured status is reset to "none" on every
+ write here, mirroring ``OMNYSampleStorage.set_sample_slot()``'s/
+ ``unset_sample_slot()``'s own behavior -- a fresh name (even a
+ rename) or a cleared slot isn't mid-measurement. No P-touch
+ label-printing offer for the previous occupant (FlOMNI-only, see
+ module docstring).
"""
try:
- packed_name = pack_desc(name, owner)
+ status_none = self._samples.STATUS_NONE
kind = key[0]
if kind == "stage":
self._samples.sample_in_samplestage.set(used)
- self._samples.sample_in_samplestage_name.set(packed_name)
+ self._samples.sample_in_samplestage_name.set(name)
+ self._samples.sample_in_samplestage_owner.set(owner)
+ self._samples.sample_in_samplestage_status.set(status_none)
elif kind == "gripper":
self._samples.sample_in_gripper.set(used)
- self._samples.sample_in_gripper_name.set(packed_name)
+ self._samples.sample_in_gripper_name.set(name)
+ self._samples.sample_in_gripper_owner.set(owner)
+ self._samples.sample_in_gripper_status.set(status_none)
elif kind == "O":
_, n = key
getattr(self._samples.sample_placed, f"sample{n}").set(used)
- getattr(self._samples.sample_names, f"sample{n}").set(packed_name)
+ getattr(self._samples.sample_names, f"sample{n}").set(name)
+ getattr(self._samples.sample_owners, f"sample{n}").set(owner)
+ getattr(self._samples.sample_status, f"sample{n}").set(status_none)
elif kind == "shuttle":
_, letter, n = key
getattr(getattr(self._samples, f"sample_shuttle_{letter}_placed"), f"sample{n}").set(used)
- getattr(getattr(self._samples, f"sample_shuttle_{letter}_names"), f"sample{n}").set(packed_name)
+ getattr(getattr(self._samples, f"sample_shuttle_{letter}_names"), f"sample{n}").set(name)
+ getattr(getattr(self._samples, f"sample_shuttle_{letter}_owners"), f"sample{n}").set(owner)
+ getattr(getattr(self._samples, f"sample_shuttle_{letter}_status"), f"sample{n}").set(status_none)
else:
raise ValueError(f"unknown OMNY slot key {key!r}")
self._last_state.pop(key, None)
@@ -832,6 +875,39 @@ class OMNY_SampleStorage(BECWidget, QWidget):
)
return False
+ def _write_status_omny(self, key: tuple, status: str) -> bool:
+ """Write just the measured-status signal for an OMNY sample-kind
+ slot, leaving used/name/owner untouched -- the counterpart to
+ ``_write_slot_omny()`` resetting status on every name/owner write,
+ for the manual "Set measured status…" action (see
+ ``action_set_status``).
+ """
+ try:
+ val = OMNY_STATUS_STR_TO_VAL[status]
+ kind = key[0]
+ if kind == "stage":
+ self._samples.sample_in_samplestage_status.set(val)
+ elif kind == "gripper":
+ self._samples.sample_in_gripper_status.set(val)
+ elif kind == "O":
+ _, n = key
+ getattr(self._samples.sample_status, f"sample{n}").set(val)
+ elif kind == "shuttle":
+ _, letter, n = key
+ getattr(getattr(self._samples, f"sample_shuttle_{letter}_status"), f"sample{n}").set(val)
+ else:
+ raise ValueError(f"unknown OMNY slot key {key!r}")
+ self._last_state.pop(key, None)
+ return True
+ except Exception as exc:
+ logger.warning(f"OMNY_SampleStorage: write of status for slot {key} failed: {exc}")
+ QMessageBox.critical(
+ self,
+ "Write failed",
+ f"Could not write status for slot {self._slot_title(key)} to the device:\n{exc}",
+ )
+ return False
+
def _write_parking_omny(self, slot_nr: int, letter: Optional[str]) -> bool:
"""Assign (or clear) which shuttle letter is recorded as parked at
OMNY parking slot `slot_nr` (1-6), mirroring
@@ -1186,6 +1262,32 @@ class OMNY_SampleStorage(BECWidget, QWidget):
if self._write_sample(slot, 0, EMPTY_NAME):
self.refresh()
+ def action_set_status(self, slot: tuple) -> None:
+ """Manually set an OMNY sample-kind slot's measured status. OMNY
+ only (tuple key) -- see ``_SlotCell._show_menu``. FlOMNI's status
+ stays a separate, scan-lifecycle-driven redis log (see module
+ docstring), not exposed for manual editing here.
+ """
+ if not isinstance(slot, tuple):
+ return
+ current = self._last_state.get(slot, (False, "", "", None))[3] or "none"
+ try:
+ current_index = OMNY_STATUS_OPTIONS.index(current)
+ except ValueError:
+ current_index = 0
+ status, ok = QInputDialog.getItem(
+ self,
+ "Set measured status",
+ f"Status for slot {self._slot_title(slot)}:",
+ OMNY_STATUS_OPTIONS,
+ current_index,
+ False,
+ )
+ if not ok:
+ return
+ if self._write_status_omny(slot, status):
+ self.refresh()
+
def action_set_parking(self, letter: str, current_slot: Optional[int]) -> None:
"""Assign OMNY shuttle `letter` to a parking slot (1-6), via
``_ShuttleStatusLabel``'s context menu. Overwrites that slot's
diff --git a/csaxs_bec/devices/omny/omny_sample_storage.py b/csaxs_bec/devices/omny/omny_sample_storage.py
index 4675176f..000cb19a 100644
--- a/csaxs_bec/devices/omny/omny_sample_storage.py
+++ b/csaxs_bec/devices/omny/omny_sample_storage.py
@@ -23,7 +23,17 @@ class OMNYSampleStorage(Device):
"unset_sample_in_samplestage",
"get_sample_name_in_samplestage",
"get_sample_name_in_gripper",
+ "get_sample_owner_in_samplestage",
+ "get_sample_owner_in_gripper",
+ "set_sample_status_in_samplestage",
+ "get_sample_status_in_samplestage",
+ "set_sample_status_in_gripper",
+ "get_sample_status_in_gripper",
"get_sample_name",
+ "get_sample_owner",
+ "get_sample_name_and_owner",
+ "set_sample_status",
+ "get_sample_status",
"is_sample_in_samplestage",
"set_shuttle_slot",
"unset_shuttle_slot",
@@ -36,6 +46,18 @@ class OMNYSampleStorage(Device):
SUB_VALUE = "value"
_default_sub = SUB_VALUE
+ # Measured status, one of these three -- an "ai" record's VAL (see
+ # omny_samples_status.template in the OMNY/OMNY.git IOC repo), not a
+ # proper EPICS enum: this IOC's sample DB uses plain "ai" records
+ # uniformly even for boolean-ish fields (see the placed-flag records),
+ # so the new STATUS record follows the same convention rather than
+ # introducing an mbbo/mbbi record type used nowhere else here. The
+ # int<->str mapping lives entirely in this class.
+ STATUS_NONE = 0
+ STATUS_STARTED = 1
+ STATUS_COMPLETED = 2
+ _STATUS_LABELS = {STATUS_NONE: "none", STATUS_STARTED: "started", STATUS_COMPLETED: "completed"}
+
sample_shuttle_A_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
@@ -97,6 +119,75 @@ class OMNYSampleStorage(Device):
}
sample_names = Dcpt(sample_names)
+ # Measured status (VAL) + owner (DESC) -- one new record per sample
+ # position, added alongside the existing placed/name records (see
+ # STATUS_NONE/STARTED/COMPLETED above and omny_samples_status.template
+ # in OMNY/OMNY.git). Parking slots have no equivalent -- they hold a
+ # shuttle letter, not a sample.
+ sample_shuttle_A_status = {
+ f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}:STATUS", {"auto_monitor": True})
+ for i in range(1, 7)
+ }
+ sample_shuttle_A_status = Dcpt(sample_shuttle_A_status)
+
+ sample_shuttle_B_status = {
+ f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}:STATUS", {"auto_monitor": True})
+ for i in range(1, 7)
+ }
+ sample_shuttle_B_status = Dcpt(sample_shuttle_B_status)
+
+ sample_shuttle_C_status = {
+ f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}:STATUS", {"auto_monitor": True})
+ for i in range(1, 7)
+ }
+ sample_shuttle_C_status = Dcpt(sample_shuttle_C_status)
+
+ sample_status = {
+ f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}:STATUS", {"auto_monitor": True})
+ for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
+ }
+ sample_status = Dcpt(sample_status)
+
+ sample_shuttle_A_owners = {
+ f"sample{i}": (
+ EpicsSignal,
+ f"XOMNY-SAMPLE_DB_shuttle_A:{i}:STATUS.DESC",
+ {"string": True, "auto_monitor": True},
+ )
+ for i in range(1, 7)
+ }
+ sample_shuttle_A_owners = Dcpt(sample_shuttle_A_owners)
+
+ sample_shuttle_B_owners = {
+ f"sample{i}": (
+ EpicsSignal,
+ f"XOMNY-SAMPLE_DB_shuttle_B:{i}:STATUS.DESC",
+ {"string": True, "auto_monitor": True},
+ )
+ for i in range(1, 7)
+ }
+ sample_shuttle_B_owners = Dcpt(sample_shuttle_B_owners)
+
+ sample_shuttle_C_owners = {
+ f"sample{i}": (
+ EpicsSignal,
+ f"XOMNY-SAMPLE_DB_shuttle_C:{i}:STATUS.DESC",
+ {"string": True, "auto_monitor": True},
+ )
+ for i in range(1, 7)
+ }
+ sample_shuttle_C_owners = Dcpt(sample_shuttle_C_owners)
+
+ sample_owners = {
+ f"sample{i}": (
+ EpicsSignal,
+ f"XOMNY-SAMPLE_DB_omny:{i}:STATUS.DESC",
+ {"string": True, "auto_monitor": True},
+ )
+ for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
+ }
+ sample_owners = Dcpt(sample_owners)
+
sample_in_gripper = Cpt(
EpicsSignal, name="sample_in_gripper", read_pv="XOMNY-SAMPLE_DB_omny:110.VAL", auto_monitor=True
)
@@ -119,6 +210,28 @@ class OMNYSampleStorage(Device):
auto_monitor=True
)
+ sample_in_gripper_status = Cpt(
+ EpicsSignal, name="sample_in_gripper_status", read_pv="XOMNY-SAMPLE_DB_omny:110:STATUS", auto_monitor=True
+ )
+ sample_in_gripper_owner = Cpt(
+ EpicsSignal,
+ name="sample_in_gripper_owner",
+ read_pv="XOMNY-SAMPLE_DB_omny:110:STATUS.DESC",
+ string=True,
+ auto_monitor=True,
+ )
+
+ sample_in_samplestage_status = Cpt(
+ EpicsSignal, name="sample_in_samplestage_status", read_pv="XOMNY-SAMPLE_DB_omny:0:STATUS", auto_monitor=True
+ )
+ sample_in_samplestage_owner = Cpt(
+ EpicsSignal,
+ name="sample_in_samplestage_owner",
+ read_pv="XOMNY-SAMPLE_DB_omny:0:STATUS.DESC",
+ string=True,
+ auto_monitor=True,
+ )
+
def __init__(self, prefix="", *, name, **kwargs):
super().__init__(prefix, name=name, **kwargs)
self.sample_shuttle_A_placed.sample1.subscribe(self._emit_value)
@@ -128,41 +241,114 @@ class OMNYSampleStorage(Device):
self.wait_for_connection()
self._run_subs(sub_type=self.SUB_VALUE, timestamp=timestamp, obj=self)
- def set_sample_slot(self, container: str, slot_nr: int, name: str) -> bool:
+ @classmethod
+ def _status_str_to_val(cls, status: str) -> int:
+ status = (status or "none").strip().lower()
+ for val, label in cls._STATUS_LABELS.items():
+ if label == status:
+ return val
+ raise OMNYSampleStorageError(
+ f"Invalid status {status!r}; expected one of {sorted(cls._STATUS_LABELS.values())}."
+ )
+
+ @classmethod
+ def _status_val_to_str(cls, val) -> str:
+ try:
+ return cls._STATUS_LABELS.get(int(round(float(val))), "none")
+ except (TypeError, ValueError):
+ return "none"
+
+ def set_sample_slot(self, container: str, slot_nr: int, name: str, owner: str = "") -> bool:
if slot_nr > 20:
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
+ status = self._status_str_to_val("none")
if container == "A":
getattr(self.sample_shuttle_A_placed, f"sample{slot_nr}").set(1)
getattr(self.sample_shuttle_A_names, f"sample{slot_nr}").set(name)
+ getattr(self.sample_shuttle_A_owners, f"sample{slot_nr}").set(owner)
+ getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").set(status)
elif container == "B":
getattr(self.sample_shuttle_B_placed, f"sample{slot_nr}").set(1)
getattr(self.sample_shuttle_B_names, f"sample{slot_nr}").set(name)
+ getattr(self.sample_shuttle_B_owners, f"sample{slot_nr}").set(owner)
+ getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").set(status)
elif container == "C":
getattr(self.sample_shuttle_C_placed, f"sample{slot_nr}").set(1)
getattr(self.sample_shuttle_C_names, f"sample{slot_nr}").set(name)
+ getattr(self.sample_shuttle_C_owners, f"sample{slot_nr}").set(owner)
+ getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").set(status)
elif container == "O":
getattr(self.sample_placed, f"sample{slot_nr}").set(1)
getattr(self.sample_names, f"sample{slot_nr}").set(name)
-
+ getattr(self.sample_owners, f"sample{slot_nr}").set(owner)
+ getattr(self.sample_status, f"sample{slot_nr}").set(status)
def unset_sample_slot(self, container: str, slot_nr: int) -> bool:
if slot_nr > 20:
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
+ status = self._status_str_to_val("none")
if container == "A":
getattr(self.sample_shuttle_A_placed, f"sample{slot_nr}").set(0)
getattr(self.sample_shuttle_A_names, f"sample{slot_nr}").set("-")
+ getattr(self.sample_shuttle_A_owners, f"sample{slot_nr}").set("")
+ getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").set(status)
elif container == "B":
getattr(self.sample_shuttle_B_placed, f"sample{slot_nr}").set(0)
getattr(self.sample_shuttle_B_names, f"sample{slot_nr}").set("-")
+ getattr(self.sample_shuttle_B_owners, f"sample{slot_nr}").set("")
+ getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").set(status)
elif container == "C":
getattr(self.sample_shuttle_C_placed, f"sample{slot_nr}").set(0)
getattr(self.sample_shuttle_C_names, f"sample{slot_nr}").set("-")
+ getattr(self.sample_shuttle_C_owners, f"sample{slot_nr}").set("")
+ getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").set(status)
elif container == "O":
getattr(self.sample_placed, f"sample{slot_nr}").set(0)
getattr(self.sample_names, f"sample{slot_nr}").set("-")
+ getattr(self.sample_owners, f"sample{slot_nr}").set("")
+ getattr(self.sample_status, f"sample{slot_nr}").set(status)
+ def set_sample_status(self, container: str, slot_nr: int, status: str) -> bool:
+ val = self._status_str_to_val(status)
+ if container == "A":
+ getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").set(val)
+ elif container == "B":
+ getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").set(val)
+ elif container == "C":
+ getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").set(val)
+ elif container == "O":
+ getattr(self.sample_status, f"sample{slot_nr}").set(val)
+
+ def get_sample_status(self, container: str, slot_nr: int) -> str:
+ if container == "A":
+ val = getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").get()
+ elif container == "B":
+ val = getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").get()
+ elif container == "C":
+ val = getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").get()
+ elif container == "O":
+ val = getattr(self.sample_status, f"sample{slot_nr}").get()
+ else:
+ return "none"
+ return self._status_val_to_str(val)
+
+ def get_sample_owner(self, container: str, slot_nr: int) -> str:
+ if container == "A":
+ val = getattr(self.sample_shuttle_A_owners, f"sample{slot_nr}").get()
+ elif container == "B":
+ val = getattr(self.sample_shuttle_B_owners, f"sample{slot_nr}").get()
+ elif container == "C":
+ val = getattr(self.sample_shuttle_C_owners, f"sample{slot_nr}").get()
+ elif container == "O":
+ val = getattr(self.sample_owners, f"sample{slot_nr}").get()
+ else:
+ return ""
+ return str(val) if val else ""
+
+ def get_sample_name_and_owner(self, container: str, slot_nr: int) -> tuple:
+ return self.get_sample_name(container, slot_nr), self.get_sample_owner(container, slot_nr)
def set_shuttle_slot(self, container: str, slot_nr: int) -> bool:
if slot_nr > 6:
@@ -176,21 +362,49 @@ class OMNYSampleStorage(Device):
getattr(self.parking_placed, f"parking{slot_nr}").set(0)
getattr(self.parking_names, f"parking{slot_nr}").set("none")
- def set_sample_in_gripper(self, name: str) -> bool:
+ def set_sample_in_gripper(self, name: str, owner: str = "") -> bool:
self.sample_in_gripper.set(1)
self.sample_in_gripper_name.set(name)
+ self.sample_in_gripper_owner.set(owner)
+ self.sample_in_gripper_status.set(self._status_str_to_val("none"))
def unset_sample_in_gripper(self) -> bool:
self.sample_in_gripper.set(0)
self.sample_in_gripper_name.set("-")
+ self.sample_in_gripper_owner.set("")
+ self.sample_in_gripper_status.set(self._status_str_to_val("none"))
- def set_sample_in_samplestage(self, name: str) -> bool:
+ def set_sample_in_samplestage(self, name: str, owner: str = "") -> bool:
self.sample_in_samplestage.set(1)
self.sample_in_samplestage_name.set(name)
+ self.sample_in_samplestage_owner.set(owner)
+ self.sample_in_samplestage_status.set(self._status_str_to_val("none"))
def unset_sample_in_samplestage(self) -> bool:
self.sample_in_samplestage.set(0)
self.sample_in_samplestage_name.set("-")
+ self.sample_in_samplestage_owner.set("")
+ self.sample_in_samplestage_status.set(self._status_str_to_val("none"))
+
+ def get_sample_owner_in_gripper(self) -> str:
+ val = self.sample_in_gripper_owner.get()
+ return str(val) if val else ""
+
+ def get_sample_owner_in_samplestage(self) -> str:
+ val = self.sample_in_samplestage_owner.get()
+ return str(val) if val else ""
+
+ def set_sample_status_in_gripper(self, status: str) -> bool:
+ self.sample_in_gripper_status.set(self._status_str_to_val(status))
+
+ def get_sample_status_in_gripper(self) -> str:
+ return self._status_val_to_str(self.sample_in_gripper_status.get())
+
+ def set_sample_status_in_samplestage(self, status: str) -> bool:
+ self.sample_in_samplestage_status.set(self._status_str_to_val(status))
+
+ def get_sample_status_in_samplestage(self) -> str:
+ return self._status_val_to_str(self.sample_in_samplestage_status.get())
def is_sample_slot_used(self, container, slot_nr: int) -> bool:
if container == "A":
@@ -247,6 +461,23 @@ class OMNYSampleStorage(Device):
returnvalue = i
return returnvalue
+ def _describe_slot(self, container: str, slot_nr: int) -> str:
+ """Sample name, plus owner/measured-status suffix when occupied --
+ e.g. "sample1 (bob) ◐ started". Used by show_all() so the
+ overview surfaces owner+status without changing get_sample_name()'s
+ own plain-name contract (still used elsewhere, e.g. transfers)."""
+ name = self.get_sample_name(container, slot_nr)
+ if not self.is_sample_slot_used(container, slot_nr):
+ return name
+ owner = self.get_sample_owner(container, slot_nr)
+ status = self.get_sample_status(container, slot_nr)
+ suffix = f" ({owner})" if owner else ""
+ if status == "started":
+ suffix += " ◐ started"
+ elif status == "completed":
+ suffix += " ✓ completed"
+ return name + suffix
+
def show_all(self):
t = PrettyTable()
red = "\x1b[91m"
@@ -262,22 +493,32 @@ class OMNYSampleStorage(Device):
field_names = [""]
for ax in [1, 3, 5]:
row = []
- row.extend([self.get_sample_name(ch, ax)])
+ row.extend([self._describe_slot(ch, ax)])
row.extend(str(ax))
row.extend(str(ax + 1))
- row.extend([self.get_sample_name(ch, ax + 1)])
+ row.extend([self._describe_slot(ch, ax + 1)])
t.add_row(row)
t.header = False
t.vrules = FRAME
print(t)
if self.is_sample_in_samplestage():
- print(f"\n\n Sample stage: {self.get_sample_name_in_samplestage()}")
+ name = self.get_sample_name_in_samplestage()
+ owner = self.get_sample_owner_in_samplestage()
+ status = self.get_sample_status_in_samplestage()
+ suffix = f" ({owner})" if owner else ""
+ if status == "started":
+ suffix += " ◐ started"
+ elif status == "completed":
+ suffix += " ✓ completed"
+ print(f"\n\n Sample stage: {name}{suffix}")
else:
print(f"\n\n Sample stage: no sample")
if self.is_sample_in_gripper():
- print(f"\n Gripper: {self.get_sample_name_in_gripper()}\n")
+ name = self.get_sample_name_in_gripper()
+ owner = self.get_sample_owner_in_gripper()
+ print(f"\n Gripper: {name}" + (f" ({owner})" if owner else "") + "\n")
else:
print(f"\n Gripper: no sample\n")
@@ -287,8 +528,7 @@ class OMNYSampleStorage(Device):
row = []
row.extend([f"Position {i:3d}"])
if self.is_sample_slot_used("O", i):
- name = self.get_sample_name("O", i)
- row.extend([name])
+ row.extend([self._describe_slot("O", i)])
else:
row.extend(["free"])
t.add_row(row)
@@ -301,10 +541,14 @@ class OMNYSampleStorage(Device):
print(" To get an overview use dev.omny_samples.show_all()")
print(" Modify a slot:")
print(" dev.omny_samples.unset_sample_slot('system',position)")
- print(" dev.omny_samples.set_sample_slot('system',position,'name')")
+ print(" dev.omny_samples.set_sample_slot('system',position,'name','owner')")
print(" system can be A, B, C, O")
- print(" dev.omny_samples.set_sample_in_gripper('name') / unset_sample_in_gripper()")
- print(" dev.omny_samples.set_sample_in_samplestage('name'), unset_sample_in_samplestage()")
+ print(" dev.omny_samples.get_sample_owner('system',position)")
+ print(" dev.omny_samples.set_sample_status('system',position,'status')")
+ print(" status is one of 'none', 'started', 'completed'")
+ print(" dev.omny_samples.get_sample_status('system',position)")
+ print(" dev.omny_samples.set_sample_in_gripper('name','owner') / unset_sample_in_gripper()")
+ print(" dev.omny_samples.set_sample_in_samplestage('name','owner'), unset_sample_in_samplestage()")
print(" dev.omny_samples.set_shuttle_slot(container, slot_nr) / unset_shuttle_slot(slot_nr)")
print(" dev.omny_samples.set_shuttle_slot('A',2)")
print(" omny.otransfer_help()")
diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py
index d5e60ead..19a6015e 100644
--- a/csaxs_bec/devices/sim/sim_omny.py
+++ b/csaxs_bec/devices/sim/sim_omny.py
@@ -266,17 +266,41 @@ class SimOMNYSampleStorage(OMNYSampleStorage):
# 0-20-only range check, but OMNY's own "O" fixed positions
# legitimately include 32-34/100/101), which would raise here
# for exactly the positions this needs to clear.
- for shuttle_placed, shuttle_names in (
- (storage.sample_shuttle_A_placed, storage.sample_shuttle_A_names),
- (storage.sample_shuttle_B_placed, storage.sample_shuttle_B_names),
- (storage.sample_shuttle_C_placed, storage.sample_shuttle_C_names),
+ # Same clearing for the owner/status signals added alongside
+ # placed/names -- untouched positions should read owner="" and
+ # status="none", not the mock-PV "0" default (an invalid status
+ # string, would fail _status_val_to_str's lookup silently back
+ # to "none" anyway, but owner would show a stray "0").
+ for shuttle_placed, shuttle_names, shuttle_owners, shuttle_status in (
+ (
+ storage.sample_shuttle_A_placed,
+ storage.sample_shuttle_A_names,
+ storage.sample_shuttle_A_owners,
+ storage.sample_shuttle_A_status,
+ ),
+ (
+ storage.sample_shuttle_B_placed,
+ storage.sample_shuttle_B_names,
+ storage.sample_shuttle_B_owners,
+ storage.sample_shuttle_B_status,
+ ),
+ (
+ storage.sample_shuttle_C_placed,
+ storage.sample_shuttle_C_names,
+ storage.sample_shuttle_C_owners,
+ storage.sample_shuttle_C_status,
+ ),
):
for slot_nr in shuttle_placed.component_names:
getattr(shuttle_placed, slot_nr).set(0)
getattr(shuttle_names, slot_nr).set("-")
+ getattr(shuttle_owners, slot_nr).set("")
+ getattr(shuttle_status, slot_nr).set(OMNYSampleStorage.STATUS_NONE)
for slot_nr in storage.sample_placed.component_names:
getattr(storage.sample_placed, slot_nr).set(0)
getattr(storage.sample_names, slot_nr).set("-")
+ getattr(storage.sample_owners, slot_nr).set("")
+ getattr(storage.sample_status, slot_nr).set(OMNYSampleStorage.STATUS_NONE)
for slot_nr in storage.parking_placed.component_names:
getattr(storage.parking_placed, slot_nr).set(0)
getattr(storage.parking_names, slot_nr).set("none")
From 28eac31ab0a8821bb997129613fa4ab2e369db47 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 03:28:08 +0200
Subject: [PATCH 033/102] feat(omny): add OMNY support to OMNY_TomoParams
widget (Phase 2 of 2)
Mirko, once Phase 1 (tomo-queue backend) was live-confirmed working: "is
the way to move to the widget phase" -- this adds OMNY as a third setup to
the tomo params GUI widget, alongside flomni/lamni.
Key design decision: replaced the module-level TOMO_TYPES dict (shared
across setups, couldn't express "type 1 means 2 sub-tomograms here but 8
there") with a per-profile "tomo_types" dict:
{tomo_type_int: {"label": str, "n_subtomos": int | None}}. n_subtomos is
not None became the single, uniform test for "is this an equally-spaced
type" everywhere (combo box, section visibility, validation, queue-table
projections display), since OMNY has three such flavors (types 1/4/5 --
2/4/8 sub-tomograms) instead of flomni's/lamni's one (always 8).
Critical bug caught by verifying the design against the live file, not by
the initial design itself: _validate() hardcoded
`tomo_type not in (1, 2, 3)` -- would have made the rest of Phase 2 look
like it worked (types 4/5 selectable, sections visible, math correct)
while silently rejecting every actual submit/queue attempt for an OMNY
type-4/5 job. Fixed to check profile membership instead. Also fixed three
UI strings that hardcoded "8 equal sub-tomograms" to read the active
type's real count.
New _compute_type1_omny/_requested_to_stepsize_omny (parameterized by
tomo_type via {1:2,4:4,5:8}, matching
OMNY._equally_spaced_subtomo_angles()'s own mapping exactly) and
_compute_fermat_positions_omny (calls OmnyFermatScan's real static
method, same pattern as flomni's). _format_projections() gained a 3-way
setup duck-type (OMNY's param set is a strict subset of flomni's -- no
positively-unique OMNY-only key exists, so a negative-then-positive check
is necessary) that resolves into SETUP_PROFILES rather than duplicating
the type-count mapping a third time.
Confirmed needing zero changes (verified, not assumed): _job_tooltip(),
_build_type23_section(), compute_fermat_positions call sites,
_detect_setup() (osamroy as OMNY's discriminator), the busy-banner
detection methods (already generic via the shared tomo_progress global
var from Phase 1), and the Qt Designer plugin registration files.
New test_omny_tomo_params_widget_math.py (29 pure-function tests, no
QApplication/QWidget, same style as the existing flomni/lamni test
files). Full suite: 761 passed. Not live-tested (needs a real display,
same as every GUI change this branch) -- next step is Mirko trying it
against a real OMNY session.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 84 ++++-
.../widgets/tomo_params/tomo_params.py | 299 +++++++++++++++---
.../test_omny_tomo_params_widget_math.py | 199 ++++++++++++
3 files changed, 532 insertions(+), 50 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index d210c3ba..086c1ad1 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -37,8 +37,13 @@ TomoQueueMixin's interactive= kwarg", "stale ~/Data10/ paths crashed on a fresh
and tomo queue seems to work now for omny." Also just fixed following his feedback: the progress
ring GUI only had 2 of flomni's 3 rings, and OMNY never tracked/printed measurement time or idle
time the way flomni does at the end of a tomogram -- see "Progress ring GUI + ETA/idle-time
-tracking + end-of-scan timing summary" below. Phase 2 (the `TomoParamsWidget` GUI itself) is a
-separate follow-up, not yet started.
+tracking + end-of-scan timing summary" below.
+
+**Just landed, not yet live-tested**: Phase 2 -- the `OMNY_TomoParams` GUI widget itself now
+supports OMNY as a third setup (5 tomo types, not just flomni's/lamni's 3). See "TomoParamsWidget
+ported to OMNY (Phase 2 of 2)" below. Mirko: "is the way to move to the widget phase" -- this is
+that. No live GUI verification yet (needs a real display, same as every other GUI change this
+branch) -- next step is Mirko trying it against a real OMNY session.
**Still open / deferred** (unchanged from before, not touched this pass):
- `x_ray_eye_align.py` is still LamNI-derived, unadapted code beyond the two call-site renames
@@ -47,7 +52,6 @@ separate follow-up, not yet started.
- The full gripper sample-transfer chain (`otransfer_put_sample()`/`otransfer_get_sample()`),
dewar/temperature methods, sample storage bookkeeping, and `omny_alignment_mixin.py`/
`omny_optics_mixin.py` beyond what scans exercise — see "Checklist" section below.
-- The `TomoParamsWidget`/`OMNY_TomoParams` GUI widget itself (Phase 2 of the tomo-queue port).
- `omny_alignment_mixin.py`/`x_ray_eye_align.py` still reference the old `~/Data10/specES1/internal/`
path for alignment-offset data (unlike the tomo-scan-path fixes below, these already tolerate a
missing path gracefully — confirmed live, no crash, just "No data available" -- so left alone for
@@ -145,15 +149,14 @@ Still untested (not attempted yet, not known-broken — just unexplored):
- `omny_webpage_generator.py` (2957 lines) is dead/unwired code — never instantiated by
`omny.py`, per `FLOMNI_TO_LAMNI_COMPARISON.md`'s note ("omny was left untouched...
separate future task if omny ever needs a real one"). Not touched this session either.
-- **Tomo-queue backend: ported, Phase 1 of 2 (this session).** `OMNY` now inherits
+- **Tomo-queue backend + widget: both ported (Phase 1 and 2, this branch).** `OMNY` inherits
`TomoQueueMixin` (`OMNY_shared/tomo_queue_mixin.py`), the same shared backend flomni/lamni
use — `tomo_queue_add/delete/move/clear/show/execute/reacquire`,
- `register_at_each_angle_hook`/`at_each_angle_hook`, `tomo_scan_resume()`. See "Tomo-queue
- backend ported to OMNY" below for the full detail. **Not yet done (Phase 2, separate
- follow-up)**: the `TomoParamsWidget`/`OMNY_TomoParams` GUI widget itself
- (`bec_widgets/widgets/tomo_params/tomo_params.py`) still has no `SETUP_PROFILES["omny"]`
- entry and its tomo-type selector/job-display logic is hardcoded to 3 tomo types (flomni's
- and lamni's), not OMNY's 5 — needs its own plan once Phase 1 is live-tested.
+ `register_at_each_angle_hook`/`at_each_angle_hook`, `tomo_scan_resume()`. The
+ `OMNY_TomoParams` GUI widget (`bec_widgets/widgets/tomo_params/tomo_params.py`) now has a
+ `SETUP_PROFILES["omny"]` entry and its tomo-type selector/job-display logic supports OMNY's 5
+ tomo types (flomni/lamni only ever had 3). See "Tomo-queue backend ported to OMNY" and
+ "TomoParamsWidget ported to OMNY (Phase 2 of 2)" below for the full detail.
## Progress ring GUI + ETA/idle-time tracking + end-of-scan timing summary (fixed)
@@ -479,6 +482,67 @@ never drift out of sync. 13 new tests across `test_omny_fermat_scan.py` (scan-le
`prepare_scan()` still aborts) and `test_omny_tomo_angles.py` (CLI-level: warning appears/doesn't
appear).
+## TomoParamsWidget ported to OMNY (Phase 2 of 2)
+
+Mirko, once Phase 1 was live-confirmed working: "is the way to move to the widget phase" -- this
+is Phase 2, adding OMNY as a third setup to the `OMNY_TomoParams` GUI widget
+(`bec_widgets/widgets/tomo_params/tomo_params.py`). The widget was already almost entirely
+`SETUP_PROFILES`-driven (two research passes plus a verification pass against the live 2476-line
+file found essentially everything generalizes mechanically), with one real complication: flomni
+and lamni each have exactly one "equally spaced sub-tomograms" tomo type (type 1, always 8
+sub-tomograms); OMNY has three (types 1/4/5 -- 2/4/8 sub-tomograms).
+
+**Key design decision**: replaced the module-level `TOMO_TYPES` dict (shared across all setups,
+couldn't express "type 1 means 2 sub-tomograms here but 8 there") with a per-profile
+`"tomo_types"` dict: `{tomo_type_int: {"label": str, "n_subtomos": int | None}}`.
+`n_subtomos is not None` became the single, uniform test for "is this an equally-spaced type"
+everywhere in the file (combo box, section visibility, validation, queue-table projections
+display) instead of duplicating an `{1,4,5}`-shaped literal two or three times.
+
+**Critical bug caught by a verification pass, not the initial design**: `_validate()` had
+`if params.get("tomo_type") not in (1, 2, 3): return "tomo_type must be 1, 2, or 3"` -- a
+hardcoded closed-enumeration check that the rest of Phase 2 would have sailed past (types 4/5
+selectable, sections visible, math correct) while **silently rejecting every actual submit/queue
+attempt** for an OMNY type-4/5 job. Fixed to check `self._profile["tomo_types"]` membership
+instead. Also caught: three UI strings hardcoded "8" ("must be divisible into 8 equal
+sub-tomograms" in the projection-preview tooltip and both confirmation dialogs) -- all three now
+read the active type's real `n_subtomos`.
+
+**New module-level OMNY math functions** (`_compute_type1_omny`/`_requested_to_stepsize_omny`),
+mirroring `_compute_type1`/`_requested_to_stepsize` but parameterized by `tomo_type` (using
+`{1:2, 4:4, 5:8}`, matching `OMNY._equally_spaced_subtomo_angles()`'s own mapping in omny.py
+exactly) instead of a hardcoded `*8`. `_compute_fermat_positions_omny` calls
+`OmnyFermatScan.get_omny_fermat_spiral_pos()`/`MIN_POSITIONS` directly, same pattern as flomni's.
+`_format_projections()` (module-level, only has a job's raw `params` dict, not `self._profile`)
+gained a 3-way setup duck-type: `"tomo_angle_range" in params` -> flomni; else `"fovx" in params`
+-> omny (OMNY's param set is a strict subset of flomni's -- no positively-unique OMNY-only key
+exists, so this negative-then-positive check is necessary, not a shortcut); else -> lamni. Once
+resolved, it looks up `SETUP_PROFILES[setup]["tomo_types"]` rather than hand-picking a compute
+function, avoiding a third copy of the type-count mapping.
+
+**Confirmed needing zero changes** (verified against the live file, not assumed): `_job_tooltip()`/
+`_irrelevant_params_for_type()` (OMNY's params never overlap the flomni-only keys they filter on,
+so the existing `!= 1` check has no observable effect for OMNY regardless); `_build_type23_section()`
+(golden-ratio-only, already type-count-agnostic); `compute_fermat_positions` call sites (fermat math
+never depends on tomo_type); `_detect_setup()` (purely `discriminator_attr`-driven, `"osamroy"` for
+OMNY); the busy-banner detection methods (already read the shared `tomo_progress` global var/
+scan-queue-status generically, Phase 1 already made OMNY use the same key); the Qt Designer plugin
+registration files (already fully generic, self-detect setup at construction).
+
+Test coverage: new `test_omny_tomo_params_widget_math.py` (29 tests, pure-function -- no
+`QApplication`/`QWidget`, same style as the existing flomni/lamni widget-math test files),
+covering the new compute functions against the reference formula, `tomo_types` n_subtomos mapping,
+`param_names`/`defaults` parity with `OMNY._TOMO_SCAN_PARAM_NAMES`, `_format_projections()`'s 3-way
+dispatch (including a regression guard that it still correctly distinguishes flomni/lamni jobs),
+`_compute_fermat_positions_omny` against the real scan class, and a standalone regression guard for
+the `_validate()` fix. Full suite: 761 passed.
+
+**Not live-tested**: needs a real display (same as every GUI change this branch) -- next step is
+Mirko trying it against a real OMNY session: switching between all 5 tomo types (confirm the
+equally-spaced section shows/hides correctly and the new sub-tomogram-count label updates),
+submitting/queueing a type-4 or type-5 job (confirms the `_validate()` fix specifically), and
+checking a queued job's tooltip/Projections column in the queue dialog.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
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 0c6341c6..bab239fd 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -79,12 +79,6 @@ logger = bec_logger.logger
# ── constants ────────────────────────────────────────────────────────────────
-TOMO_TYPES = {
- 1: "8 equally spaced sub-tomograms",
- 2: "Golden ratio (sorted in bunches)",
- 3: "Equally spaced, golden ratio start",
-}
-
# Mirrors _update_type_visibility()'s section/field gating (_sec_type1 shown
# only for type 1, golden_ratio_bunch_size shown only for type 2 within
# _sec_type23, the rest of _sec_type23 shown for type 2/3) -- reused by
@@ -228,6 +222,48 @@ LAMNI_DEFAULTS: dict[str, Any] = {
"at_each_angle_hook": None,
}
+# Exact tuple from OMNY._TOMO_SCAN_PARAM_NAMES (source of truth in omny.py).
+# No tomo_angle_range/single_point_*/zero_deg_reference_at_each_subtomo/
+# frames_per_trigger -- none of these exist as OMNY properties.
+OMNY_QUEUE_PARAM_NAMES = (
+ "tomo_countingtime",
+ "tomo_shellstep",
+ "fovx",
+ "fovy",
+ "stitch_x",
+ "stitch_y",
+ "tomo_stitch_overlap",
+ "ptycho_reconstruct_foldername",
+ "manual_shift_y",
+ "tomo_type",
+ "tomo_angle_stepsize",
+ "golden_ratio_bunch_size",
+ "golden_max_number_of_projections",
+ "golden_projections_at_0_deg_for_damage_estimation",
+ "corridor_size",
+ "at_each_angle_hook",
+)
+
+# Read directly off each OMNY property getter's None-fallback in omny.py.
+OMNY_DEFAULTS: dict[str, Any] = {
+ "tomo_countingtime": 0.1,
+ "tomo_shellstep": 1,
+ "fovx": 20.0,
+ "fovy": 20.0,
+ "stitch_x": 0,
+ "stitch_y": 0,
+ "tomo_stitch_overlap": 0.2,
+ "ptycho_reconstruct_foldername": "ptycho_reconstruct",
+ "manual_shift_y": 0.0,
+ "tomo_type": 1,
+ "tomo_angle_stepsize": 10.0,
+ "golden_ratio_bunch_size": 20,
+ "golden_max_number_of_projections": 1000.0,
+ "golden_projections_at_0_deg_for_damage_estimation": 0,
+ "corridor_size": -1,
+ "at_each_angle_hook": None,
+}
+
def _hline() -> QFrame:
line = QFrame()
@@ -438,8 +474,8 @@ class OMNY_TomoParams(BECWidget, QWidget):
type_row = QHBoxLayout()
type_row.addWidget(QLabel("Tomo type:"))
self._pw["tomo_type"] = QComboBox()
- for tid, label in TOMO_TYPES.items():
- self._pw["tomo_type"].addItem(f"{tid} – {label}", tid)
+ for tid, info in self._profile["tomo_types"].items():
+ self._pw["tomo_type"].addItem(f"{tid} – {info['label']}", tid)
self._pw["tomo_type"].currentIndexChanged.connect(self._on_type_changed)
type_row.addWidget(self._pw["tomo_type"], stretch=1)
vbox.addLayout(type_row)
@@ -599,7 +635,11 @@ class OMNY_TomoParams(BECWidget, QWidget):
form.addRow("At-each-angle hook:", self._pw["at_each_angle_hook"])
def _build_type1_section(self) -> QGroupBox:
- box = QGroupBox("Type 1 – sub-tomogram settings")
+ # Generic title, not "Type 1" -- this section is shared by every
+ # "equally spaced sub-tomograms" type_flavor a profile defines (see
+ # SETUP_PROFILES[...]["tomo_types"]), which for OMNY is types 1/4/5
+ # (2/4/8 sub-tomograms), not just type 1 like flomni/lamni.
+ box = QGroupBox("Equally spaced sub-tomograms – settings")
form = QFormLayout(box)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
@@ -614,6 +654,12 @@ class OMNY_TomoParams(BECWidget, QWidget):
)
form.addRow("Angle range:", self._pw["tomo_angle_range"])
+ # read-only: how many sub-tomograms the currently selected type uses
+ # (2/4/8 for OMNY's types 1/4/5, always 8 for flomni/lamni's type 1)
+ # -- updated from _update_type_visibility() whenever the type changes.
+ self._lbl_subtomo_count = QLabel("---")
+ form.addRow("Sub-tomograms:", self._lbl_subtomo_count)
+
# requested total projections (user entry in edit mode)
self._pw["_requested_total"] = QSpinBox()
self._pw["_requested_total"].setRange(8, 10000)
@@ -752,7 +798,7 @@ class OMNY_TomoParams(BECWidget, QWidget):
# derive requested_total from stored tomo_angle_stepsize
angle_range = int(params.get("tomo_angle_range", 180))
stepsize = float(params.get("tomo_angle_stepsize", 10.0))
- actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize)
+ actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize, type_val)
self._pw["_requested_total"].blockSignals(True)
self._pw["_requested_total"].setValue(actual_total)
self._pw["_requested_total"].blockSignals(False)
@@ -820,7 +866,9 @@ class OMNY_TomoParams(BECWidget, QWidget):
# derive tomo_angle_stepsize from requested_total + angle_range (type 1)
angle_range = int(params.get("tomo_angle_range", 180))
requested = self._pw["_requested_total"].value()
- stepsize = self._profile["requested_to_stepsize"](angle_range, requested)
+ stepsize = self._profile["requested_to_stepsize"](
+ angle_range, requested, params.get("tomo_type")
+ )
params["tomo_angle_stepsize"] = stepsize
# type 3: override tomo_angle_stepsize from projections-per-subtomo
@@ -849,8 +897,8 @@ class OMNY_TomoParams(BECWidget, QWidget):
fpt = params.get("frames_per_trigger", 1)
if not isinstance(fpt, int) or fpt < 1 or isinstance(fpt, bool):
return "frames_per_trigger must be a positive integer"
- if params.get("tomo_type") not in (1, 2, 3):
- return "tomo_type must be 1, 2, or 3"
+ if params.get("tomo_type") not in self._profile["tomo_types"]:
+ return f"tomo_type must be one of {sorted(self._profile['tomo_types'])}"
if self._profile["has_180_mode"] and params.get("tomo_angle_range") not in (180, 360):
return "tomo_angle_range must be 180 or 360"
if self._profile["has_single_point"]:
@@ -869,7 +917,17 @@ class OMNY_TomoParams(BECWidget, QWidget):
# ── type visibility ───────────────────────────────────────────────────────
def _update_type_visibility(self, tomo_type: int) -> None:
- self._sec_type1.setVisible(tomo_type == 1)
+ # "Equally spaced sub-tomograms" section: shown for whichever
+ # tomo_type(s) this profile marks as such (flomni/lamni: type 1
+ # only; OMNY: types 1/4/5 -- 2/4/8 sub-tomograms) rather than a bare
+ # `== 1` check, which would blank the panel entirely for OMNY's
+ # types 4/5.
+ type_info = self._profile["tomo_types"].get(tomo_type, {})
+ n_subtomos = type_info.get("n_subtomos")
+ self._sec_type1.setVisible(n_subtomos is not None)
+ if n_subtomos is not None:
+ self._lbl_subtomo_count.setText(str(n_subtomos))
+ self._pw["_requested_total"].setSingleStep(n_subtomos)
self._sec_type23.setVisible(tomo_type in (2, 3))
# _numprj_type3: only visible for type 3
_w__numprj_type3 = self._pw.get("_numprj_type3")
@@ -905,18 +963,22 @@ class OMNY_TomoParams(BECWidget, QWidget):
# entirely, so any fallback here is a no-op, not a guess.
angle_range_widget = self._pw.get("tomo_angle_range")
angle_range = int(angle_range_widget.currentData() or 180) if angle_range_widget else 180
+ tomo_type = self._pw["tomo_type"].currentData()
requested = self._pw["_requested_total"].value()
- stepsize = self._profile["requested_to_stepsize"](angle_range, requested)
- actual_total, achievable_step, _ = self._profile["compute_type1"](angle_range, stepsize)
+ stepsize = self._profile["requested_to_stepsize"](angle_range, requested, tomo_type)
+ actual_total, achievable_step, _ = self._profile["compute_type1"](
+ angle_range, stepsize, tomo_type
+ )
self._lbl_actual_total.setText(str(actual_total))
self._lbl_achievable_step.setText(f"{achievable_step:.4f}")
if actual_total != requested:
+ n_subtomos = self._profile["tomo_types"].get(tomo_type, {}).get("n_subtomos")
self._lbl_actual_total.setStyleSheet("color: orange;")
self._lbl_actual_total.setToolTip(
f"Requested {requested} → actual {actual_total} "
- f"(must be divisible into 8 equal sub-tomograms)"
+ f"(must be divisible into {n_subtomos} equal sub-tomograms)"
)
else:
self._lbl_actual_total.setStyleSheet("")
@@ -1178,18 +1240,22 @@ class OMNY_TomoParams(BECWidget, QWidget):
QMessageBox.warning(self, "Validation error", error)
return
- # Confirm if actual_total differs from requested (type-1)
- if params.get("tomo_type") == 1:
+ # Confirm if actual_total differs from requested (equally-spaced types)
+ type_info = self._profile["tomo_types"].get(params.get("tomo_type"), {})
+ n_subtomos = type_info.get("n_subtomos")
+ if n_subtomos is not None:
angle_range = int(params.get("tomo_angle_range", 180))
requested = self._pw["_requested_total"].value()
stepsize = params["tomo_angle_stepsize"]
- actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize)
+ actual_total, _, _ = self._profile["compute_type1"](
+ angle_range, stepsize, params.get("tomo_type")
+ )
if actual_total != requested:
reply = QMessageBox.question(
self,
"Projection count adjusted",
f"Requested {requested} projections → achievable: {actual_total}.\n"
- f"(Must be divisible into 8 equal sub-tomograms.)\n\n"
+ f"(Must be divisible into {n_subtomos} equal sub-tomograms.)\n\n"
f"Submit with {actual_total} projections?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
)
@@ -1235,17 +1301,21 @@ class OMNY_TomoParams(BECWidget, QWidget):
QMessageBox.warning(self, "Validation error", error)
return
- if params.get("tomo_type") == 1:
+ type_info = self._profile["tomo_types"].get(params.get("tomo_type"), {})
+ n_subtomos = type_info.get("n_subtomos")
+ if n_subtomos is not None:
angle_range = int(params.get("tomo_angle_range", 180))
requested = self._pw["_requested_total"].value()
stepsize = params["tomo_angle_stepsize"]
- actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize)
+ actual_total, _, _ = self._profile["compute_type1"](
+ angle_range, stepsize, params.get("tomo_type")
+ )
if actual_total != requested:
reply = QMessageBox.question(
self,
"Projection count adjusted",
f"Requested {requested} projections → achievable: {actual_total}.\n"
- f"(Must be divisible into 8 equal sub-tomograms.)\n\n"
+ f"(Must be divisible into {n_subtomos} equal sub-tomograms.)\n\n"
f"Queue with {actual_total} projections?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
)
@@ -2089,7 +2159,7 @@ class CommandJobBuilderDialog(QDialog):
# ── module-level helpers ─────────────────────────────────────────────────────
-def _requested_to_stepsize(angle_range: int, requested_total: int) -> float:
+def _requested_to_stepsize(angle_range: int, requested_total: int, tomo_type: int = 1) -> float:
"""Convert a desired total projection count to tomo_angle_stepsize.
angle_range is accepted for call-site compatibility but NOT used here:
@@ -2098,19 +2168,25 @@ def _requested_to_stepsize(angle_range: int, requested_total: int) -> float:
sub-tomograms into low/high halves in 360 mode -- it doesn't change
the total projection count or per-subtomo step). Mirrors
Flomni._subtomo_angle_plan()'s docstring / _tomo_type1_actual_grid().
+
+ tomo_type is accepted for call-site uniformity with the OMNY sibling
+ (_requested_to_stepsize_omny(), which has more than one equally-spaced
+ flavor and needs it) -- unused here, flomni has only one.
"""
if requested_total <= 0:
return 180.0
return (180.0 / requested_total) * 8
-def _compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float]:
+def _compute_type1(
+ angle_range: int, stepsize: float, tomo_type: int = 1
+) -> tuple[int, float, float]:
"""
Given stored tomo_angle_stepsize, return:
(actual_total, achievable_step, stepsize)
Mirrors the CLI's internal computation exactly -- see
- _requested_to_stepsize()'s docstring for why angle_range is accepted
- but not used in the N/step/total math.
+ _requested_to_stepsize()'s docstring for why angle_range/tomo_type are
+ accepted but not used in the N/step/total math.
"""
if stepsize <= 0:
return 0, 0.0, 0.0
@@ -2122,7 +2198,9 @@ def _compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float
return actual_total, achievable_step, stepsize
-def _lamni_requested_to_stepsize(angle_range: int, requested_total: int) -> float:
+def _lamni_requested_to_stepsize(
+ angle_range: int, requested_total: int, tomo_type: int = 1
+) -> float:
"""Lamni sibling of _requested_to_stepsize(): inverse of
_lamni_compute_type1()'s N*8 grid -- mirrors
LamNI._tomo_type1_actual_grid()'s inverse (lamni.py) exactly.
@@ -2130,13 +2208,16 @@ def _lamni_requested_to_stepsize(angle_range: int, requested_total: int) -> floa
angle_range is accepted but ignored, for call-site symmetry with the
flomni functions above -- lamni is laminography: always 360 degrees, no
tomo_angle_range concept at all (see LamNI._TOMO_SCAN_PARAM_NAMES).
+ tomo_type likewise accepted but unused -- see _requested_to_stepsize().
"""
if requested_total <= 0:
return 360.0
return (360.0 / requested_total) * 8
-def _lamni_compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float]:
+def _lamni_compute_type1(
+ angle_range: int, stepsize: float, tomo_type: int = 1
+) -> tuple[int, float, float]:
"""Lamni sibling of _compute_type1(): mirrors
LamNI._tomo_type1_actual_grid() (lamni.py) exactly --
N = int(360 / stepsize) -- unique angles per sub-tomogram
@@ -2146,7 +2227,7 @@ def _lamni_compute_type1(angle_range: int, stepsize: float) -> tuple[int, float,
A full 360-degree sweep's closing angle is the same physical angle as
its start, so N (not N+1) is the real per-sub-tomogram count --
sub_tomo_scan() generates it with endpoint=False for exactly this
- reason. angle_range is accepted but ignored (see
+ reason. angle_range/tomo_type are accepted but ignored (see
_lamni_requested_to_stepsize()).
"""
if stepsize <= 0:
@@ -2159,6 +2240,46 @@ def _lamni_compute_type1(angle_range: int, stepsize: float) -> tuple[int, float,
return actual_total, achievable_step, stepsize
+# OMNY has three "equally spaced sub-tomograms" flavors (tomo_type 1/4/5 --
+# 2/4/8 sub-tomograms), unlike flomni's/lamni's single always-8 flavor -- see
+# OMNY._TOMO_TYPE_1_LIKE / OMNY._equally_spaced_subtomo_angles() (omny.py).
+_OMNY_N_SUBTOMOS_FOR_TYPE = {1: 2, 4: 4, 5: 8}
+
+
+def _requested_to_stepsize_omny(
+ angle_range: int, requested_total: int, tomo_type: int = 1
+) -> float:
+ """OMNY sibling of _requested_to_stepsize(): same N/180-degree math, but
+ with a variable sub-tomogram multiplier depending on which of tomo_type
+ 1/4/5 is active, instead of flomni's/lamni's fixed 8. angle_range is
+ accepted but ignored (OMNY has no 180/360 toggle, always 180).
+ """
+ n_subtomos = _OMNY_N_SUBTOMOS_FOR_TYPE.get(tomo_type, 2)
+ if requested_total <= 0:
+ return 180.0
+ return (180.0 / requested_total) * n_subtomos
+
+
+def _compute_type1_omny(
+ angle_range: int, stepsize: float, tomo_type: int = 1
+) -> tuple[int, float, float]:
+ """OMNY sibling of _compute_type1(): mirrors
+ OMNY._equally_spaced_subtomo_angles()'s own grid (omny.py) exactly --
+ N = int(180 / stepsize)
+ achievable_step = 180 / N
+ actual_total = N * n_subtomos(tomo_type)
+ """
+ if stepsize <= 0:
+ return 0, 0.0, 0.0
+ N = int(180.0 / stepsize)
+ if N <= 0:
+ return 0, 0.0, 0.0
+ n_subtomos = _OMNY_N_SUBTOMOS_FOR_TYPE.get(tomo_type, 2)
+ achievable_step = 180.0 / N
+ actual_total = N * n_subtomos
+ return actual_total, achievable_step, stepsize
+
+
def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]:
"""Estimated Fermat-spiral point count for the currently edited flomni
fields, plus the scan-server's own minimum threshold.
@@ -2189,6 +2310,30 @@ def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]:
return len(positions), FlomniFermatScan.MIN_POSITIONS
+def _compute_fermat_positions_omny(params: dict[str, Any]) -> tuple[int, int]:
+ """OMNY sibling of _compute_fermat_positions_flomni(): calls
+ OmnyFermatScan.get_omny_fermat_spiral_pos() directly -- the exact same
+ algorithm the scan itself runs (and the same one OMNY.tomo_parameters()'s
+ CLI warning uses via OMNY._expected_fermat_position_count()), so the
+ estimate can never drift out of sync. cenx/ceny/zshift left at their
+ defaults -- they only shift the final position, never affect the count.
+
+ Returns:
+ (estimated_count, minimum_required)
+ """
+ from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan
+
+ fovx = params.get("fovx", 0.0)
+ fovy = params.get("fovy", 0.0)
+ step = params.get("tomo_shellstep", 0.0)
+ if step <= 0:
+ return 0, OmnyFermatScan.MIN_POSITIONS
+ positions = OmnyFermatScan.get_omny_fermat_spiral_pos(
+ -abs(fovx / 2), abs(fovx / 2), -abs(fovy / 2), abs(fovy / 2), step=step, spiral_type=0
+ )
+ return len(positions), OmnyFermatScan.MIN_POSITIONS
+
+
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
@@ -2296,6 +2441,11 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"fermat_position_fields": ["tomo_shellstep", "fovx", "fovy"],
"sample_name_getter": lambda w: w.dev.flomni_samples.sample_names.sample0.get(),
"cli_hint_name": "flomni",
+ "tomo_types": {
+ 1: {"label": "8 equally spaced sub-tomograms", "n_subtomos": 8},
+ 2: {"label": "Golden ratio (sorted in bunches)", "n_subtomos": None},
+ 3: {"label": "Equally spaced, golden ratio start", "n_subtomos": None},
+ },
},
"lamni": {
"label": "LamNI",
@@ -2356,6 +2506,59 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
],
"sample_name_getter": lambda w: w.client.get_global_var("sample_name"),
"cli_hint_name": "lamni",
+ "tomo_types": {
+ 1: {"label": "8 equally spaced sub-tomograms", "n_subtomos": 8},
+ 2: {"label": "Golden ratio (sorted in bunches)", "n_subtomos": None},
+ 3: {"label": "Equally spaced, golden ratio start", "n_subtomos": None},
+ },
+ },
+ "omny": {
+ "label": "OMNY",
+ "discriminator_attr": "osamroy",
+ "param_names": OMNY_QUEUE_PARAM_NAMES,
+ "defaults": OMNY_DEFAULTS,
+ "has_180_mode": False,
+ "has_single_point": False,
+ "has_zero_deg_reference": False,
+ "fov_fields": [
+ ("fovx", "FOV x (µm)", 0.1, 200.0, 2),
+ ("fovy", "FOV y (µm)", 0.1, 100.0, 2),
+ ], # bounds copied from flomni's -- must match OMNY.fovx/fovy setters' ValueError bounds
+ "stitch_fields": [("stitch_x", "Stitch x", 0, 50), ("stitch_y", "Stitch y", 0, 50)],
+ "manual_shift_fields": [("manual_shift_y", "Manual shift y (µm)", -1000.0, 1000.0, 3)],
+ "piezo_range_fields": [],
+ "offset_fields": [],
+ "offset_getter": None,
+ "offset_setter": None,
+ # flomni's own field_order minus frames_per_trigger/single_point --
+ # OMNY has neither property.
+ "field_order": [
+ "tomo_countingtime",
+ "tomo_shellstep",
+ "fov",
+ "stitch",
+ "tomo_stitch_overlap",
+ "ptycho_reconstruct_foldername",
+ "manual_shift",
+ "at_each_angle_hook",
+ ],
+ "type1_base_angle": 180.0,
+ "compute_type1": _compute_type1_omny,
+ "requested_to_stepsize": _requested_to_stepsize_omny,
+ "compute_fermat_positions": _compute_fermat_positions_omny,
+ "fermat_position_fields": ["tomo_shellstep", "fovx", "fovy"],
+ "sample_name_getter": lambda w: w.dev.omny_samples.get_sample_name_in_samplestage(),
+ "cli_hint_name": "omny",
+ # OMNY has three "equally spaced sub-tomograms" flavors (2/4/8
+ # sub-tomograms), unlike flomni's/lamni's single always-8 flavor --
+ # see OMNY._TOMO_TYPE_1_LIKE / _equally_spaced_subtomo_angles() (omny.py).
+ "tomo_types": {
+ 1: {"label": "2 equally spaced sub-tomograms", "n_subtomos": 2},
+ 2: {"label": "Golden ratio (sorted in bunches)", "n_subtomos": None},
+ 3: {"label": "Equally spaced, golden ratio start", "n_subtomos": None},
+ 4: {"label": "4 equally spaced sub-tomograms", "n_subtomos": 4},
+ 5: {"label": "8 equally spaced sub-tomograms", "n_subtomos": 8},
+ },
},
}
@@ -2364,25 +2567,41 @@ def _format_projections(params: dict) -> str:
"""
Human-readable projection count for a queued job, mirroring the CLI's
per-type logic. A job's own ``params`` dict is that setup's own param
- snapshot, so ``"tomo_angle_range" in params`` distinguishes a flomni job
- (fixed-180 base, mode-independent) from a lamni job (plain 360 base,
- see ``_lamni_compute_type1``/``_lamni_requested_to_stepsize``) without
- this module-level helper needing to know which setup is active:
- - type 1: flomni int(180/stepsize)*8, lamni int(360/stepsize)*8
+ snapshot, so this module-level helper duck-types which setup it belongs
+ to without needing to know which one is currently active:
+ - ``"tomo_angle_range" in params`` -> flomni (fixed-180 base,
+ mode-independent).
+ - else ``"fovx" in params`` -> omny. OMNY's param set is a strict
+ subset of flomni's (no positively-unique OMNY-only key exists), so
+ this negative-then-positive check is the correct 3-way
+ discriminator, not just a shortcut.
+ - else -> lamni (plain 360 base, ``tomo_circfov`` instead of
+ ``fovx``).
+ Once the setup is known, ``SETUP_PROFILES[setup]["tomo_types"]`` is the
+ single source of truth for which tomo_type values are "equally spaced
+ sub-tomograms" (and how many) -- avoids a second/third hardcoded
+ ``{1, 4, 5}``-shaped literal here.
+ - equally-spaced types: flomni int(180/stepsize)*8, lamni
+ int(360/stepsize)*8, omny int(180/stepsize)*{2,4,8} depending on
+ tomo_type.
- type 2 and 3: golden ratio-flavored, no fixed sub-tomogram count ->
configured max (golden_max_number_of_projections), or ∞ if unset.
Type 3 shifts sub-tomogram starting angles by the golden ratio but
- is otherwise capped exactly like type 2, not type 1's fixed grid.
+ is otherwise capped exactly like type 2, not the equally-spaced
+ types' fixed grid.
"""
try:
tomo_type = int(params.get("tomo_type", 1))
stepsize = float(params.get("tomo_angle_stepsize", 0) or 0)
is_flomni_job = "tomo_angle_range" in params
- compute_type1 = _compute_type1 if is_flomni_job else _lamni_compute_type1
+ is_omny_job = not is_flomni_job and "fovx" in params
+ setup = "flomni" if is_flomni_job else ("omny" if is_omny_job else "lamni")
+ profile = SETUP_PROFILES[setup]
+ type_info = profile["tomo_types"].get(tomo_type, {})
- if tomo_type == 1:
+ if type_info.get("n_subtomos") is not None:
angle_range = int(params.get("tomo_angle_range", 180))
- actual_total, _, _ = compute_type1(angle_range, stepsize)
+ actual_total, _, _ = profile["compute_type1"](angle_range, stepsize, tomo_type)
return str(actual_total)
if tomo_type in (2, 3):
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
new file mode 100644
index 00000000..6146c670
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
@@ -0,0 +1,199 @@
+"""Regression test for TomoParamsWidget's omny-specific projection-count
+math (_compute_type1_omny/_requested_to_stepsize_omny), mirroring
+test_lamni_tomo_params_widget_math.py's style.
+
+OMNY's formula is NOT the same as flomni's/lamni's fixed-8-sub-tomogram
+grid: OMNY has three "equally spaced sub-tomograms" flavors (tomo_type
+1/4/5 -- 2/4/8 sub-tomograms), matching OMNY._equally_spaced_subtomo_angles()
+(omny.py) exactly: N = int(180/stepsize) unique angles per sub-tomogram,
+N*n_subtomos total, where n_subtomos = {1: 2, 4: 4, 5: 8}[tomo_type].
+"""
+
+import pytest
+
+from csaxs_bec.bec_ipython_client.plugins.omny.omny import OMNY
+from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
+ OMNY_DEFAULTS,
+ OMNY_QUEUE_PARAM_NAMES,
+ SETUP_PROFILES,
+ _compute_fermat_positions_omny,
+ _compute_type1,
+ _compute_type1_omny,
+ _format_projections,
+ _lamni_compute_type1,
+ _requested_to_stepsize,
+ _requested_to_stepsize_omny,
+)
+from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan
+
+STEPSIZES = [10.0, 7.0, 25.0, 12.5]
+OMNY_TYPES_N_SUBTOMOS = {1: 2, 4: 4, 5: 8}
+
+
+@pytest.mark.parametrize("stepsize", STEPSIZES)
+@pytest.mark.parametrize("tomo_type", [1, 4, 5])
+def test_omny_compute_type1_matches_equally_spaced_subtomo_angles(tomo_type, stepsize):
+ """Reference formula mirrors OMNY._equally_spaced_subtomo_angles()'s own
+ grid: N = int(180/stepsize) unique angles per sub-tomogram, N*n_subtomos
+ total."""
+ N = int(180.0 / stepsize)
+ n_subtomos = OMNY_TYPES_N_SUBTOMOS[tomo_type]
+ expected_total = N * n_subtomos
+ expected_step = 180.0 / N
+
+ actual_total, achievable_step, _ = _compute_type1_omny(180, stepsize, tomo_type)
+ assert actual_total == expected_total
+ assert achievable_step == pytest.approx(expected_step)
+
+
+@pytest.mark.parametrize("tomo_type,requested_total", [(1, 18), (4, 36), (5, 72)])
+def test_omny_requested_to_stepsize_round_trips(tomo_type, requested_total):
+ """requested_total must already be a multiple of n_subtomos for the
+ round trip to land exactly back on it -- these were chosen as N=9."""
+ stepsize = _requested_to_stepsize_omny(180, requested_total, tomo_type)
+ actual_total, _, _ = _compute_type1_omny(180, stepsize, tomo_type)
+ assert actual_total == requested_total
+
+
+def test_omny_formula_differs_from_flomni_and_lamni_formula():
+ """Cheap insurance against accidentally aliasing the wrong function into
+ a SETUP_PROFILES entry -- omny's math must not silently match flomni's
+ or lamni's."""
+ for stepsize in STEPSIZES:
+ flomni_total, flomni_step, _ = _compute_type1(180, stepsize)
+ lamni_total, lamni_step, _ = _lamni_compute_type1(360, stepsize)
+ omny_total, omny_step, _ = _compute_type1_omny(180, stepsize, 1)
+ assert (flomni_total, flomni_step) != (omny_total, omny_step)
+ assert (lamni_total, lamni_step) != (omny_total, omny_step)
+
+ for requested in (16, 32, 48, 64):
+ assert _requested_to_stepsize(180, requested) != _requested_to_stepsize_omny(
+ 180, requested, 1
+ )
+
+
+def test_omny_tomo_types_have_correct_n_subtomos():
+ tomo_types = SETUP_PROFILES["omny"]["tomo_types"]
+ assert tomo_types[1]["n_subtomos"] == 2
+ assert tomo_types[4]["n_subtomos"] == 4
+ assert tomo_types[5]["n_subtomos"] == 8
+ assert tomo_types[2]["n_subtomos"] is None
+ assert tomo_types[3]["n_subtomos"] is None
+
+
+def test_setup_profiles_param_names_match_cli_class():
+ """Guards against the known mirror-drift risk: SETUP_PROFILES'
+ param_names must stay in sync with OMNY's own _TOMO_SCAN_PARAM_NAMES
+ (there is no shared import between bec_widgets and the ipython-client
+ plugins, so nothing else catches this)."""
+ assert set(SETUP_PROFILES["omny"]["param_names"]) == set(OMNY._TOMO_SCAN_PARAM_NAMES)
+ assert set(OMNY_QUEUE_PARAM_NAMES) == set(OMNY._TOMO_SCAN_PARAM_NAMES)
+
+
+def test_setup_profiles_omny_defaults_cover_all_param_names():
+ missing = set(OMNY_QUEUE_PARAM_NAMES) - set(OMNY_DEFAULTS)
+ assert not missing, f"omny profile is missing defaults for: {missing}"
+
+
+def test_omny_offset_fields_empty():
+ """OMNY has no alignment-offset concept in this widget (unlike lamni's
+ tomo_fovx_offset/tomo_fovy_offset) -- confirm the profile reflects that
+ rather than silently inheriting lamni's fields."""
+ assert SETUP_PROFILES["omny"]["offset_fields"] == []
+ assert SETUP_PROFILES["omny"]["offset_getter"] is None
+ assert SETUP_PROFILES["omny"]["offset_setter"] is None
+
+
+def test_omny_field_order_matches_flomni_minus_omny_only_gaps():
+ """flomni's own field_order minus frames_per_trigger/single_point --
+ OMNY has neither property."""
+ assert SETUP_PROFILES["omny"]["field_order"] == [
+ "tomo_countingtime",
+ "tomo_shellstep",
+ "fov",
+ "stitch",
+ "tomo_stitch_overlap",
+ "ptycho_reconstruct_foldername",
+ "manual_shift",
+ "at_each_angle_hook",
+ ]
+
+
+def test_omny_has_no_180_mode_single_point_or_zero_deg_reference():
+ profile = SETUP_PROFILES["omny"]
+ assert profile["has_180_mode"] is False
+ assert profile["has_single_point"] is False
+ assert profile["has_zero_deg_reference"] is False
+
+
+def test_compute_fermat_positions_omny_matches_scan_class_below_threshold():
+ params = {"fovx": 2.0, "fovy": 2.0, "tomo_shellstep": 2.0}
+ count, min_positions = _compute_fermat_positions_omny(params)
+ assert min_positions == OmnyFermatScan.MIN_POSITIONS
+ assert count < min_positions
+ expected = OmnyFermatScan.get_omny_fermat_spiral_pos(
+ -1.0, 1.0, -1.0, 1.0, step=2.0, spiral_type=0
+ )
+ assert count == len(expected)
+
+
+def test_compute_fermat_positions_omny_matches_scan_class_above_threshold():
+ params = {"fovx": 20.0, "fovy": 20.0, "tomo_shellstep": 1.0}
+ count, min_positions = _compute_fermat_positions_omny(params)
+ assert count >= min_positions
+
+
+def test_compute_fermat_positions_omny_zero_step_is_safe():
+ count, min_positions = _compute_fermat_positions_omny(
+ {"fovx": 5.0, "fovy": 5.0, "tomo_shellstep": 0.0}
+ )
+ assert count == 0
+ assert min_positions == OmnyFermatScan.MIN_POSITIONS
+
+
+# --- _format_projections(): 3-way setup dispatch, including OMNY types 4/5 --
+
+
+def test_format_projections_dispatches_omny_job_types_1_4_5():
+ for tomo_type, n_subtomos in OMNY_TYPES_N_SUBTOMOS.items():
+ params = {"tomo_type": tomo_type, "tomo_angle_stepsize": 10.0, "fovx": 20.0, "fovy": 20.0}
+ N = int(180.0 / 10.0)
+ assert _format_projections(params) == str(N * n_subtomos)
+
+
+def test_format_projections_omny_golden_types_unaffected():
+ params = {"tomo_type": 2, "fovx": 20.0, "golden_max_number_of_projections": 500}
+ assert _format_projections(params) == "500"
+
+ params_infinite = {"tomo_type": 2, "fovx": 20.0, "golden_max_number_of_projections": 0}
+ assert _format_projections(params_infinite) == "∞"
+
+
+def test_format_projections_still_distinguishes_flomni_and_lamni_jobs():
+ """Regression guard: adding the omny branch must not break the existing
+ flomni/lamni duck-typing."""
+ flomni_params = {"tomo_type": 1, "tomo_angle_range": 180, "tomo_angle_stepsize": 10.0}
+ lamni_params = {"tomo_type": 1, "tomo_angle_stepsize": 10.0} # no tomo_angle_range, no fovx
+
+ flomni_N = int(180.0 / 10.0)
+ assert _format_projections(flomni_params) == str(flomni_N * 8)
+
+ lamni_N = int(360.0 / 10.0)
+ assert _format_projections(lamni_params) == str(lamni_N * 8)
+
+
+# --- _validate()'s tomo_type check: regression guard for the critical fix --
+
+
+def test_validate_tomo_type_condition_accepts_omny_types_4_and_5():
+ """_validate() used to hardcode `tomo_type not in (1, 2, 3)`, which would
+ silently reject every OMNY type-4/5 submission even though the rest of
+ the UI (combo box, section visibility) would appear to work. Tested here
+ as a standalone expression against the profile data (mirroring
+ _validate()'s post-fix condition) rather than requiring a live QWidget
+ instance."""
+ tomo_types = SETUP_PROFILES["omny"]["tomo_types"]
+ for tomo_type in (1, 2, 3, 4, 5):
+ assert tomo_type in tomo_types, f"tomo_type {tomo_type} should be valid for omny"
+ for invalid in (0, 6, 99, None):
+ assert invalid not in tomo_types, f"tomo_type {invalid} should be rejected for omny"
From 6691f51bd2a6ccb4f0bf92a50e00a05c9f3f64b5 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 11:05:18 +0200
Subject: [PATCH 034/102] feat(omny): save reference images at sample-transfer
confirm checkpoints
Mirrors flomni's ftransfer_confirm()/save_reference_image() pattern, adapted
for OMNY's dual-camera-pair setup: whichever camera pair is currently shown
live (cam200+cam203 for parking/shuttle, cam201+cam202 for the sample
stage -- see _omnycam_parking()/_omnycam_samplestage() in gui_tools.py) gets
saved together into one HDF5 file at each manual confirm checkpoint in
_otransfer_confirm(), via the new _otransfer_save_reference_images().
X-ray eye alignment image/fit saving is left untouched -- it already exists
as shared code (x_ray_eye_align.py) but has known pre-existing OMNY/LamNI
naming bugs unrelated to this change.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../plugins/omny/gui_tools.py | 7 +++
.../omny/omny_sample_transfer_mixin.py | 48 +++++++++++++++++--
2 files changed, 52 insertions(+), 3 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
index 4a18db61..ffc7d1a1 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
@@ -96,6 +96,12 @@ class OMNYGuiTools:
def _omnycam_parking(self):
self.omnygui_show_omnycam_parking()
+ # Recorded so omny_sample_transfer_mixin.py's _otransfer_confirm()
+ # knows which camera pair to save a reference image from -- OMNY
+ # always shows exactly one pair (parking/shuttle or sample stage)
+ # depending on where the gripper currently is, unlike flomni's
+ # fixed single gripper camera.
+ self._omnycam_active_pair = ("cam200", "cam203")
def omnygui_show_omnycam_parking(self):
self.omnygui_show_gui()
@@ -136,6 +142,7 @@ class OMNYGuiTools:
def _omnycam_samplestage(self):
self.omnygui_show_omnycam_samplestage()
+ self._omnycam_active_pair = ("cam201", "cam202")
def omnygui_show_omnycam_samplestage(self):
self.omnygui_show_gui()
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 05ac44fc..8375e6b5 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -1,6 +1,8 @@
+import os
import time
import builtins
+import h5py
import numpy as np
from rich import box
@@ -740,6 +742,7 @@ class OMNYSampleTransferMixin:
return bool(float(dev.otransy.controller.socket_put_and_receive("MGgrstat")))
def otransfer_put_sample(self, pin_position: int):
+ self.transfer_step = 0
self._otransfer_check_sensor_connected()
if not self._otransfer_check_free_slot_available_at_position(pin_position, 0):
raise OMNYTransferError(f"position {pin_position} is not free")
@@ -760,7 +763,7 @@ class OMNYSampleTransferMixin:
print("The mount process started.")
- self._otransfer_confirm()
+ self._otransfer_confirm(step_name="put")
self._otransfer_controller_disable_mount_mode()
# update the new sample status in storage
self._otransfer_check_free_slot_available_at_position(pin_position, 1)
@@ -789,7 +792,7 @@ class OMNYSampleTransferMixin:
self._otransfer_shield_close(pin_position)
dev.omny_samples.show_all()
- def _otransfer_confirm(self):
+ def _otransfer_confirm(self, step_name: str = ""):
mntprgs = 1
while mntprgs != 0:
@@ -820,10 +823,48 @@ class OMNYSampleTransferMixin:
text = "All OK? Please confirm"
if self.OMNYTools.yesno(text, "y"):
+ self.transfer_step += 1
+ self._otransfer_save_reference_images(file_suffix=f"{step_name}_{self.transfer_step}")
dev.otransy.controller.socket_put_confirmed("confirm=1")
else:
raise OMNYTransferError("The sample mount progress was aborted by user.")
+ def _otransfer_save_reference_images(self, file_suffix: str = "") -> None:
+ """Save the currently-active camera pair's last images to one HDF5
+ file -- OMNY's equivalent of flomni's ``save_reference_image()``,
+ called at each manual confirm checkpoint (see ``_otransfer_confirm``).
+
+ Unlike flomni (one fixed camera, ``cam_flomni_gripper``), OMNY
+ always shows exactly one *pair* of cameras live at a time --
+ parking/shuttle (cam200+cam203) or sample stage (cam201+cam202) --
+ depending on where the gripper currently is (see
+ ``_omnycam_parking()``/``_omnycam_samplestage()`` in gui_tools.py,
+ which record that pair on ``self._omnycam_active_pair``). Both
+ images from the active pair go into one file, keyed by camera name,
+ rather than flomni's single "reference_image" dataset.
+
+ IDSCamera's own ``get_last_image()`` is used directly (same as
+ ``x_ray_eye_align.py``'s ``update_frame()`` already does for
+ cam_xeye), rather than flomni's BEC-preview-redis-endpoint pull --
+ cam200-203 are IDSCamera, not the WebcamViewer class flomni's
+ cameras use.
+ """
+ pair = getattr(self, "_omnycam_active_pair", None)
+ if not pair:
+ print("No active OMNY camera pair known; skipping reference-image save.")
+ return
+ suffix = f"_{file_suffix}_" if file_suffix else "_"
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
+ file = os.path.expanduser(
+ f"~/data/raw/logs/omny_sample_transfer_images/omny_sample_transfer_reference_image{suffix}{timestamp}.h5"
+ )
+ os.makedirs(os.path.dirname(file), exist_ok=True)
+ with h5py.File(file, "w") as f:
+ for cam_name in pair:
+ image = getattr(dev, cam_name).get_last_image()
+ if image is not None:
+ f.create_dataset(cam_name, data=image)
+
def _otransfer_check_sensor_connected(self):
sensorvoltage = float(dev.otransy.controller.socket_put_and_receive("MG@AN[8001]"))
gripper_sensorvoltagetarget = self._get_user_param_safe(
@@ -835,6 +876,7 @@ class OMNYSampleTransferMixin:
)
def otransfer_get_sample(self, pin_position: int):
+ self.transfer_step = 0
self._omnycam_parking()
if not self.OMNYTools.yesno(
"Please confirm that currently there is no sample in the gripper. It would be dropped!",
@@ -875,7 +917,7 @@ class OMNYSampleTransferMixin:
print("The Unmount process started.")
time.sleep(1)
- self._otransfer_confirm()
+ self._otransfer_confirm(step_name="get")
self._otransfer_controller_disable_mount_mode()
# update database
From 50d27585e5d867ac1bc23fa83bbe903242379c60 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 14:53:13 +0200
Subject: [PATCH 035/102] fix(omny): remove widget status-setting; fix inverted
loadlock confirm
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- sample_storage.py: remove the "Set measured status…" context-menu action
(and its now-unused write helper/constants) -- measured status is CLI-only
(omny.otransfer_set_sample_status()/dev.omny_samples.set_sample_status()),
not manually editable from the widget.
- omny_sample_transfer_mixin.py: otransfer_park_loadlock_slot()'s confirm
check was inverted -- it raised on "y" (in back position) and proceeded
on "n", exactly backwards. Pre-existing bug, found during sim testing.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../omny/omny_sample_transfer_mixin.py | 2 +-
.../widgets/sample_storage/sample_storage.py | 74 -------------------
2 files changed, 1 insertion(+), 75 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 8375e6b5..a82fc592 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -474,7 +474,7 @@ class OMNYSampleTransferMixin:
)
# if the valve of the loadlock is not closed, ask before movement
# todo if(epics_get("XOMNY-ES1-LL1VG1:POSITION")!="CLOSED") {
- if self.OMNYTools.yesno("Please confirm that the leica transfer is in back position."):
+ if not self.OMNYTools.yesno("Please confirm that the leica transfer is in back position."):
raise OMNYTransferError("Leica shuttle is not in back position.")
else:
self._oparkz_leica_slot(slot_number)
diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
index e6120e13..e83b9b51 100644
--- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
+++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
@@ -131,14 +131,6 @@ OMNY_PARKING_SLOTS = tuple(range(1, 7))
# slots (which use EMPTY_NAME = "-"), parking slots are cleared to "none".
PARKING_EMPTY_SENTINEL = "none"
-# Mirrors OMNYSampleStorage.STATUS_NONE/STATUS_STARTED/STATUS_COMPLETED's
-# int<->str mapping, duplicated locally rather than reaching into that
-# class's own (private) _status_str_to_val()/_status_val_to_str()
-# classmethods -- this widget already duplicates other device-side
-# conventions the same way (see EMPTY_NAME/PARKING_EMPTY_SENTINEL above).
-OMNY_STATUS_OPTIONS = ("none", "started", "completed")
-OMNY_STATUS_STR_TO_VAL = {"none": 0, "started": 1, "completed": 2}
-
# colours
# An occupied slot's name label intentionally has no fixed foreground colour
# here (see _SlotCell.set_state()) -- it was previously hardcoded to
@@ -400,17 +392,10 @@ class _SlotCell(QFrame):
menu = QMenu(self)
if self._occupied:
act_rename = menu.addAction("Change name…")
- # OMNY-only (tuple key): measured status has a real EPICS
- # channel there (see OMNY_SampleStorage), unlike FlOMNI's
- # scan-lifecycle-driven redis log, which isn't manually
- # editable from here.
- act_status = menu.addAction("Set measured status…") if isinstance(self._slot, tuple) else None
act_clear = menu.addAction("Clear (set empty)")
chosen = menu.exec_(self.mapToGlobal(pos))
if chosen == act_rename:
self._owner.action_change_name(self._slot, self._name, self._sample_owner)
- elif act_status is not None and chosen == act_status:
- self._owner.action_set_status(self._slot)
elif chosen == act_clear:
self._owner.action_clear(self._slot)
else:
@@ -875,39 +860,6 @@ class OMNY_SampleStorage(BECWidget, QWidget):
)
return False
- def _write_status_omny(self, key: tuple, status: str) -> bool:
- """Write just the measured-status signal for an OMNY sample-kind
- slot, leaving used/name/owner untouched -- the counterpart to
- ``_write_slot_omny()`` resetting status on every name/owner write,
- for the manual "Set measured status…" action (see
- ``action_set_status``).
- """
- try:
- val = OMNY_STATUS_STR_TO_VAL[status]
- kind = key[0]
- if kind == "stage":
- self._samples.sample_in_samplestage_status.set(val)
- elif kind == "gripper":
- self._samples.sample_in_gripper_status.set(val)
- elif kind == "O":
- _, n = key
- getattr(self._samples.sample_status, f"sample{n}").set(val)
- elif kind == "shuttle":
- _, letter, n = key
- getattr(getattr(self._samples, f"sample_shuttle_{letter}_status"), f"sample{n}").set(val)
- else:
- raise ValueError(f"unknown OMNY slot key {key!r}")
- self._last_state.pop(key, None)
- return True
- except Exception as exc:
- logger.warning(f"OMNY_SampleStorage: write of status for slot {key} failed: {exc}")
- QMessageBox.critical(
- self,
- "Write failed",
- f"Could not write status for slot {self._slot_title(key)} to the device:\n{exc}",
- )
- return False
-
def _write_parking_omny(self, slot_nr: int, letter: Optional[str]) -> bool:
"""Assign (or clear) which shuttle letter is recorded as parked at
OMNY parking slot `slot_nr` (1-6), mirroring
@@ -1262,32 +1214,6 @@ class OMNY_SampleStorage(BECWidget, QWidget):
if self._write_sample(slot, 0, EMPTY_NAME):
self.refresh()
- def action_set_status(self, slot: tuple) -> None:
- """Manually set an OMNY sample-kind slot's measured status. OMNY
- only (tuple key) -- see ``_SlotCell._show_menu``. FlOMNI's status
- stays a separate, scan-lifecycle-driven redis log (see module
- docstring), not exposed for manual editing here.
- """
- if not isinstance(slot, tuple):
- return
- current = self._last_state.get(slot, (False, "", "", None))[3] or "none"
- try:
- current_index = OMNY_STATUS_OPTIONS.index(current)
- except ValueError:
- current_index = 0
- status, ok = QInputDialog.getItem(
- self,
- "Set measured status",
- f"Status for slot {self._slot_title(slot)}:",
- OMNY_STATUS_OPTIONS,
- current_index,
- False,
- )
- if not ok:
- return
- if self._write_status_omny(slot, status):
- self.refresh()
-
def action_set_parking(self, letter: str, current_slot: Optional[int]) -> None:
"""Assign OMNY shuttle `letter` to a parking slot (1-6), via
``_ShuttleStatusLabel``'s context menu. Overwrites that slot's
From 55f4638c78c1a5dc1f1ad955b3c8e0e1304caa63 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 15:03:23 +0200
Subject: [PATCH 036/102] fix(omny): widget STATUS_NONE client-proxy crash;
otransfer_help() coverage
- sample_storage.py: _write_slot_omny() read self._samples.STATUS_NONE, a
plain OMNYSampleStorage class attribute -- but self._samples is the BEC
client-side device proxy, which only exposes signals and USER_ACCESS
methods, not arbitrary class attributes. Every widget write to an OMNY
slot raised "'Device' object has no attribute 'STATUS_NONE'". Replaced
with a local OMNY_STATUS_NONE constant, same pattern already used for
EMPTY_NAME/PARKING_EMPTY_SENTINEL.
- omny_sample_transfer_mixin.py: otransfer_help() now also prints
dev.omny_samples.help()'s full command reference (set/unset slots,
shuttles, gripper, sample stage, owner, measured status) instead of just
pointing at it, so omny.otransfer_help() alone is a complete reference.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../plugins/omny/omny_sample_transfer_mixin.py | 8 ++++++--
.../widgets/sample_storage/sample_storage.py | 10 +++++++++-
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index a82fc592..90a3a0f3 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -1571,5 +1571,9 @@ class OMNYSampleTransferMixin:
print("omny.otransfer_get_sample(position) pick with the gripper from ")
print("omny.otransfer_put_sample(position) put with the gripper to ")
print("omny.otransfer_show_all() show the full sample storage overview")
- print("For the other storage commands (set/unset slots, shuttles, gripper, sample")
- print("stage) see the omny.otransfer_* commands, or dev.omny_samples.help()")
+ print(
+ "The other storage commands (set/unset slots, shuttles, gripper, sample stage,"
+ )
+ print("owner, measured status) are also available as omny.otransfer_* -- see below:")
+ print()
+ dev.omny_samples.help()
diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
index e83b9b51..3d1c1d12 100644
--- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
+++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
@@ -131,6 +131,14 @@ OMNY_PARKING_SLOTS = tuple(range(1, 7))
# slots (which use EMPTY_NAME = "-"), parking slots are cleared to "none".
PARKING_EMPTY_SENTINEL = "none"
+# Mirrors OMNYSampleStorage.STATUS_NONE, duplicated locally rather than read
+# off the device -- self._samples is the BEC *client-side* device proxy,
+# which only exposes USER_ACCESS methods/signals, not plain class
+# attributes like STATUS_NONE (reaching for self._samples.STATUS_NONE
+# raised "'Device' object has no attribute 'STATUS_NONE'" here, even though
+# it works when testing directly against the real device class).
+OMNY_STATUS_NONE = 0
+
# colours
# An occupied slot's name label intentionally has no fixed foreground colour
# here (see _SlotCell.set_state()) -- it was previously hardcoded to
@@ -823,7 +831,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
module docstring).
"""
try:
- status_none = self._samples.STATUS_NONE
+ status_none = OMNY_STATUS_NONE
kind = key[0]
if kind == "stage":
self._samples.sample_in_samplestage.set(used)
From 6291f6196b5acbc24f8e88c2e9561ad4aaa531b6 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 15:12:03 +0200
Subject: [PATCH 037/102] docs(omny): add sample-storage testing checklist
Covers the OMNY_SampleStorage widget, the real owner + measured-status
EPICS channel, the otransfer_* CLI wrappers, and transfer reference-image
saving -- so it can be pulled to a separate session/machine for testing.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../AI_docs/SAMPLE_STORAGE_TEST_CHECKLIST.md | 66 +++++++++++++++++++
1 file changed, 66 insertions(+)
create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/SAMPLE_STORAGE_TEST_CHECKLIST.md
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/SAMPLE_STORAGE_TEST_CHECKLIST.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/SAMPLE_STORAGE_TEST_CHECKLIST.md
new file mode 100644
index 00000000..21f47040
--- /dev/null
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/SAMPLE_STORAGE_TEST_CHECKLIST.md
@@ -0,0 +1,66 @@
+# OMNY sample-storage testing checklist (as of 2026-09-04)
+
+For live/sim testing of this branch's (`update-omny`) sample-storage work: the
+`OMNY_SampleStorage` widget's OMNY support, the real owner + measured-status EPICS
+channel, and the `omny.otransfer_*` CLI wrappers. Written so this can be pulled and
+run from a separate session/machine. See `OPEN_ISSUES.md` for the broader OMNY
+live-testing effort this is part of.
+
+## Widget — `OMNY_SampleStorage`
+
+- [ ] Open the widget in an OMNY session — header (stage+gripper) + fixed-O grid + 3
+ shuttle group boxes render, no "no known setup" banner.
+- [ ] Occupied slots show names in legible text (not near-invisible — this was a
+ dark-theme contrast bug, fixed).
+- [ ] Right-click an empty slot → **New sample…** → name + owner (both required,
+ empty owner rejected) → slot fills in without an error (a `STATUS_NONE`
+ crash here was fixed — client-side device proxies don't expose plain class
+ attributes, only signals/USER_ACCESS methods).
+- [ ] Duplicate name (matching another occupied slot) → rejected with a clear
+ message; renaming a slot to its own existing name is still fine.
+- [ ] Right-click an occupied slot → menu shows only **Change name…** / **Clear
+ (set empty)** — no "Set measured status…" (removed; status is CLI-only now).
+- [ ] **Clear (set empty)** works without error.
+- [ ] Each shuttle's status line: right-click → **Set parking slot…** (1-6) /
+ **Clear parking assignment** — shows "Slot N" / "not parked". The
+ "● ACTIVE" badge needs `oparkz` sitting at a real target position (2-6);
+ may not be easy to trigger from sim unless you drive that motor there.
+- [ ] **Print** button builds a PDF (expect a "printer not available" message
+ unless you have a `WSLA_X12SA` CUPS queue locally — not a bug).
+
+## CLI (`omny.otransfer_*`)
+
+- [ ] Tab-complete `omny.otransfer_` — lists ~22 commands (`otransfer_show_all`,
+ `otransfer_set_sample_status`, `otransfer_get_sample_owner`, etc.).
+- [ ] `omny.otransfer_help()` — prints the transfer commands **and then the full
+ `dev.omny_samples.help()` reference** below it, in one call.
+- [ ] `omny.otransfer_show_all()` — table shows `(owner)` and
+ `◐ started`/`✓ completed` suffixes on occupied slots.
+- [ ] `dev.omny_samples.set_sample_slot('A', 4, 'testsample', 'yourname')` then
+ `dev.omny_samples.get_sample_name_and_owner('A', 4)` round-trips.
+- [ ] `omny.otransfer_set_sample_status('A', 1, 'started')` / `'completed'` /
+ `'none'` — status is settable from the CLI (by design; not from the widget).
+- [ ] `omny.otransfer_park_loadlock_slot(6)` → answer **y** to "in back position"
+ → should now proceed (drive to the slot) instead of raising
+ `OMNYTransferError` (was inverted — pre-existing bug, fixed); answer **n**
+ → should correctly abort with that error.
+
+## Transfer reference-image saving
+
+Depends on whether your session drives the Galil `MGconfirm`/`MGmntprgs`
+handshake (`_otransfer_confirm()`) — may not fire in a lightweight sim.
+
+- [ ] Run `omny.otransfer_put_sample(...)` / `otransfer_get_sample(...)` through a
+ confirm checkpoint → check `~/data/raw/logs/omny_sample_transfer_images/`
+ for a new `.h5` file with both cameras of whichever pair was active
+ (parking `cam200`/`cam203` vs stage `cam201`/`cam202`).
+- [ ] If the handshake doesn't fire: `omny._otransfer_save_reference_images(file_suffix='test')`
+ after opening a camera view (e.g. `omny.otransfer_park_slot(3)`) to set
+ `_omnycam_active_pair` — same effect, minus the physical move.
+
+## X-ray eye alignment — known incomplete, not this branch's scope
+
+The fit+image saving in `x_ray_eye_align.py` is shared code (also used by FlOMNI/LamNI)
+and should already work in principle, but it has pre-existing OMNY/LamNI method-name
+mismatches (e.g. `self.lfzp_in()`) that likely block the actual click-through run from
+completing for OMNY. Not fixed this pass — flag if you hit it.
From 89056e5a527d9f4ad2e141b6e3bc358a35c8b1d4 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 15:12:41 +0200
Subject: [PATCH 038/102] docs(omny): add tomo-scan/queue/widget live-testing
checklist
Written so Mirko can pull this and run through it from his own machine --
covers the Phase 2 widget (untested, includes the critical type-4/5
_validate() fix), the progress-GUI/idle-time/timing-summary changes, first
real end-to-end tomo-queue runs, and the advanced tomo modes' full scans
(only parameters were tested so far). Mirrors SAMPLE_STORAGE_TEST_CHECKLIST.md's
format/purpose for the concurrent sample-storage work.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md | 59 +++++++++++++++++++
1 file changed, 59 insertions(+)
create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md
new file mode 100644
index 00000000..1ec26493
--- /dev/null
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md
@@ -0,0 +1,59 @@
+# OMNY tomo-scan/tomo-queue/widget testing checklist (as of 2026-09-04)
+
+For live/sim testing of this branch's (`update-omny`) tomo-scan work: the tomo-queue backend
+(`TomoQueueMixin`, Phase 1), the progress-ring GUI + idle-time tracking + end-of-scan timing
+summary, the `~/data/raw/` path fixes, and the `OMNY_TomoParams` widget's OMNY support (Phase 2).
+Written so this can be pulled and run from a separate session/machine. See `OPEN_ISSUES.md` for
+the broader OMNY live-testing effort this is part of.
+
+## Widget — `OMNY_TomoParams` (never tested live, highest priority)
+
+- [ ] Open `OMNY_TomoParams` against the OMNY session — it detects `"omny"` (not flomni/lamni),
+ no "no known setup" banner.
+- [ ] Cycle through all 5 tomo types in the dropdown — the "Equally spaced sub-tomograms" section
+ shows for types 1/4/5 and hides for 2/3; the "Sub-tomograms" label reads 2/4/8 correctly for
+ each.
+- [ ] **Submit or queue a type-4 and a type-5 job specifically** — a hardcoded validation bug
+ (`tomo_type not in (1, 2, 3)`) would have silently rejected these before the fix; this is
+ the single most important check on this list.
+- [ ] Check a queued type-4/5 job's tooltip and the "Projections" column in the queue dialog show
+ correct values.
+- [ ] Shrink FOV/step until the Fermat-position count drops low enough to trigger the orange
+ warning / blocked submit — compare against the CLI's `tomo_parameters()` warning for the
+ same values.
+
+## Progress GUI / idle-time / timing summary (recently changed)
+
+- [ ] Run a real scan and watch the progress window — it should **not** re-raise/refocus on every
+ projection anymore (was "closing and reopening" on macOS).
+- [ ] Progress bar shows **3 rings** now (third one auto-tracks the live scan itself).
+- [ ] After a completed `tomo_scan()`, the end-of-scan summary prints: "Total measurement time" /
+ "...excluding detected gaps" / "...lost to detected gaps".
+- [ ] Interrupt and resume a scan (or otherwise create a gap) — confirm idle time is picked up in
+ that summary instead of showing 0.
+
+## Tomo-queue backend, first real end-to-end runs
+
+- [ ] `tomo_queue_execute()` on a real queued job, not just add/show.
+- [ ] `tomo_scan_resume()` after interrupting a scan.
+- [ ] `tomo_queue_reacquire()` — try it on both a type-1 job and a type-4/5 job (the
+ `_TOMO_TYPE_1_LIKE` generalization is new).
+
+## Advanced tomo modes — parameters were tested, full scans weren't
+
+- [ ] A real `omny.tomo_scan()` for type 2 (golden ratio) and type 3 (golden-start), not just
+ setting parameters via `tomo_parameters()`.
+- [ ] A real full scan for type 4 and type 5 (4/8 sub-tomograms).
+
+## Smaller fixes
+
+- [ ] `tomography_scannumbers.txt` and the PDF report land under `~/data/raw/...` (not the old
+ `~/Data10/...`, which doesn't exist on a fresh machine).
+- [ ] If your session has scilog configured: `write_to_scilog()`/the PDF report actually send
+ (rather than just skip with a "scilog is not enabled" warning).
+
+## Already confirmed working live (informational, not re-testing needed)
+
+- `omny.tomo_scan()`/`omny.tomo_queue_add()`/`omny.tomo_queue_show()` for tomo_type 1 — "tomo
+ scan and tomo queue seems to work now for omny."
+- `tomo_parameters()`'s "too few Fermat scan points" CLI warning.
From 0bdd20013b42c48cb13f4734100c178ff2eb8fbf Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 15:28:27 +0200
Subject: [PATCH 039/102] fix(omny): catch KeyboardInterrupt around frozen-GUI
raise_window() calls
A Ctrl+C landing during raise_window()'s RPC wait (e.g. when the GUI
process is frozen/unresponsive) was escaping two `except Exception`
handlers that don't catch KeyboardInterrupt (a BaseException sibling,
not an Exception subclass): omnygui_show_progress() aborted the whole
tomo_scan()/tomo_scan_resume() call instead of just skipping the
window-raise, and tomo_queue_execute() left the job silently stuck at
status="running" instead of the usual clean "incomplete" + explanatory
message. Reproduced live by Mirko via the tomo queue.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/OMNY_shared/tomo_queue_mixin.py | 9 ++-
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 66 +++++++++++++++++++
.../plugins/omny/gui_tools.py | 11 ++++
.../test_omny_gui_tools.py | 15 +++++
.../test_omny_tomo_queue.py | 22 +++++++
5 files changed, 122 insertions(+), 1 deletion(-)
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 c26f7fa2..fc142da3 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
@@ -916,7 +916,14 @@ class TomoQueueMixin:
# never block on input() -- see LamNI.tomo_scan()'s
# fine-alignment check.
self.tomo_scan(interactive=False)
- except Exception as exc:
+ except (Exception, KeyboardInterrupt) as exc:
+ # KeyboardInterrupt is a BaseException, not an Exception -- a
+ # bare `except Exception` misses it, leaving the job stuck at
+ # status="running" with no explanation the next time the
+ # queue is inspected (confirmed live: a Ctrl+C landing during
+ # tomo_scan_resume()'s GUI-raise call produced exactly this --
+ # a silent "running" job and a raw traceback instead of the
+ # informative message below).
self._tomo_queue_proxy.update_by_id(job_id, status="incomplete")
print(f"Tomo queue job '{label}' did not complete: {exc}")
print(
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 086c1ad1..efab46ed 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -45,6 +45,14 @@ ported to OMNY (Phase 2 of 2)" below. Mirko: "is the way to move to the widget p
that. No live GUI verification yet (needs a real display, same as every other GUI change this
branch) -- next step is Mirko trying it against a real OMNY session.
+**Just fixed following Mirko's next round of live testing (from the test checklist)**: a
+Ctrl+C during `tomo_queue_resume()`'s GUI-raise call (triggered by a frozen/unresponsive GUI
+process) was escaping as a raw `KeyboardInterrupt` instead of being handled gracefully, in two
+places -- see "KeyboardInterrupt during a frozen-GUI raise_window() call was not caught" below.
+The underlying GUI freeze itself, and a separate "two items in the server queue... after
+clearing it also did not resume" report, are not yet explained -- need more detail from Mirko
+(see that section's last paragraph).
+
**Still open / deferred** (unchanged from before, not touched this pass):
- `x_ray_eye_align.py` is still LamNI-derived, unadapted code beyond the two call-site renames
that unblocked the GUI-open crash — see its own section below. The GUI itself opens fine now;
@@ -543,6 +551,64 @@ equally-spaced section shows/hides correctly and the new sub-tomogram-count labe
submitting/queueing a type-4 or type-5 job (confirms the `_validate()` fix specifically), and
checking a queued job's tooltip/Projections column in the queue dialog.
+## KeyboardInterrupt during a frozen-GUI raise_window() call was not caught (fixed)
+
+Mirko, running from the tomo queue on his own machine: aborted a running scan with Ctrl+C
+(worked correctly -- clean `ScanInterruption: User abort`, queue paused, job marked
+`"incomplete"`), then called `omny.tomo_queue_resume()` twice; the second call, while
+`tomo_scan_resume()` was starting up, produced a **raw Python `KeyboardInterrupt` traceback**
+instead of BEC's usual graceful abort. Separately: "the gui progress and tomo parameters was
+open and both a frozen on my local machine", and "i had two items in the server queue... after
+clearing it also did not resume."
+
+Root cause, read straight off the traceback: the stack bottomed out in
+`self.gui.omny.raise_window()` -> `bec_widgets`' `RPCBase._run_rpc()`, blocked on
+`self._msg_wait_event.wait(...)`. `tomo_scan()` calls `omnygui_show_progress()`
+(-> `omnygui_show_gui()` -> `raise_window()`) unconditionally as its first line, on every call
+including `tomo_scan_resume()`. When the GUI process itself is frozen/unresponsive (matching
+Mirko's separate "both frozen on my local machine" report), that RPC call doesn't hang forever --
+`bec_widgets`' default RPC timeout is 60s (`BECGuiClient._rpc_timeout`, `client_utils.py`), after
+which it raises a clean `RPCResponseTimeoutError` -- but 60s is a long, silent wait for an
+operator watching a terminal, and Mirko understandably lost patience and hit Ctrl+C himself
+during that wait. `KeyboardInterrupt` is a `BaseException`, not an `Exception` subclass, so it
+sailed straight past two different `except Exception` handlers that were supposed to catch
+exactly this kind of trouble:
+
+1. `omnygui_show_progress()` (`gui_tools.py`) already wrapped the whole show/raise sequence in
+ `try/except Exception` specifically so a GUI hiccup can never abort a scan -- but a
+ `KeyboardInterrupt` escaped it outright, propagating all the way up through
+ `tomo_scan_resume()`. **Fixed**: added a dedicated `except KeyboardInterrupt` clause that logs
+ a warning and continues without raising the window -- bringing the window to front is a
+ nicety, not something the scan itself needs.
+2. `tomo_queue_execute()` (`tomo_queue_mixin.py`)'s `except Exception as exc:` around the
+ `tomo_scan()`/`tomo_scan_resume()` call is what marks a job `status="incomplete"` and prints
+ the informative "Tomo queue job '...' did not complete: ... Queue paused..." message -- the
+ same message Mirko saw work correctly on the *first* abort. A `KeyboardInterrupt` escaping this
+ handler leaves the job silently stuck at `status="running"` with no explanation printed at all,
+ which is exactly what "I had two items in the server queue. I do not have details" describes.
+ **Fixed**: widened to `except (Exception, KeyboardInterrupt) as exc:`, so any interruption --
+ not just a raised `Exception` -- gets the same clean status update and message.
+
+Both fixes are narrowly scoped (they don't change how Ctrl+C is handled *during* an actual running
+scan -- BEC's own deferred-pause/abort alarm flow for that is untouched) and only change what
+happens when the interrupt lands during the pre-scan GUI-raise window. Regression tests:
+`test_omnygui_show_progress_swallows_keyboardinterrupt_from_frozen_gui` (`gui_tools.py`'s
+`raise_window()` mocked to raise `KeyboardInterrupt`, asserts `omnygui_show_progress()` doesn't
+propagate it) and `test_tomo_queue_execute_marks_job_incomplete_on_keyboardinterrupt`
+(`tomo_scan` mocked to raise `KeyboardInterrupt`, asserts the job ends up `"incomplete"` not
+`"running"`, and that the interrupt still propagates to the caller afterward -- it's not
+swallowed, just handled the same way any other mid-job exception already was).
+
+**Still unexplained, needs more detail from Mirko to pursue further**: "i had two items in the
+server queue" and "after clearing it also did not resume" -- ambiguous whether "server queue"
+means the OMNY-side `tomo_queue` (this session's job list, cleared via `tomo_queue_clear()`, which
+is fully destructive -- if that's what was cleared, "did not resume" is expected, there was
+nothing left to resume) or BEC's own scan-server execution queue (a lower-level, separate concept;
+inspectable via `bec.queue.scan_queue_status()` or the BECQueue GUI widget). Also unclear whether
+the underlying GUI freeze itself (client-side Qt process on Mirko's Mac) has a deeper cause worth
+chasing, or was a one-off. Not reproduced live this pass -- Mirko: "i cannot move to the machine
+you are running on with my tests now."
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
index ffc7d1a1..e770cf2a 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
@@ -220,6 +220,17 @@ class OMNYGuiTools:
self.progressbar.add_ring().set_update("manual")
self.progressbar.add_ring().set_update("scan")
self._omnygui_update_progress()
+ except KeyboardInterrupt:
+ # A frozen/unresponsive GUI process makes raise_window() block on
+ # its RPC wait (up to the client's ~60s default timeout) -- if the
+ # operator loses patience and hits Ctrl+C during that wait, don't
+ # let it escape as a raw KeyboardInterrupt and abort the whole
+ # tomo_scan()/tomo_scan_resume() call: bringing the window to
+ # front is a nicety, not something the scan itself needs.
+ logger.warning(
+ "omnygui_show_progress() interrupted (GUI unresponsive?) -- continuing "
+ "without raising the window."
+ )
except Exception as e:
logger.warning(f"Error in omnygui_show_progress: {e}")
diff --git a/tests/tests_bec_ipython_client/test_omny_gui_tools.py b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
index 55e54dd5..9e50bf59 100644
--- a/tests/tests_bec_ipython_client/test_omny_gui_tools.py
+++ b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
@@ -183,6 +183,21 @@ def test_omnygui_show_progress_creates_three_rings_matching_flomni():
ring3.set_update.assert_called_once_with("scan")
+def test_omnygui_show_progress_swallows_keyboardinterrupt_from_frozen_gui():
+ """A frozen/unresponsive GUI process makes raise_window()'s RPC wait block
+ for up to the client's default timeout; an operator losing patience and
+ hitting Ctrl+C during that wait must not abort the calling tomo_scan()/
+ tomo_scan_resume() with a raw KeyboardInterrupt -- confirmed live: this
+ is exactly what happened, since a bare `except Exception` does not catch
+ KeyboardInterrupt (a BaseException sibling, not a subclass)."""
+ tools, client = _make_gui_tools()
+ client.gui.windows = {"omny": mock.MagicMock()}
+ client.gui.omny.raise_window.side_effect = KeyboardInterrupt()
+ tools.progressbar = mock.MagicMock()
+
+ tools.omnygui_show_progress() # must not raise
+
+
def test_omnygui_update_progress_text_includes_start_eta_finish():
tools, _ = _make_gui_tools()
tools.progressbar = mock.MagicMock()
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
index 6ad9cca9..7627cd53 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
@@ -138,6 +138,28 @@ def test_tomo_queue_execute_resumes_incomplete_job():
assert job["status"] == "done"
+def test_tomo_queue_execute_marks_job_incomplete_on_keyboardinterrupt():
+ """KeyboardInterrupt is a BaseException, not an Exception -- a bare
+ `except Exception` around the tomo_scan()/tomo_scan_resume() call would
+ miss it, leaving the job silently stuck at status="running" with no
+ explanation. Confirmed live: a Ctrl+C landing during tomo_scan_resume()'s
+ GUI-raise call produced exactly that (plus a raw traceback instead of the
+ informative "Queue paused..." message)."""
+ omny = make_omny()
+ omny.tomo_scan = lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt())
+
+ omny.tomo_queue_add(label="job1")
+ try:
+ omny.tomo_queue_execute()
+ raised = False
+ except KeyboardInterrupt:
+ raised = True
+
+ assert raised
+ job = omny._tomo_queue_proxy.as_list()[0]
+ assert job["status"] == "incomplete"
+
+
def test_tomo_queue_add_command_move_action(monkeypatch):
omny = make_omny()
fake_dev = {"ccm_energy": "ccm-energy-device"}
From 70696756f169934cbc4faf8b0e53cf09b9262dc5 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 15:52:52 +0200
Subject: [PATCH 040/102] fix(omny): stop polling sample name via a device_rpc
queue round-trip
OMNY_TomoParams's poll timer called sample_name_getter every tick,
which for OMNY was dev.omny_samples.get_sample_name_in_samplestage()
-- a device method, dispatched by BEC as a device_rpc request through
the scan queue rather than a local read. With the queue busy or
paused this blocked the widget's Qt thread (GUI freeze while a scan
ran) and piled up duplicate PENDING device_rpc entries in BEC's
primary queue (confirmed live via bec.queue.queue_storage
.describe_queue()). The method was just a one-line wrapper around an
already-readable EpicsSignal, so read it directly with cached=True
instead, matching flomni's fast-getter pattern and avoiding even the
signal round-trip. Also applied cached=True to flomni's own getter,
which didn't have the device_rpc bug but is polled the same way.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 45 ++++++++++++----
.../widgets/tomo_params/tomo_params.py | 31 ++++++++++-
.../test_omny_tomo_params_widget_math.py | 51 +++++++++++++++++++
.../test_tomo_params_widget_math.py | 35 +++++++++++++
4 files changed, 151 insertions(+), 11 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index efab46ed..5b1ed802 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -599,15 +599,42 @@ propagate it) and `test_tomo_queue_execute_marks_job_incomplete_on_keyboardinter
`"running"`, and that the interrupt still propagates to the caller afterward -- it's not
swallowed, just handled the same way any other mid-job exception already was).
-**Still unexplained, needs more detail from Mirko to pursue further**: "i had two items in the
-server queue" and "after clearing it also did not resume" -- ambiguous whether "server queue"
-means the OMNY-side `tomo_queue` (this session's job list, cleared via `tomo_queue_clear()`, which
-is fully destructive -- if that's what was cleared, "did not resume" is expected, there was
-nothing left to resume) or BEC's own scan-server execution queue (a lower-level, separate concept;
-inspectable via `bec.queue.scan_queue_status()` or the BECQueue GUI widget). Also unclear whether
-the underlying GUI freeze itself (client-side Qt process on Mirko's Mac) has a deeper cause worth
-chasing, or was a one-off. Not reproduced live this pass -- Mirko: "i cannot move to the machine
-you are running on with my tests now."
+**Follow-up, fully explained and fixed**: Mirko confirmed "i was talking becs own queue" (BEC's
+own scan-server execution queue, not `tomo_queue`), and separately noticed "the gui is essentially
+freezing while a scan is running... pressing queue control in the params gui, the window only
+opened after the scan finished." Caught live with a stuck queue: `bec.queue.queue_storage
+.describe_queue()` showed **two duplicate `device_rpc` entries stuck PENDING**, both
+`device='omny_samples', func='get_sample_name_in_samplestage'` (different RIDs -- two separate
+invocations), plus one `func='stop'` (routine BEC abort cleanup, unrelated), all stuck because the
+queue was `PAUSED` from the earlier abort and never got serviced.
+
+Root cause: `OMNY_TomoParams`'s poll timer (`_on_poll()` -> `_refresh_sample_name()`,
+`tomo_params.py`) calls `SETUP_PROFILES["omny"]["sample_name_getter"]` on every tick.
+It was `lambda w: w.dev.omny_samples.get_sample_name_in_samplestage()` -- a device *method* call,
+which BEC dispatches as a `device_rpc` request through the scan queue (a real round-trip), unlike
+flomni's getter (`w.dev.flomni_samples.sample_names.sample0.get()`, a direct signal read) or
+lamni's (`w.client.get_global_var(...)`, a Redis read). Polling a queue-mediated call every tick
+means: (a) it blocks the widget's Qt thread waiting on the round-trip, so a busy/paused queue
+freezes the *entire* GUI including unrelated actions like opening the queue-control dialog, and
+(b) each tick fires a *new* request without checking if the previous one is still outstanding, so
+they pile up as duplicate PENDING entries whenever the queue can't service them promptly --
+exactly what the "two items in the server queue" report was.
+
+`get_sample_name_in_samplestage()` (`omny_sample_storage.py:453-455`) turned out to be a one-line
+wrapper: `return str(self.sample_in_samplestage_name.get())`, around an `auto_monitor=True`
+EpicsSignal that's already directly readable. **Fixed**: `sample_name_getter` now reads
+`w.dev.omny_samples.sample_in_samplestage_name.get(cached=True)` directly, matching flomni's
+fast-getter pattern -- no more device_rpc round-trip, so no more GUI freeze and no more duplicate
+queue entries from this source. Mirko, from prior advice from BEC developers: GUI code should
+prefer `cached=True` where possible -- checked `DeviceBase.get()` (`bec_lib/device.py`): without
+it, `.get()` still issues a live `self._run(cached=False, ...)` round-trip to the device server;
+`cached=True` reads BEC's already-locally-updated status cache instead, no round-trip at all, so
+used it here too (there's existing precedent for this exact pattern in GUI code, e.g.
+`sample_storage.py`'s `oparkz.readback.get(cached=True)`). Regression test:
+`test_omny_sample_name_getter_reads_signal_not_rpc_method` (fake device whose
+`get_sample_name_in_samplestage()` raises `AssertionError` if called, and whose fake signal
+records its `cached` kwarg -- confirms both that the getter only touches the signal and that it
+passes `cached=True`).
## Environment note (not code, but will bite again if forgotten)
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 bab239fd..df9cc913 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -2439,7 +2439,13 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"requested_to_stepsize": _requested_to_stepsize,
"compute_fermat_positions": _compute_fermat_positions_flomni,
"fermat_position_fields": ["tomo_shellstep", "fovx", "fovy"],
- "sample_name_getter": lambda w: w.dev.flomni_samples.sample_names.sample0.get(),
+ # cached=True: no active bug here (this was always a direct signal
+ # read, never the device-method/device_rpc round-trip OMNY's getter
+ # had -- see SETUP_PROFILES["omny"]["sample_name_getter"]), but
+ # since this is also polled every tick (_on_poll()), avoid the live
+ # self._run(cached=False, ...) round-trip on principle -- standard
+ # advice for GUI/widget code.
+ "sample_name_getter": lambda w: w.dev.flomni_samples.sample_names.sample0.get(cached=True),
"cli_hint_name": "flomni",
"tomo_types": {
1: {"label": "8 equally spaced sub-tomograms", "n_subtomos": 8},
@@ -2547,7 +2553,28 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"requested_to_stepsize": _requested_to_stepsize_omny,
"compute_fermat_positions": _compute_fermat_positions_omny,
"fermat_position_fields": ["tomo_shellstep", "fovx", "fovy"],
- "sample_name_getter": lambda w: w.dev.omny_samples.get_sample_name_in_samplestage(),
+ # Read the signal directly with cached=True (like flomni's own
+ # getter), not the get_sample_name_in_samplestage() wrapper method:
+ # a device *method* call is dispatched as a device_rpc request
+ # through BEC's scan queue -- a real round-trip, not a local read --
+ # so polling it every tick (see _on_poll()/_refresh_sample_name())
+ # blocks the widget's Qt thread whenever the queue is busy/paused,
+ # and piles up duplicate PENDING device_rpc entries in BEC's primary
+ # queue when it is. Confirmed live: two duplicate
+ # get_sample_name_in_samplestage device_rpc entries stuck PENDING
+ # after an abort left the queue paused, plus a GUI freeze while a
+ # scan was running -- the wrapper method itself is just
+ # `return str(self.sample_in_samplestage_name.get())`
+ # (omny_sample_storage.py), so reading the signal directly is
+ # equivalent and free of both problems. cached=True goes one step
+ # further: without it, DeviceBase.get() still issues a live
+ # `self._run(cached=False, ...)` round-trip to the device server;
+ # cached=True reads BEC's already-locally-updated status cache
+ # instead, no round-trip at all -- standard advice for GUI/widget
+ # code (see e.g. sample_storage.py's oparkz.readback.get(cached=True)).
+ "sample_name_getter": lambda w: w.dev.omny_samples.sample_in_samplestage_name.get(
+ cached=True
+ ),
"cli_hint_name": "omny",
# OMNY has three "equally spaced sub-tomograms" flavors (2/4/8
# sub-tomograms), unlike flomni's/lamni's single always-8 flavor --
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
index 6146c670..f39de1bd 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
@@ -197,3 +197,54 @@ def test_validate_tomo_type_condition_accepts_omny_types_4_and_5():
assert tomo_type in tomo_types, f"tomo_type {tomo_type} should be valid for omny"
for invalid in (0, 6, 99, None):
assert invalid not in tomo_types, f"tomo_type {invalid} should be rejected for omny"
+
+
+# --- sample_name_getter: regression guard against the device_rpc polling bug --
+
+
+def test_omny_sample_name_getter_reads_signal_not_rpc_method():
+ """sample_name_getter used to call get_sample_name_in_samplestage(), a
+ device *method* -- dispatched by BEC as a device_rpc request through the
+ scan queue, a real round-trip, not a local read. The widget's poll timer
+ calls this every tick (_on_poll()/_refresh_sample_name()), so with the
+ queue busy or paused this blocked the widget's Qt thread (GUI "freeze"
+ while a scan ran) and piled up duplicate PENDING device_rpc entries in
+ BEC's primary queue (confirmed live, two stuck entries after an abort
+ left the queue paused). The method itself is just
+ `return str(self.sample_in_samplestage_name.get())`
+ (omny_sample_storage.py) -- fixed to read that signal directly, matching
+ flomni's own getter's pattern (a direct .get(), no RPC)."""
+
+ class _FakeSignal:
+ def __init__(self, value):
+ self._value = value
+ self.get_calls = []
+
+ def get(self, cached=False):
+ self.get_calls.append(cached)
+ return self._value
+
+ class _FakeOmnySamples:
+ def __init__(self):
+ self.sample_in_samplestage_name = _FakeSignal("my_sample")
+
+ def get_sample_name_in_samplestage(self):
+ raise AssertionError(
+ "sample_name_getter must not call the device_rpc-dispatched "
+ "method -- it should read the signal directly"
+ )
+
+ class _FakeDev:
+ omny_samples = _FakeOmnySamples()
+
+ class _FakeWidget:
+ dev = _FakeDev()
+
+ signal = _FakeDev.omny_samples.sample_in_samplestage_name
+ result = SETUP_PROFILES["omny"]["sample_name_getter"](_FakeWidget())
+
+ assert result == "my_sample"
+ # cached=True avoids DeviceBase.get()'s live self._run(cached=False, ...)
+ # round-trip to the device server, reading BEC's local status cache
+ # instead -- standard advice for GUI/widget code.
+ assert signal.get_calls == [True]
diff --git a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py
index 1f1b1f6f..2f4f77ac 100644
--- a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py
+++ b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py
@@ -10,6 +10,7 @@ import pytest
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni
from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
+ SETUP_PROFILES,
_compute_fermat_positions_flomni,
_compute_type1,
_requested_to_stepsize,
@@ -73,3 +74,37 @@ def test_compute_fermat_positions_flomni_zero_step_is_safe():
)
assert count == 0
assert min_positions == FlomniFermatScan.MIN_POSITIONS
+
+
+def test_flomni_sample_name_getter_uses_cached_read():
+ """Not fixing a bug here (unlike SETUP_PROFILES["omny"]["sample_name_getter"]
+ -- this getter was always a direct signal read, never a device-method call
+ dispatched as a device_rpc through the scan queue), but this is also polled
+ every tick (_on_poll()), so avoid DeviceBase.get()'s live
+ self._run(cached=False, ...) round-trip on the same principle."""
+
+ class _FakeSignal:
+ def __init__(self, value):
+ self._value = value
+ self.get_calls = []
+
+ def get(self, cached=False):
+ self.get_calls.append(cached)
+ return self._value
+
+ class _FakeSampleNames:
+ sample0 = _FakeSignal("my_sample")
+
+ class _FakeFlomniSamples:
+ sample_names = _FakeSampleNames()
+
+ class _FakeDev:
+ flomni_samples = _FakeFlomniSamples()
+
+ class _FakeWidget:
+ dev = _FakeDev()
+
+ result = SETUP_PROFILES["flomni"]["sample_name_getter"](_FakeWidget())
+
+ assert result == "my_sample"
+ assert _FakeSampleNames.sample0.get_calls == [True]
From 78f0c8fa3bce14ff37185ad4c0f3939b8da162dc Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 16:25:07 +0200
Subject: [PATCH 041/102] feat(omny): port zero_deg_reference_at_each_subtomo
to types 1/4/5
flomni already has this (type-1-only: a dedicated projection at exactly
0 degrees before every odd/forward sub-tomogram, plus once more at the
end, for tracking radiation damage over a tomogram). Deliberately
excluded from OMNY during the Phase 1 port as flomni-specific; Mirko
asked for it to be extended to OMNY's generalized equally-spaced types
(1/4/5, 2/4/8 sub-tomograms), and confirmed the pre-shot should fire
even for sub-tomogram 1 (whose own first projection already lands on 0)
to get two consecutive 0-degree shots as an immediate baseline pair.
Also fixes a second hardcoded `tomo_type != 1` bug in the widget's
_irrelevant_params_for_type() (same class as the earlier _validate()
fix) that would have hidden this setting from OMNY type-4/5 job
tooltips even when enabled.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 69 +++++++++++++++++++
.../omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md | 11 +++
.../bec_ipython_client/plugins/omny/omny.py | 68 +++++++++++++++++-
.../widgets/tomo_params/tomo_params.py | 25 +++++--
.../test_omny_tomo_params_widget_math.py | 23 ++++++-
.../test_omny_tomo_queue.py | 9 ++-
.../test_omny_tomo_scan.py | 61 ++++++++++++++++
7 files changed, 252 insertions(+), 14 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 5b1ed802..ec4fca15 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -636,6 +636,75 @@ used it here too (there's existing precedent for this exact pattern in GUI code,
records its `cached` kwarg -- confirms both that the getter only touches the signal and that it
passes `cached=True`).
+## zero_deg_reference_at_each_subtomo ported to OMNY's equally spaced types (1/4/5)
+
+Mirko: "one thing flomni has and we could implement here as well is the repeated projection when
+the stage is close to 0 anyways... for the tomo types 1,4 and 5 it could also be useful. in tomo
+type 1 it would of course only mean that at the end of the measurement one additional projection
+will be recorded." This flag was deliberately excluded from OMNY's `_TOMO_SCAN_PARAM_NAMES` during
+the Phase 1 port as a "flomni-only concept" (flomni's version is type-1-only, tied to its fixed
+8-sub-tomogram/180-vs-360-mode scheme) -- this reverses that call for OMNY's generalized 1/4/5
+equally-spaced types specifically, it's not a blind re-add.
+
+Verified against `_equally_spaced_subtomo_angles()`'s bit-reversal interleaving: odd (forward)
+sub-tomograms always start near angle 0 for any `n_subtomos` (2/4/8) -- exactly flomni's 180-mode
+rule (`subtomo_number % 2 == 1`), no 360-mode branch needed since OMNY only ever scans 180deg.
+Sub-tomogram 1 specifically always starts at *exactly* 0.0 (phase offset 0 by construction,
+regardless of tomo_type); the other odd sub-tomograms (3/5/7, types 4/5 only) start at a small but
+*nonzero* phase offset -- genuinely new angles near 0, not exact duplicates.
+
+One design question flomni's own code doesn't answer for OMNY: firing the pre-shot before
+sub-tomogram 1 too would duplicate its own first projection (both exactly angle 0, back to back).
+Asked Mirko directly -- answer: "it will be very helpful to start with two projections at zero
+degrees" -- so the pre-shot fires before every odd sub-tomogram including 1, deliberately
+producing two consecutive 0-degree shots at the very start (useful as an immediate damage/noise-
+floor baseline pair), matching flomni's own ported logic exactly rather than diverging from it.
+
+**Implementation** (`omny.py`): new `zero_deg_reference_at_each_subtomo` property (mirrors
+flomni's of the same name), added to `_TOMO_SCAN_PARAM_NAMES` (comment above the tuple updated --
+this is now the one flomni-adjacent concept OMNY *does* carry, unlike `tomo_angle_range`/
+`single_point_*`/`frames_per_trigger`, which still don't apply). `tomo_scan()`'s type-1-like loop:
+before each odd sub-tomogram (`ii % 2 == 1`), fires `self._tomo_scan_at_angle(0, ii)` -- guarded by
+`start_angle is None` so a resume mid-sub-tomogram doesn't force a detour through 0 it isn't
+actually passing through -- and once more after the whole loop finishes, for the final before/after
+bookend shot. `tomo_parameters()` gained a matching print (current-settings summary) and a
+`self.OMNYTools.yesno(...)` prompt (edit mode), gated by `self.tomo_type in self._TOMO_TYPE_1_LIKE`.
+
+**Widget** (`tomo_params.py`): `SETUP_PROFILES["omny"]["has_zero_deg_reference"]` flipped
+`False` -> `True` -- the checkbox itself (`_build_type1_section()`'s `if self._profile[
+"has_zero_deg_reference"]: self._add_bool(...)`) was already fully generic, needing no new UI
+code. Added `zero_deg_reference_at_each_subtomo` to `OMNY_QUEUE_PARAM_NAMES`/`OMNY_DEFAULTS`
+(default `False`). **Second hardcoded-`!= 1` bug caught while wiring this up** (same class of bug
+as `_validate()`'s Phase 2 fix): `_irrelevant_params_for_type()` (used by `_job_tooltip()` to hide
+inapplicable fields) had `if tomo_type != 1: irrelevant |= _TYPE1_ONLY_PARAMS` -- would have hidden
+this setting from every OMNY type-4/5 job's tooltip even when genuinely enabled. Fixed to
+`tomo_type not in (1, 4, 5)` (safe without profile context: 4/5 are OMNY-only tomo_type values,
+flomni/lamni jobs never have them).
+
+Regression tests: `test_tomo_scan_zero_deg_reference_disabled_by_default_no_extra_shots`,
+`..._type1_fires_before_subtomo1_and_after_subtomo2`, `..._type5_fires_before_every_odd_subtomo`,
+`..._skipped_only_for_the_resumed_subtomo` (`test_omny_tomo_scan.py`, spy on `_tomo_scan_at_angle`
+with `sub_tomo_scan` stubbed out); `test_irrelevant_params_for_type_keeps_zero_deg_reference_for_omny_types_4_and_5`
+(`test_omny_tomo_params_widget_math.py`); updated the pre-existing "no flomni-only concepts" test
+(`test_omny_tomo_queue.py`) and the pre-existing `has_zero_deg_reference is False` test (now
+`True`), both of which would otherwise have failed against this deliberate reversal. Full suite:
+770 passed.
+
+**Deferred, needs Mirko's hardware knowledge, not implemented**: Mirko also asked for flomni's
+`collect_empty_frames()` (acquires 10 flat-field/empty frames at angle 0 with the sample shifted
+out of the beam by fovx/2, called once at the start of every new tomo_scan()) to be ported to
+OMNY. Checked flomni's implementation (`flomni.py:2570`) -- it's built entirely on flomni's fine-
+piezo/laser-tracker stack (`dev.rtx`/`rty`/`rtz`, `laser_tracker_check_signalstrength()`,
+`move_samx_to_scan_region()`), none of which OMNY has. OMNY's own alignment offsets
+(`omny_alignment_mixin.py`'s `get_alignment_offset()`) are a pure sinusoidal-fit computation
+folded directly into `cenx`/`ceny` passed to `scans.omny_fermat_scan()` -- there's no separate
+fine-stage motor to move at all, and no `scans.acquire()` (or equivalent stationary-multi-frame-
+burst call) used anywhere in OMNY's plugin code to crib from. A literal port isn't possible without
+knowing: (1) what OMNY-side call performs a stationary multi-frame acquisition (does one exist?),
+and (2) whether "shift out of the beam" for OMNY means moving `osamx` directly (it's a real,
+movable coarse stage, confirmed) or just passing a deliberately offset `cenx` into
+`omny_fermat_scan()`. Not guessed at -- next step is asking Mirko for these two specifics.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md
index 1ec26493..69de97aa 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md
@@ -45,6 +45,17 @@ the broader OMNY live-testing effort this is part of.
setting parameters via `tomo_parameters()`.
- [ ] A real full scan for type 4 and type 5 (4/8 sub-tomograms).
+## zero_deg_reference_at_each_subtomo (new, never tested live)
+
+- [ ] `tomo_parameters()`: enable it for tomo_type 1 -- confirm the prompt/summary text and that a
+ real `tomo_scan()` records two projections at angle 0 right at the start (subtomo 1's own
+ first projection plus the deliberate duplicate) and one more at the very end.
+- [ ] Same for tomo_type 4 or 5 -- confirm extra 0-degree shots also appear before sub-tomograms
+ 3/5/7 (not just 1), each at a distinct (nonzero) angle from 0, not a duplicate.
+- [ ] Widget: the "0° reference each sub-tomo" checkbox now appears for OMNY (previously
+ flomni/lamni only) -- toggle it for a type-4/5 job specifically and check the queued job's
+ tooltip shows it (regression guard for a hardcoded `!= 1` bug caught while wiring this up).
+
## Smaller fixes
- [ ] `tomography_scannumbers.txt` and the PDF report land under `~/data/raw/...` (not the old
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index fbc4f402..10ef4a1d 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -654,8 +654,10 @@ class OMNY(
# Property names a tomo_queue_add() job snapshots (TomoQueueMixin
# contract). Mirrors flomni's own tuple exactly, since OMNY's properties
# already mirror flomni's names 1:1 -- no tomo_angle_range/single_point_*/
- # zero_deg_reference_at_each_subtomo/frames_per_trigger, since OMNY has
- # no such properties.
+ # frames_per_trigger, since OMNY has no such properties.
+ # zero_deg_reference_at_each_subtomo *is* included (unlike the above) --
+ # it applies to any equally spaced tomo type, not just type 1, see
+ # tomo_scan()'s type-1-like loop and its own property docstring below.
_TOMO_SCAN_PARAM_NAMES = (
"tomo_countingtime",
"tomo_shellstep",
@@ -671,6 +673,7 @@ class OMNY(
"golden_ratio_bunch_size",
"golden_max_number_of_projections",
"golden_projections_at_0_deg_for_damage_estimation",
+ "zero_deg_reference_at_each_subtomo",
"corridor_size",
"at_each_angle_hook",
)
@@ -1051,6 +1054,30 @@ class OMNY(
def golden_projections_at_0_deg_for_damage_estimation(self, val: float):
self.client.set_global_var("golden_projections_at_0_deg_for_damage_estimation", val)
+ @property
+ def zero_deg_reference_at_each_subtomo(self):
+ """If True (tomo_type in _TOMO_TYPE_1_LIKE, i.e. 1/4/5 -- the equally
+ spaced sub-tomogram modes), an additional projection at exactly 0
+ degrees is acquired at the start of every odd (forward) sub-tomogram
+ -- i.e. every time the rotation passes back through 0 degrees,
+ including sub-tomogram 1 itself, which already lands on 0 degrees
+ naturally -- and once more after the final sub-tomogram completes.
+ Together with sub-tomogram 1's own first projection, this gives two
+ consecutive 0-degree shots right at the start (deliberate: useful as
+ an immediate damage/noise-floor baseline pair) and one more at every
+ later natural pass through 0, useful for tracking radiation damage
+ over time. Mirrors flomni's property of the same name, and
+ golden_projections_at_0_deg_for_damage_estimation, which provides
+ similar functionality for tomo_type 2/3."""
+ val = self.client.get_global_var("zero_deg_reference_at_each_subtomo")
+ if val is None:
+ return False
+ return val
+
+ @zero_deg_reference_at_each_subtomo.setter
+ def zero_deg_reference_at_each_subtomo(self, val: bool):
+ self.client.set_global_var("zero_deg_reference_at_each_subtomo", val)
+
@property
def golden_ratio_bunch_size(self):
val = self.client.get_global_var("golden_ratio_bunch_size")
@@ -1354,9 +1381,34 @@ class OMNY(
n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type]
self.progress["tomo_type"] = f"Equally spaced sub-tomograms ({n_subtomos})"
for ii in range(subtomo_start, n_subtomos + 1):
+ if (
+ start_angle is None
+ and ii % 2 == 1
+ and self.zero_deg_reference_at_each_subtomo
+ ):
+ # Dedicated reference shot at exactly 0 degrees, taken
+ # every time the rotation passes back through 0
+ # (every odd/forward sub-tomogram -- see
+ # _equally_spaced_subtomo_angles()), for tracking
+ # radiation damage over the full tomogram. Fires
+ # for sub-tomogram 1 too even though its own first
+ # projection already lands on 0 -- two consecutive
+ # 0-degree shots right at the start is deliberate
+ # (immediate baseline pair), not skipped as
+ # redundant. Skipped when resuming mid-sub-tomogram
+ # (start_angle given explicitly) since we're not
+ # actually passing through 0 deg at that moment.
+ self._tomo_scan_at_angle(0, ii)
self.sub_tomo_scan(ii, n_subtomos=n_subtomos, start_angle=start_angle)
start_angle = None
+ if self.zero_deg_reference_at_each_subtomo:
+ # Final reference shot at exactly 0 degrees once the whole
+ # tomogram is complete, giving a clean "before vs after"
+ # pair for radiation-damage comparison across the full
+ # acquisition.
+ self._tomo_scan_at_angle(0, n_subtomos)
+
elif self.tomo_type == 2:
# Golden ratio tomography
previous_subtomo_number = -1
@@ -1889,6 +1941,11 @@ class OMNY(
)
print(f"Total number of projections: {180/self.tomo_angle_stepsize*n_subtomos}")
print(f"Angular step within sub-tomogram: {self.tomo_angle_stepsize} degrees")
+ if self.tomo_type in self._TOMO_TYPE_1_LIKE and self.zero_deg_reference_at_each_subtomo:
+ print(
+ "Repeating a reference projection at 0 degrees at the start of every forward"
+ " sub-tomogram, plus once more at the end."
+ )
print(f"\nSample name: {self.sample_name}\n")
if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"):
@@ -1994,6 +2051,13 @@ class OMNY(
self.tomo_angle_stepsize = 180 / tomo_numberofprojections * n_subtomos
print(f"The angular step in a subtomogram it will be {self.tomo_angle_stepsize}")
+ if self.tomo_type in self._TOMO_TYPE_1_LIKE:
+ self.zero_deg_reference_at_each_subtomo = self.OMNYTools.yesno(
+ "Repeat a reference projection at 0 degrees at the start of every forward"
+ " sub-tomogram (plus once more at the end)?",
+ "y" if self.zero_deg_reference_at_each_subtomo else "n",
+ )
+
@staticmethod
def _get_val(msg: str, default_value, data_type):
return data_type(input(f"{msg} ({default_value}): ") or default_value)
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 df9cc913..4df22863 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -80,11 +80,13 @@ logger = bec_logger.logger
# ── constants ────────────────────────────────────────────────────────────────
# Mirrors _update_type_visibility()'s section/field gating (_sec_type1 shown
-# only for type 1, golden_ratio_bunch_size shown only for type 2 within
-# _sec_type23, the rest of _sec_type23 shown for type 2/3) -- reused by
-# _job_tooltip() so a queued job's hover details don't show fields that are
-# meaningless for its own tomo_type (e.g. golden-ratio settings on a type-1
-# job), on both flomni and lamni.
+# for every equally spaced sub-tomogram type -- 1 for flomni/lamni, 1/4/5 for
+# OMNY --, golden_ratio_bunch_size shown only for type 2 within _sec_type23,
+# the rest of _sec_type23 shown for type 2/3) -- reused by _job_tooltip() so
+# a queued job's hover details don't show fields that are meaningless for
+# its own tomo_type (e.g. golden-ratio settings on a type-1 job).
+# tomo_angle_range is flomni-only regardless (OMNY has no such property, so
+# it's simply never present in an OMNY job's params to begin with).
_TYPE1_ONLY_PARAMS = {"tomo_angle_range", "zero_deg_reference_at_each_subtomo"}
_TYPE2_ONLY_PARAMS = {"golden_ratio_bunch_size"}
_TYPE23_PARAMS = {
@@ -97,7 +99,11 @@ def _irrelevant_params_for_type(tomo_type: Any) -> set[str]:
"""Param names that don't apply to a job's own tomo_type and should be
hidden from its tooltip."""
irrelevant: set[str] = set()
- if tomo_type != 1:
+ # tomo_type 1 for flomni/lamni; 1/4/5 (all equally spaced sub-tomogram
+ # flavors) for OMNY -- 4/5 are OMNY-only tomo_type values (flomni/lamni
+ # jobs never have them), so this is safe without knowing which setup a
+ # given job's params belong to.
+ if tomo_type not in (1, 4, 5):
irrelevant |= _TYPE1_ONLY_PARAMS
if tomo_type not in (2, 3):
irrelevant |= _TYPE23_PARAMS
@@ -240,6 +246,7 @@ OMNY_QUEUE_PARAM_NAMES = (
"golden_ratio_bunch_size",
"golden_max_number_of_projections",
"golden_projections_at_0_deg_for_damage_estimation",
+ "zero_deg_reference_at_each_subtomo",
"corridor_size",
"at_each_angle_hook",
)
@@ -260,6 +267,7 @@ OMNY_DEFAULTS: dict[str, Any] = {
"golden_ratio_bunch_size": 20,
"golden_max_number_of_projections": 1000.0,
"golden_projections_at_0_deg_for_damage_estimation": 0,
+ "zero_deg_reference_at_each_subtomo": False,
"corridor_size": -1,
"at_each_angle_hook": None,
}
@@ -2525,7 +2533,10 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"defaults": OMNY_DEFAULTS,
"has_180_mode": False,
"has_single_point": False,
- "has_zero_deg_reference": False,
+ # Applies to any equally spaced tomo type here (1/4/5), not just
+ # type 1 -- see omny.py's zero_deg_reference_at_each_subtomo
+ # property and tomo_scan()'s type-1-like loop.
+ "has_zero_deg_reference": True,
"fov_fields": [
("fovx", "FOV x (µm)", 0.1, 200.0, 2),
("fovy", "FOV y (µm)", 0.1, 100.0, 2),
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
index f39de1bd..f5b2796e 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
@@ -20,6 +20,7 @@ from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
_compute_type1,
_compute_type1_omny,
_format_projections,
+ _irrelevant_params_for_type,
_lamni_compute_type1,
_requested_to_stepsize,
_requested_to_stepsize_omny,
@@ -119,11 +120,15 @@ def test_omny_field_order_matches_flomni_minus_omny_only_gaps():
]
-def test_omny_has_no_180_mode_single_point_or_zero_deg_reference():
+def test_omny_has_no_180_mode_or_single_point():
+ """has_zero_deg_reference IS True for OMNY (unlike 180-mode/single-point,
+ which genuinely don't exist here): zero_deg_reference_at_each_subtomo
+ applies to any equally spaced tomo type, not just type 1 -- see
+ omny.py's own property docstring and tomo_scan()'s type-1-like loop."""
profile = SETUP_PROFILES["omny"]
assert profile["has_180_mode"] is False
assert profile["has_single_point"] is False
- assert profile["has_zero_deg_reference"] is False
+ assert profile["has_zero_deg_reference"] is True
def test_compute_fermat_positions_omny_matches_scan_class_below_threshold():
@@ -182,6 +187,20 @@ def test_format_projections_still_distinguishes_flomni_and_lamni_jobs():
assert _format_projections(lamni_params) == str(lamni_N * 8)
+# --- _irrelevant_params_for_type(): regression guard for the type-4/5 gap --
+
+
+def test_irrelevant_params_for_type_keeps_zero_deg_reference_for_omny_types_4_and_5():
+ """_irrelevant_params_for_type() used to hardcode `tomo_type != 1`, which
+ would hide zero_deg_reference_at_each_subtomo from an OMNY type-4/5 job's
+ tooltip even though it's a real, applicable setting for those types too
+ (only flomni/lamni are limited to type 1) -- fixed to `not in (1, 4, 5)`."""
+ for tomo_type in (1, 4, 5):
+ assert "zero_deg_reference_at_each_subtomo" not in _irrelevant_params_for_type(tomo_type)
+ for tomo_type in (2, 3):
+ assert "zero_deg_reference_at_each_subtomo" in _irrelevant_params_for_type(tomo_type)
+
+
# --- _validate()'s tomo_type check: regression guard for the critical fix --
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
index 7627cd53..736a54b2 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
@@ -58,14 +58,17 @@ def make_omny(fake_client=None):
def test_tomo_scan_param_names_have_no_flomni_only_concepts():
"""OMNY mirrors flomni's names for shared concepts, but has no
- 180/360 toggle, no single-point acquisition, no zero-deg reference,
- and no frames_per_trigger -- none of these exist as OMNY properties."""
+ 180/360 toggle, no single-point acquisition, and no frames_per_trigger --
+ none of these exist as OMNY properties. zero_deg_reference_at_each_subtomo
+ is a deliberate exception: it applies to any equally spaced tomo type
+ (1/4/5 for OMNY, not just type 1), so unlike the others it *is* present --
+ see tomo_scan()'s type-1-like loop and the property's own docstring."""
names = OMNY._TOMO_SCAN_PARAM_NAMES
assert "tomo_angle_range" not in names
assert "single_point_instead_of_fermat_scan" not in names
assert "single_point_random_shift_max" not in names
- assert "zero_deg_reference_at_each_subtomo" not in names
assert "frames_per_trigger" not in names
+ assert "zero_deg_reference_at_each_subtomo" in names
# shared-with-flomni names are present, using OMNY's own property names
assert "fovx" in names
assert "stitch_x" in names
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
index 80a11719..fd3b7638 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
@@ -147,6 +147,67 @@ def test_tomo_scan_accepts_interactive_kwarg(monkeypatch):
assert omny.progress["tomo_start_time"] is not None
+def test_tomo_scan_zero_deg_reference_disabled_by_default_no_extra_shots(monkeypatch):
+ """zero_deg_reference_at_each_subtomo defaults to False -- no extra
+ _tomo_scan_at_angle(0, ...) calls beyond whatever sub_tomo_scan() does
+ on its own (stubbed to a no-op here, so none at all)."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ calls = []
+ omny._tomo_scan_at_angle = lambda angle, subtomo_number: calls.append((angle, subtomo_number))
+
+ omny.tomo_scan()
+
+ assert calls == []
+
+
+def test_tomo_scan_zero_deg_reference_type1_fires_before_subtomo1_and_after_subtomo2(monkeypatch):
+ """Type 1 (n_subtomos=2): a pre-shot before sub-tomogram 1 (even though
+ its own first projection already lands on 0 -- two consecutive 0-degree
+ shots right at the start is deliberate) and a final shot after
+ sub-tomogram 2, giving exactly one extra projection at the end plus one
+ at the very start for this type."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ omny.tomo_type = 1
+ omny.zero_deg_reference_at_each_subtomo = True
+ calls = []
+ omny._tomo_scan_at_angle = lambda angle, subtomo_number: calls.append((angle, subtomo_number))
+
+ omny.tomo_scan()
+
+ assert calls == [(0, 1), (0, 2)]
+
+
+def test_tomo_scan_zero_deg_reference_type5_fires_before_every_odd_subtomo(monkeypatch):
+ """Type 5 (n_subtomos=8): a pre-shot before every odd/forward
+ sub-tomogram (1, 3, 5, 7), plus the final shot after sub-tomogram 8."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ omny.tomo_type = 5
+ omny.zero_deg_reference_at_each_subtomo = True
+ calls = []
+ omny._tomo_scan_at_angle = lambda angle, subtomo_number: calls.append((angle, subtomo_number))
+
+ omny.tomo_scan()
+
+ assert calls == [(0, 1), (0, 3), (0, 5), (0, 7), (0, 8)]
+
+
+def test_tomo_scan_zero_deg_reference_skipped_only_for_the_resumed_subtomo(monkeypatch):
+ """Resuming mid-sub-tomogram (start_angle given explicitly) must skip the
+ pre-shot only for that specific sub-tomogram -- we aren't actually
+ passing through 0 deg at that moment -- but later odd sub-tomograms in
+ the same tomo_scan() call still get theirs, and the final shot is
+ unaffected either way."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ omny.tomo_type = 5
+ omny.zero_deg_reference_at_each_subtomo = True
+ calls = []
+ omny._tomo_scan_at_angle = lambda angle, subtomo_number: calls.append((angle, subtomo_number))
+
+ omny.tomo_scan(subtomo_start=3, start_angle=12.5)
+
+ assert calls == [(0, 5), (0, 7), (0, 8)]
+
+
def test_tomo_scan_clears_heartbeat_on_normal_completion(monkeypatch):
"""The busy-detector heartbeat must be cleared as soon as the scan
finishes, not left for the next poll to time out before the GUI stops
From 71eb9d858ffadce3f809931b4ac1a9d942aba2ba Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 16:50:46 +0200
Subject: [PATCH 042/102] feat(omny): port collect_empty_frames() from flomni
10 flat-field/empty-frame images at angle 0, sample shifted out of the
beam by fovx/2 + margin, taken once at the start of every new
tomo_scan() (not on resume) -- mirrors flomni's own collect_empty_frames()
structure and formula, using OMNY's own device names.
Took a few corrections to land on the right design: an initial staged
rtx+osamx move (based on a paraphrase of the real algorithm) was
discarded once Mirko surfaced the actual legacy SPEC macro, which does
a single position-threshold recenter instead. A second implementation
of that (RtOMNYController.move_samx_to_scan_region(), matching a name
already declared but unimplemented in USER_ACCESS) was itself
discarded on realizing OMNY already has this exact mechanism, working
and already wired into every real projection: dev.osamx
.omny_osamx_to_scan_center() (omny_fermat_scan.py's
prepare_setup_part2()). collect_empty_frames() reuses that directly.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 59 +++++++-
.../omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md | 12 ++
.../bec_ipython_client/plugins/omny/omny.py | 59 ++++++++
csaxs_bec/devices/omny/rt/rt_omny_ophyd.py | 8 ++
.../test_omny_tomo_scan.py | 131 ++++++++++++++++++
5 files changed, 268 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index ec4fca15..045162b4 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -1,4 +1,4 @@
-# OMNY open issues (as of 2026-09-03)
+# OMNY open issues (as of 2026-09-05)
Tracks the live-testing effort for bringing OMNY up to flomni's/BEC's current standards on
branch `update-omny`: bugs found (fixed and unfixed) and what's still untested. Written so a
@@ -705,6 +705,63 @@ and (2) whether "shift out of the beam" for OMNY means moving `osamx` directly (
movable coarse stage, confirmed) or just passing a deliberately offset `cenx` into
`omny_fermat_scan()`. Not guessed at -- next step is asking Mirko for these two specifics.
+## collect_empty_frames() ported to OMNY
+
+Mirko: flomni's `collect_empty_frames()` (10 flat-field/empty-frame images at angle 0, sample
+shifted out of the beam by fovx/2, taken once at the start of every new `tomo_scan()`) had no OMNY
+equivalent. Working out the right port took several rounds, each correcting the last:
+
+1. **First attempt (discarded)**: a bespoke "150 microns single-move, 300 combined" staged
+ rtx+osamx dance, based on Mirko's own paraphrase ("move rtx within 150 microns range, if more
+ range needed, move rtx to 150, then osamx 150..."). Confirmed via `AskUserQuestion` that rtx's
+ own reading stays put (doesn't reset) across the osamx move -- but this whole model turned out
+ to be an approximation of something else entirely (see below), and was never committed as final
+ -- superseded before being finished.
+2. **Second attempt (also discarded)**: while drafting, Mirko volunteered the *actual* legacy SPEC
+ macro for OMNY's projection scan, which recenters `osamx` with a single position-threshold
+ check (0.025mm/25um), not a staged multi-move dance:
+ ```
+ if (fabs(A[osamx]-(_omny_samx_in+$3/1000)) > 0.025) { ...move osamx to _omny_samx_in+$3/1000... }
+ ```
+ Cross-checking `RtOMNYController.USER_ACCESS` (`rt_omny_ophyd.py`) found `"move_samx_to_scan_
+ region"` declared there -- exactly flomni's own mechanism's name -- but **never actually
+ implemented** for OMNY (only `RtFlomniController.move_samx_to_scan_region()`, flomni's real
+ version, exists, and it's a PID-voltage-based iterative correction Mirko confirmed OMNY has no
+ equivalent readback for). Implemented a new `RtOMNYController.move_samx_to_scan_region()` from
+ the SPEC algorithm to fill this apparent gap.
+3. **Correction, before committing anything (the actual fix)**: Mirko recalled that in flomni this
+ recenter logic lives inside the *scan class itself* (`flomni_fermat_scan.py`), not the plugin,
+ and asked whether OMNY's own scan class should get the same treatment for consistency. Checking
+ `omny_fermat_scan.py`'s `prepare_setup_part2()` found **`self.dev.osamx.omny_osamx_to_scan_
+ center(self.cenx)` already there** -- a real, already-implemented, already-wired-into-every-
+ projection port of the exact same SPEC algorithm, just living on `OMNYGalilMotor`
+ (`ogalil_ophyd.py`) instead of `RtOMNYController`. The "gap" from step 2 didn't actually exist;
+ `move_samx_to_scan_region` (declared in `USER_ACCESS`, never implemented) was simply the wrong,
+ unused name to have gone looking under. **Removed** the step-2 addition entirely (replaced with
+ a comment pointing at the real mechanism, so nobody else goes down the same path) rather than
+ leave two parallel "recenter osamx" implementations that don't call each other.
+
+**Final implementation** (`omny.py`): `collect_empty_frames()`, structurally mirroring flomni's own
+(same 10-frame count, same `subtomo_number=0` logging convention, same
+"shift by fovx/2 + 20um margin, direction away from the normal alignment offset" formula) but using
+OMNY's own device names (`osamroy` not `fsamroy`) and reusing the *existing*
+`dev.osamx.omny_osamx_to_scan_center()` for the recenter step -- no new device-driver code needed
+in the end. Wired into `tomo_scan()`'s "a new tomo scan was started" branch (same placement as
+flomni's), so it fires once per fresh scan across all 5 tomo types, never on a resume.
+
+Regression tests: `test_tomo_scan_collects_empty_frames_on_new_scan_not_on_resume`,
+`test_collect_empty_frames_rotates_to_zero_when_not_already_there`,
+`test_collect_empty_frames_shifts_x_away_from_normal_offset_and_recenters_osamx`
+(`test_omny_tomo_scan.py`) -- the last one checks the exact shifted-x value, that
+`omny_osamx_to_scan_center()` is called with that same value, and that exactly one scan number
+gets logged with `subtomo_number=0`. `make_omny_for_tomo_scan()`'s shared fixture gained a
+`collect_empty_frames = lambda: None` stub (needed by every other `tomo_scan()` test once this
+was wired in). Full suite: 773 passed.
+
+**Not verified against real hardware** -- next step is Mirko trying a real `tomo_scan()` and
+confirming: the rotation-skip-if-already-at-0 branch, the direction/magnitude of the x shift, and
+that `omny_osamx_to_scan_center()`'s recenter fires (or correctly doesn't) as expected.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md
index 69de97aa..10757976 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md
@@ -56,6 +56,18 @@ the broader OMNY live-testing effort this is part of.
flomni/lamni only) -- toggle it for a type-4/5 job specifically and check the queued job's
tooltip shows it (regression guard for a hardcoded `!= 1` bug caught while wiring this up).
+## collect_empty_frames() (new, never tested on real hardware)
+
+- [ ] Start a fresh `tomo_scan()` (any tomo type) -- confirm it rotates to 0 (or prints "No
+ rotation required" if already there), shifts x away from the normal alignment offset by
+ fovx/2 + 20um, and successfully acquires 10 empty frames before the real angular grid starts.
+- [ ] Confirm `dev.osamx.omny_osamx_to_scan_center()`'s recenter fires when it should (osamx more
+ than 25um from where it needs to be for the shifted x) and is a no-op otherwise.
+- [ ] Confirm this does *not* run on `tomo_scan_resume()`/a mid-scan resume -- only on a genuinely
+ fresh scan.
+- [ ] Check `tomography_scannumbers.txt` gets one new line per empty frame, `subtomo_number=0`,
+ distinguishable from the real angular grid (subtomo_number 1+).
+
## Smaller fixes
- [ ] `tomography_scannumbers.txt` and the PDF report land under `~/data/raw/...` (not the old
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index 10ef4a1d..90136fed 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -1373,6 +1373,7 @@ class OMNY(
self.progress["estimated_finish_time"] = None
self.progress["accumulated_idle_time"] = 0.0
self.progress["heartbeat"] = None
+ self.collect_empty_frames()
try:
with scans.dataset_id_on_hold:
@@ -1784,6 +1785,64 @@ class OMNY(
f"{scan_number} {angle} {dev.osamroy.read()['osamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n"
)
+ def collect_empty_frames(self):
+ """Acquire 10 empty-frame (flat-field) images at angle 0, with osamx/rtx
+ shifted out of the beam by half the field of view (fovx/2), in the
+ direction opposite the normal alignment x-offset at angle 0.
+
+ Called once at the start of a new tomo_scan() (not on resume). Mirrors
+ flomni's collect_empty_frames() (same purpose, same 10-frame count,
+ same subtomo_number=0 logging convention), adapted for OMNY's own
+ device names: rtx is moved directly to the shifted-out x offset, then
+ dev.osamx.omny_osamx_to_scan_center() recenters osamx too if it has
+ drifted far enough from where it should sit for this offset -- the
+ same recenter check every real projection already goes through
+ (wired into omny_fermat_scan.py's prepare_setup_part2(), not
+ anything specific to this method), reused as-is rather than
+ duplicated.
+
+ These frames are deliberately not passed through tomo_reconstruct()
+ -- they are flat fields, not ptycho projections, and must not be
+ added to the reconstruction queue. Logged to
+ tomography_scannumbers.txt with subtomo_number=0 so they remain
+ traceable but are clearly distinguished from the angular grid.
+ """
+ scans = builtins.__dict__.get("scans")
+
+ angle = 0
+
+ # --- rotation ---
+ osamroy_current_setpoint = dev.osamroy.user_setpoint.get()
+ if angle != osamroy_current_setpoint:
+ umv(dev.osamroy, angle)
+ else:
+ print("No rotation required")
+
+ # --- alignment offset at angle 0, then push x out of the beam by
+ # fovx/2, in the direction opposite the normal offset ---
+ offsets = self.get_alignment_offset(angle)
+ normal_offset_x = offsets[0] - self.compute_additional_correction_x(angle)
+ normal_offset_y = (
+ offsets[1]
+ - self.compute_additional_correction_y(angle)
+ - self.compute_additional_correction_y_2(angle)
+ )
+ normal_offset_z = offsets[2]
+ direction = -1 if normal_offset_x >= 0 else 1
+ sum_offset_x = normal_offset_x + direction * (self.fovx / 2) + direction * 20
+
+ self.laser_tracker_on()
+ umv(dev.rtx, sum_offset_x, dev.rty, normal_offset_y, dev.rtz, normal_offset_z)
+ self.laser_tracker_check_and_wait_for_signalstrength()
+ dev.osamx.omny_osamx_to_scan_center(sum_offset_x)
+
+ print("Acquiring 10 empty frames at angle 0, samx/rtx shifted out of the beam.")
+ start_scan_number = bec.queue.next_scan_number
+ scans.acquire(exp_time=self.tomo_countingtime, frames_per_trigger=10)
+ end_scan_number = bec.queue.next_scan_number
+ for scan_nr in range(start_scan_number, end_scan_number):
+ self._write_tomo_scan_number(scan_nr, angle, subtomo_number=0)
+
def tomo_scan_projection(self, angle: float):
scans = builtins.__dict__.get("scans")
diff --git a/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py b/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
index c9cfba05..28c35110 100644
--- a/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
+++ b/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py
@@ -680,6 +680,14 @@ class RtOMNYController(Controller):
print("Failed to reach laser target position.")
raise RtError("Failed to reach laser target position.")
+ # move_samx_to_scan_region is declared in USER_ACCESS above but was never
+ # implemented here -- checked while wiring up OMNY.collect_empty_frames()
+ # (2026-09-05): the real, already-working equivalent for OMNY is
+ # OMNYGalilMotor.omny_osamx_to_scan_center() (ogalil_ophyd.py), called
+ # from omny_fermat_scan.py's prepare_setup_part2() on every projection
+ # -- ported from the same original SPEC macro this docstring would have
+ # quoted, already wired into regular scanning. Not duplicating it here.
+
def laser_tracker_print_intensity_for_otrack_tweaking(self):
# self.laser_update_tracker_info()
# _laser_tracker_intensity = self.tracker_info["tracker_intensity"]
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
index fd3b7638..99a1791f 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
@@ -71,6 +71,7 @@ def make_omny_for_tomo_scan(monkeypatch, tomo_angle_stepsize: float, active_acco
obj._current_special_angles = []
obj._at_each_angle_hooks = {} # normally set by _init_tomo_queue()
obj.write_pdf_report = lambda: None
+ obj.collect_empty_frames = lambda: None
obj.sub_tomo_scan = lambda subtomo_number, n_subtomos=2, start_angle=None: None
obj.omnygui_show_progress = lambda: None
obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
@@ -122,6 +123,24 @@ def test_tomo_scan_registers_sample_with_plain_string_account(monkeypatch):
assert omny.tomo_id == 42
+def test_tomo_scan_collects_empty_frames_on_new_scan_not_on_resume(monkeypatch):
+ """collect_empty_frames() (flat-field acquisition at angle 0, sample
+ shifted out of the beam) belongs to starting a fresh tomo_scan(), not to
+ resuming an interrupted one -- mirrors flomni's own placement, inside
+ the same "a new tomo scan was started" branch as the sample-database
+ registration and progress-field reset just above it."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ calls = []
+ omny.collect_empty_frames = lambda: calls.append("collected")
+
+ omny.tomo_scan()
+ assert calls == ["collected"]
+
+ calls.clear()
+ omny.tomo_scan(subtomo_start=2, start_angle=90.0)
+ assert calls == []
+
+
def test_tomo_scan_shows_progress_gui(monkeypatch):
"""tomo_scan() must open/show the progress GUI unconditionally at the
start of every scan -- previously only shown per-angle inside
@@ -460,3 +479,115 @@ def test_write_tomo_scan_number_creates_missing_directory(monkeypatch, tmp_path)
written = (tmp_path / "data" / "raw" / "logs" / "tomography_scannumbers.txt").read_text()
assert written == "844 12.5 12.500 1 1 0 test_sample\n"
+
+
+# --- collect_empty_frames(): flat-field acquisition at scan start, ported
+# from flomni's own collect_empty_frames() for OMNY's device names and its
+# already-existing osamx recenter (dev.osamx.omny_osamx_to_scan_center(),
+# the same one every real projection already goes through via
+# omny_fermat_scan.py's prepare_setup_part2() -- not anything bespoke). ----
+
+
+def _make_omny_for_collect_empty_frames(monkeypatch, osamroy_setpoint=45.0):
+ """Bare OMNY instance with collect_empty_frames()'s dependencies stubbed:
+ a fake device tree (osamroy/rtx/rty/rtz/osamx), umv(), alignment-offset
+ math, and the laser-tracker/recenter calls -- everything real hardware
+ control, nothing scan-content-computation, so the test only checks the
+ sequence/values collect_empty_frames() itself is responsible for."""
+ calls = []
+
+ fake_osamx = types.SimpleNamespace(
+ omny_osamx_to_scan_center=lambda cenx: calls.append(("recenter", cenx))
+ )
+ fake_dev = types.SimpleNamespace(
+ osamroy=types.SimpleNamespace(
+ user_setpoint=types.SimpleNamespace(get=lambda: osamroy_setpoint),
+ read=lambda: {"osamroy": {"value": 0.0}},
+ ),
+ rtx=object(),
+ rty=object(),
+ rtz=object(),
+ osamx=fake_osamx,
+ )
+ monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
+ monkeypatch.setattr(
+ omny_module, "umv", lambda *args: calls.append(("umv", args)), raising=False
+ )
+
+ class _FakeQueue:
+ def __init__(self):
+ self.next_scan_number = 100
+
+ fake_bec = types.SimpleNamespace(queue=_FakeQueue())
+ monkeypatch.setattr(omny_module, "bec", fake_bec, raising=False)
+
+ def _fake_acquire(**kwargs):
+ calls.append(("acquire", kwargs))
+ fake_bec.queue.next_scan_number += 1
+
+ fake_scans = types.SimpleNamespace(acquire=_fake_acquire)
+ monkeypatch.setitem(builtins.__dict__, "scans", fake_scans)
+
+ omny = object.__new__(OMNY)
+ omny.client = FakeGlobalVarClient()
+ omny.tomo_id = 1
+ omny.tomo_countingtime = 0.2
+ omny.fovx = 40.0
+ omny.get_alignment_offset = lambda angle: (5.0, 1.0, 0.0)
+ omny.compute_additional_correction_x = lambda angle: 0.0
+ omny.compute_additional_correction_y = lambda angle: 0.0
+ omny.compute_additional_correction_y_2 = lambda angle: 0.0
+ omny.laser_tracker_on = lambda: calls.append(("tracker_on",))
+ omny.laser_tracker_check_and_wait_for_signalstrength = lambda: calls.append(("tracker_check",))
+ written_scan_numbers = []
+ omny._write_tomo_scan_number = (
+ lambda scan_nr, angle, subtomo_number: written_scan_numbers.append(
+ (scan_nr, angle, subtomo_number)
+ )
+ )
+ return omny, calls, written_scan_numbers
+
+
+def test_collect_empty_frames_rotates_to_zero_when_not_already_there(monkeypatch):
+ omny, calls, _ = _make_omny_for_collect_empty_frames(monkeypatch, osamroy_setpoint=45.0)
+
+ omny.collect_empty_frames()
+
+ rotation_calls = [c for c in calls if c[0] == "umv" and c[1] == (omny_module.dev.osamroy, 0)]
+ assert len(rotation_calls) == 1
+
+
+def test_collect_empty_frames_shifts_x_away_from_normal_offset_and_recenters_osamx(monkeypatch):
+ """direction is chosen to move AWAY from the normal alignment offset at
+ angle 0 (normal_offset_x=5.0 here -- positive, so direction=-1), by
+ fovx/2 + a 20um margin, matching flomni's own formula exactly. osamx
+ recentering reuses the real dev.osamx.omny_osamx_to_scan_center(), not a
+ bespoke mechanism -- called with that same shifted x value."""
+ omny, calls, written_scan_numbers = _make_omny_for_collect_empty_frames(
+ monkeypatch, osamroy_setpoint=0.0
+ )
+
+ omny.collect_empty_frames()
+
+ # no rotation umv call: already at angle 0
+ assert [c for c in calls if c[0] == "umv" and len(c[1]) == 1] == []
+
+ expected_offset_x = (
+ 5.0 + (-1) * (40.0 / 2) + (-1) * 20
+ ) # normal_offset_x + direction*(fovx/2+20)
+ rtx_moves = [c for c in calls if c[0] == "umv" and len(c[1]) > 1]
+ assert len(rtx_moves) == 1
+ assert rtx_moves[0][1][1] == expected_offset_x # umv(dev.rtx, , dev.rty, , dev.rtz, )
+ assert rtx_moves[0][1][3] == 1.0 # normal_offset_y, unshifted
+
+ assert ("recenter", expected_offset_x) in calls
+ assert ("tracker_on",) in calls
+ assert ("tracker_check",) in calls
+
+ acquire_calls = [c for c in calls if c[0] == "acquire"]
+ assert acquire_calls == [("acquire", {"exp_time": 0.2, "frames_per_trigger": 10})]
+
+ # one acquire call bumped next_scan_number by 1 (100 -> 101) -- exactly
+ # one scan number logged, with subtomo_number=0 (flat field, not part
+ # of the angular grid)
+ assert written_scan_numbers == [(100, 0, 0)]
From bdb0d25f90db4203d497a2c6b0bd025a6f5a89b3 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 17:01:00 +0200
Subject: [PATCH 043/102] fix(omny): reset progress fields before zero-degree
reference shots
progress["projection"]/["subtomo_projection"]/["subtomo_total_projections"]/
["total_projections"] are otherwise only ever set inside sub_tomo_scan()'s
per-point loop. The zero_deg_reference_at_each_subtomo pre/post shots call
_tomo_scan_at_angle() directly, bypassing that -- so the printed progress
showed whatever was left over from a previous scan/subtomo instead of a
value that made sense for the shot. Confirmed live: a fresh scan's first
reference shot printed a stale "Projection: 9 / Total: 32" from an earlier
run. Same bug exists in flomni.py (this was ported from there faithfully,
bug included) -- flagged in OPEN_ISSUES.md but not touched, out of this
branch's scope.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 32 +++++++++++
.../bec_ipython_client/plugins/omny/omny.py | 27 +++++++++-
.../test_omny_tomo_scan.py | 54 +++++++++++++++++++
3 files changed, 112 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 045162b4..7d69baa6 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -762,6 +762,38 @@ was wired in). Full suite: 773 passed.
confirming: the rotation-skip-if-already-at-0 branch, the direction/magnitude of the x shift, and
that `omny_osamx_to_scan_center()`'s recenter fires (or correctly doesn't) as expected.
+## zero_deg_reference_at_each_subtomo's progress fields were stale (fixed for OMNY; same bug likely in flomni, not touched)
+
+Mirko, live-testing the just-landed feature: "the first zero degree projection does not seem to
+reset the projection number. not sure if it is correctly done in the case of flomni" -- and a
+pasted transcript showing the very first (pre-subtomo-1) reference shot printing "Projection: 9 /
+Total projections expected: 32" (stale leftover from an earlier run), followed moments later by
+subtomo 1's own real first projection correctly printing "Projection: 0 / Total: 10".
+
+Root cause: `progress["projection"]`/`["subtomo_projection"]`/`["subtomo_total_projections"]`/
+`["total_projections"]` are otherwise only ever set inside `sub_tomo_scan()`'s per-point loop. The
+0-degree reference shots (both the pre-subtomo-N and the final post-loop one) call
+`_tomo_scan_at_angle()` directly, bypassing `sub_tomo_scan()` entirely -- so whatever those fields
+happened to hold from a previous scan (or the previous subtomo) is what gets printed, not a value
+that makes sense for this shot.
+
+**Fixed for OMNY**: both call sites in `tomo_scan()`'s type-1-like loop now explicitly set all
+four fields immediately before calling `_tomo_scan_at_angle(0, ...)` -- the pre-shot is treated as
+sub-tomogram `ii`'s own projection 0 (matching what `sub_tomo_scan()` is about to set for its real
+first point right after), the final shot as one past the last real point of the final
+sub-tomogram. Regression test:
+`test_tomo_scan_zero_deg_reference_progress_fields_are_fresh_not_stale` (seeds `progress` with
+stale values matching the live report, asserts both shots' snapshots are fully fresh/correct, not
+just non-crashing).
+
+**Same bug confirmed present in flomni's own code** (`flomni.py`'s equally-spaced-sub-tomograms
+loop, `_tomo_scan_at_angle(0, ii)`/`_tomo_scan_at_angle(0, 8)` calls, same as OMNY's before this
+fix -- flomni's `sub_tomo_scan()` is likewise the only place that sets these fields) -- this is
+where OMNY's version was ported from, so it inherited the bug faithfully. **Not fixed here**:
+`flomni.py` is a different, currently-in-production plugin outside this branch's scope
+(`update-omny`); flagging for a separate, dedicated look rather than bundling an unreviewed
+flomni-affecting change into this branch.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index 90136fed..d07a2ea1 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -1380,6 +1380,7 @@ class OMNY(
if self.tomo_type in (1, 4, 5):
# equally spaced sub-tomograms (2, 4, or 8)
n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type]
+ n_points = int(180.0 / self.tomo_angle_stepsize)
self.progress["tomo_type"] = f"Equally spaced sub-tomograms ({n_subtomos})"
for ii in range(subtomo_start, n_subtomos + 1):
if (
@@ -1399,6 +1400,24 @@ class OMNY(
# redundant. Skipped when resuming mid-sub-tomogram
# (start_angle given explicitly) since we're not
# actually passing through 0 deg at that moment.
+ #
+ # progress["projection"]/["subtomo_projection"]/etc.
+ # are otherwise only ever set inside sub_tomo_scan()
+ # -- set them explicitly here too, or the printed
+ # progress for this shot shows whatever was left
+ # over from the previous scan/subtomo instead of a
+ # value that makes sense for it (confirmed live: a
+ # stale "Projection: 9 / Total: 32" from a prior
+ # run, on the very first projection of a fresh
+ # scan). Treated as sub-tomogram ii's own
+ # projection 0, matching what sub_tomo_scan() is
+ # about to set for its real first point right
+ # after this.
+ self.progress["subtomo"] = ii
+ self.progress["subtomo_projection"] = 0
+ self.progress["subtomo_total_projections"] = n_points
+ self.progress["projection"] = (ii - 1) * n_points
+ self.progress["total_projections"] = n_points * n_subtomos
self._tomo_scan_at_angle(0, ii)
self.sub_tomo_scan(ii, n_subtomos=n_subtomos, start_angle=start_angle)
start_angle = None
@@ -1407,7 +1426,13 @@ class OMNY(
# Final reference shot at exactly 0 degrees once the whole
# tomogram is complete, giving a clean "before vs after"
# pair for radiation-damage comparison across the full
- # acquisition.
+ # acquisition. One past the last real point of the final
+ # sub-tomogram, same progress-freshness reasoning as above.
+ self.progress["subtomo"] = n_subtomos
+ self.progress["subtomo_projection"] = n_points
+ self.progress["subtomo_total_projections"] = n_points
+ self.progress["projection"] = n_points * n_subtomos
+ self.progress["total_projections"] = n_points * n_subtomos
self._tomo_scan_at_angle(0, n_subtomos)
elif self.tomo_type == 2:
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
index 99a1791f..4be38cca 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
@@ -196,6 +196,60 @@ def test_tomo_scan_zero_deg_reference_type1_fires_before_subtomo1_and_after_subt
assert calls == [(0, 1), (0, 2)]
+def test_tomo_scan_zero_deg_reference_progress_fields_are_fresh_not_stale(monkeypatch):
+ """progress["projection"]/["total_projections"]/["subtomo_projection"]/
+ ["subtomo_total_projections"] are otherwise only ever set inside
+ sub_tomo_scan() -- the 0-degree reference shots bypass that, calling
+ _tomo_scan_at_angle() directly, so without an explicit reset they show
+ whatever was left over from a previous scan/subtomo instead of a value
+ that makes sense for this shot. Confirmed live: a fresh scan's very
+ first (pre-subtomo-1) reference shot printed a stale "Projection: 9 /
+ Total projections expected: 32" from an earlier run."""
+ omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ omny.tomo_type = 1
+ omny.zero_deg_reference_at_each_subtomo = True
+ # simulate stale leftover progress from an unrelated previous scan
+ omny.progress["projection"] = 9
+ omny.progress["total_projections"] = 32
+ omny.progress["subtomo_projection"] = 9
+ omny.progress["subtomo_total_projections"] = 16
+
+ fields = (
+ "subtomo",
+ "subtomo_projection",
+ "subtomo_total_projections",
+ "projection",
+ "total_projections",
+ )
+ snapshots = []
+
+ def _fake_at_angle(angle, subtomo_number):
+ snapshots.append({key: omny.progress[key] for key in fields})
+
+ omny._tomo_scan_at_angle = _fake_at_angle
+
+ omny.tomo_scan()
+
+ # n_points = int(180/45) = 4, n_subtomos = 2 -> total = 8
+ pre_shot = snapshots[0] # before sub-tomogram 1
+ assert pre_shot == {
+ "subtomo": 1,
+ "subtomo_projection": 0,
+ "subtomo_total_projections": 4,
+ "projection": 0,
+ "total_projections": 8,
+ }
+
+ final_shot = snapshots[-1] # after sub-tomogram 2
+ assert final_shot == {
+ "subtomo": 2,
+ "subtomo_projection": 4,
+ "subtomo_total_projections": 4,
+ "projection": 8,
+ "total_projections": 8,
+ }
+
+
def test_tomo_scan_zero_deg_reference_type5_fires_before_every_odd_subtomo(monkeypatch):
"""Type 5 (n_subtomos=8): a pre-shot before every odd/forward
sub-tomogram (1, 3, 5, 7), plus the final shot after sub-tomogram 8."""
From 3ffcdf51289e58888feddb219cbad28bea6da131 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 19:51:58 +0200
Subject: [PATCH 044/102] fix(omny): parking overwrite guard, active-slot leica
targets, shuttle-scoped print; add measured-status+account encoding and
P-touch label printing
- Widget: reject assigning a shuttle to a parking slot already held by a
different shuttle ("Slot in use", clear it first) instead of silently
overwriting.
- Widget: PDF report now shows only the 3 shuttles + their samples (title
"OMNY Shuttle Samples") -- stage/gripper/fixed-O positions can't
physically leave the vacuum chamber, so a printed report has nothing
useful to say about them. The on-screen widget still shows/manages all
of it.
- Widget: fixed the active-parking-slot badge never showing for a
loadlock-driven park. Root cause: otransfer_park_loadlock_slot() drives
oparkz to a different target-position table (base -6, "leica") than the
regular otransfer_park_slot() (base -0.9); the widget only recognized
the regular one. Now checks both.
- OMNYSampleStorage: measured status now packs status *and* the beamtime
account into one VAL -- 0 = not measured, negative 5-digit = started,
positive 5-digit = completed, falling back to FALLBACK_ACCOUNT (11111)
for anything that doesn't parse as exactly 5 digits. No IOC schema
change needed (same ai record). New get_sample_account()(+gripper/
samplestage variants); set_sample_status() now takes an account param;
show_all()/help() updated. otransfer_* CLI wrappers updated to match.
- Widget: new P-touch label-print offer, but only when clearing a
*shuttle* slot (not fixed-O/stage/gripper -- those are bookkeeping
corrections, not physical sample removal, since only shuttle samples
leave the vacuum chamber). Uses a separate PTouchLabelPrinter instance
at template 3 (not yet bench-tested against a real printer, unlike
FlOMNI's confirmed-working template 1), same field names as FlOMNI.
- AI_docs/OPEN_ISSUES.md: noted that the measured-status+account encoding
should be ported to FlOMNI at some point (separate task, not this
branch's scope -- FlOMNI's own status tracking is untouched, still the
redis-based _MeasuredSampleLog).
- New tests: encode/decode round-trips + fallback behavior for the
status+account VAL scheme, plus oparkz leica-target coverage.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 20 ++
.../omny/omny_sample_transfer_mixin.py | 21 +-
.../widgets/sample_storage/sample_storage.py | 217 ++++++++++++++----
csaxs_bec/devices/omny/omny_sample_storage.py | 155 +++++++++----
.../test_omny_sample_storage_widget_math.py | 20 ++
.../tests_devices/test_omny_sample_storage.py | 47 ++++
6 files changed, 379 insertions(+), 101 deletions(-)
create mode 100644 tests/tests_devices/test_omny_sample_storage.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 7d69baa6..4d161436 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -794,6 +794,26 @@ where OMNY's version was ported from, so it inherited the bug faithfully. **Not
(`update-omny`); flagging for a separate, dedicated look rather than bundling an unreviewed
flomni-affecting change into this branch.
+## Measured-status + account tracking now real EPICS for OMNY -- port to flomni later
+
+OMNY sample storage gained a real EPICS channel for measured status and the beamtime account
+(`OMNYSampleStorage`'s new `sample_status`/`sample_shuttle_{A,B,C}_status` signals, one new `ai`
+record per sample position -- see `omny_samples_status.template` in `OMNY/OMNY.git` and
+`OMNY/ioc-samples-and-xrayeye`). Encoding: VAL packs *both* status and account into one number --
+`0` means not measured; otherwise VAL's magnitude is the 5-digit account and its sign is the
+status (negative = "started", positive = "completed"), falling back to `FALLBACK_ACCOUNT` (11111)
+for anything that doesn't parse as exactly 5 digits. See `OMNYSampleStorage.STATUS_NONE`'s class
+comment and `_status_to_val()`/`_val_to_status()`/`_val_to_account()`/`_normalize_account()` in
+`csaxs_bec/devices/omny/omny_sample_storage.py`.
+
+This deliberately mirrors, but does not replace, FlOMNI's own measured-status tracking, which
+still lives entirely in a BEC redis global var (`_MeasuredSampleLog`/`flomni_measured_samples` in
+`sample_storage.py`/`flomni.py`), keyed by `pack_desc(name, owner)` rather than a fixed physical
+position -- FlOMNI wasn't touched this branch (explicitly out of scope, see the sample-storage
+work above). **Mirko's intent: port the same real-EPICS status+account channel to FlOMNI at some
+point** -- a separate, dedicated task (its own `.template` record + `FlomniSampleStorage` changes
++ retiring/reconciling `_MeasuredSampleLog`), not bundled into this branch.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 90a3a0f3..58b3b093 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -1170,30 +1170,39 @@ class OMNYSampleTransferMixin:
def otransfer_get_sample_name_and_owner(self, container: str, slot_nr: int) -> tuple:
return dev.omny_samples.get_sample_name_and_owner(container, slot_nr)
- def otransfer_set_sample_status(self, container: str, slot_nr: int, status: str) -> bool:
- return dev.omny_samples.set_sample_status(container, slot_nr, status)
+ def otransfer_set_sample_status(self, container: str, slot_nr: int, status: str, account=None) -> bool:
+ return dev.omny_samples.set_sample_status(container, slot_nr, status, account)
def otransfer_get_sample_status(self, container: str, slot_nr: int) -> str:
return dev.omny_samples.get_sample_status(container, slot_nr)
+ def otransfer_get_sample_account(self, container: str, slot_nr: int):
+ return dev.omny_samples.get_sample_account(container, slot_nr)
+
def otransfer_get_sample_owner_in_gripper(self) -> str:
return dev.omny_samples.get_sample_owner_in_gripper()
def otransfer_get_sample_owner_in_samplestage(self) -> str:
return dev.omny_samples.get_sample_owner_in_samplestage()
- def otransfer_set_sample_status_in_gripper(self, status: str) -> bool:
- return dev.omny_samples.set_sample_status_in_gripper(status)
+ def otransfer_set_sample_status_in_gripper(self, status: str, account=None) -> bool:
+ return dev.omny_samples.set_sample_status_in_gripper(status, account)
def otransfer_get_sample_status_in_gripper(self) -> str:
return dev.omny_samples.get_sample_status_in_gripper()
- def otransfer_set_sample_status_in_samplestage(self, status: str) -> bool:
- return dev.omny_samples.set_sample_status_in_samplestage(status)
+ def otransfer_get_sample_account_in_gripper(self):
+ return dev.omny_samples.get_sample_account_in_gripper()
+
+ def otransfer_set_sample_status_in_samplestage(self, status: str, account=None) -> bool:
+ return dev.omny_samples.set_sample_status_in_samplestage(status, account)
def otransfer_get_sample_status_in_samplestage(self) -> str:
return dev.omny_samples.get_sample_status_in_samplestage()
+ def otransfer_get_sample_account_in_samplestage(self):
+ return dev.omny_samples.get_sample_account_in_samplestage()
+
# def _otransfer_modify_sample(_ocontainer, _oposition, _ostatus, _oname):
# #_ocontainer is A, B, C for the pin containers
# #it can also be FZP, OSA and omny
diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
index 3d1c1d12..64f5e99c 100644
--- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
+++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
@@ -24,11 +24,11 @@ OMNY (``dev.omny_samples``)
recording which shuttle letter is docked at each physical parking
position (parking slots are a shuttle-letter assignment, not a sample,
so they carry no name/owner/status). Unlike FlOMNI, OMNY has a
- dedicated owner channel (and a measured-status channel -- see
- ``OMNY_SampleStorage.STATUS_NONE``/``STATUS_STARTED``/
- ``STATUS_COMPLETED``) next to each sample slot's existing placed/name
- channels, so no packing is needed there. Whichever shuttle's parking
- slot matches the ``oparkz`` motor's current readback is "active", i.e.
+ dedicated owner channel and a measured-status channel (packing status
+ *and* account into one VAL -- see ``OMNY_SampleStorage.STATUS_NONE``)
+ next to each sample slot's existing placed/name channels, so no
+ packing is needed there. Whichever shuttle's parking slot matches the
+ ``oparkz`` motor's current readback is "active", i.e.
at the transfer-ready position (see ``oparkz_active_parking_slot()``).
A LamNI session has neither device; the widget shows a "no known setup
@@ -157,10 +157,13 @@ COLOR_SLOT_BORDER = "#c0c0c0"
# already-transferred template (confirmed working) -- NOT the generic
# Text1/Text2/Text6 names that OMNY_shared/flomni.lbx's own objectName
# attributes happen to use for its (different) template source file.
-# FlOMNI-only: OMNY has no measured-sample-log integration (see module
-# docstring), so it never drives this printer.
+# Same printer, same field names, but a *different* template number is
+# pre-loaded on the printer for the OMNY label -- not yet bench-tested
+# against a real printer the way FlOMNI's template 1 has been, so the
+# field names are an assumption for now, not confirmed working.
PTOUCH_PRINTER_HOST = "BRN94DDF8AAB8EC.psi.ch"
PTOUCH_TEMPLATE_NUMBER = 1
+PTOUCH_TEMPLATE_NUMBER_OMNY = 3
PTOUCH_FIELD_ACCOUNT = "ACCOUNT"
PTOUCH_FIELD_DATE = "DATE"
PTOUCH_FIELD_SAMPLENAME = "SAMPLENAME"
@@ -197,20 +200,38 @@ _OPARKZ_TARGET_POSITIONS: dict[int, float] = {
5: -0.9 - 29,
6: -0.9,
}
+# otransfer_park_loadlock_slot()'s targets (_oparkz_leica_slot() in the same
+# mixin) -- the *same* slot numbers, reached via the loadlock/leica transfer
+# path instead of the regular gripper path, at a different oparkz offset
+# (base -6, not -0.9). Both genuinely mean "the parking stage is at that
+# slot" -- "active" must recognize either, or a loadlock-driven park (like
+# otransfer_park_loadlock_slot()) never shows as active in the GUI even
+# though it physically is.
+_OPARKZ_LEICA_TARGET_POSITIONS: dict[int, float] = {
+ 2: -6 - 29 * 3 - 42 - 0.05,
+ 3: -6 - 29 * 3,
+ 4: -6 - 29 * 2,
+ 5: -6 - 29,
+ 6: -6,
+}
_OPARKZ_TOLERANCE = 0.05
def oparkz_active_parking_slot(readback: float, tol: float = _OPARKZ_TOLERANCE) -> Optional[int]:
"""Return which parking slot (2-6) the oparkz motor is currently sitting
at, i.e. ready for a sample transfer, or None if it's between positions
- (or at slot 1, which has no drivable transfer target). Pure function --
- see the module-level docstring above ``_OPARKZ_TARGET_POSITIONS`` for
- why this duplicates rather than imports omny_sample_transfer_mixin.py's
- ``_oparkz_slot_check()``.
+ (or at slot 1, which has no drivable transfer target). Checks both the
+ regular-gripper and loadlock/leica target tables (see
+ ``_OPARKZ_LEICA_TARGET_POSITIONS``) since either represents the parking
+ stage genuinely being at that slot. Pure function -- see the
+ module-level docstring above ``_OPARKZ_TARGET_POSITIONS`` for why this
+ duplicates rather than imports omny_sample_transfer_mixin.py's
+ ``_oparkz_slot_check()``/``_oparkz_leica_slot()``.
"""
- for slot, target in _OPARKZ_TARGET_POSITIONS.items():
- if abs(readback - target) < tol:
- return slot
+ for targets in (_OPARKZ_TARGET_POSITIONS, _OPARKZ_LEICA_TARGET_POSITIONS):
+ for slot, target in targets.items():
+ if abs(readback - target) < tol:
+ return slot
return None
@@ -504,6 +525,9 @@ class OMNY_SampleStorage(BECWidget, QWidget):
self.label_printer = PTouchLabelPrinter(
host=PTOUCH_PRINTER_HOST, template_number=PTOUCH_TEMPLATE_NUMBER
)
+ self.label_printer_omny = PTouchLabelPrinter(
+ host=PTOUCH_PRINTER_HOST, template_number=PTOUCH_TEMPLATE_NUMBER_OMNY
+ )
self._setup = self._detect_setup()
self._build_ui()
self._poll_timer = QTimer(self)
@@ -634,9 +658,9 @@ class OMNY_SampleStorage(BECWidget, QWidget):
sample-kind slot -- stage, gripper, the 10 fixed O positions, and
the 18 shuttle slots. Owner and measured status each have their own
dedicated OMNYSampleStorage signal (added alongside the existing
- placed/name signals -- see that class's ``STATUS_NONE``/
- ``STATUS_STARTED``/``STATUS_COMPLETED``), unlike FlOMNI which packs
- name+owner into one field via ``sample_desc_codec``.
+ placed/name signals -- see that class's status+account VAL
+ encoding, ``STATUS_NONE``), unlike FlOMNI which packs name+owner
+ into one field via ``sample_desc_codec``.
"""
def _val(key, default=None):
@@ -646,13 +670,19 @@ class OMNY_SampleStorage(BECWidget, QWidget):
return default
def _status_str(raw) -> Optional[str]:
- # mirrors OMNYSampleStorage._status_val_to_str(), without a live
+ # mirrors OMNYSampleStorage._val_to_status(), without a live
# device round-trip: this widget only has the bulk-read dict.
+ # VAL packs status+account (see that class's status+account VAL
+ # encoding comment): 0 -> no badge, negative -> "started",
+ # positive -> "completed" (the magnitude is the account, not
+ # needed here -- see _status_account() for that).
try:
val = int(round(float(raw)))
except (TypeError, ValueError):
return None
- return {1: "started", 2: "completed"}.get(val) # 0 ("none") -> None: no badge
+ if val == 0:
+ return None
+ return "started" if val < 0 else "completed"
state: dict[tuple, tuple[bool, str, str, Optional[str]]] = {}
@@ -788,6 +818,74 @@ class OMNY_SampleStorage(BECWidget, QWidget):
if reply != QMessageBox.StandardButton.Retry:
break
+ def _offer_print_label_for_previous_occupant_omny(self, key: tuple) -> None:
+ """If the shuttle slot about to be cleared has a measured status
+ (started or completed), offer to print an account/date/name label
+ for it -- OMNY's counterpart to
+ ``_offer_print_label_for_previous_occupant()``, but only ever
+ called for shuttle-slot clears (see ``_write_slot_omny``): unlike
+ the fixed-O/stage/gripper positions, a shuttle's samples are the
+ only ones that can physically leave the vacuum chamber, so
+ printing a label only makes sense there.
+
+ Uses ``self.label_printer_omny`` (``PTOUCH_TEMPLATE_NUMBER_OMNY``),
+ a different template number than FlOMNI's -- same field names
+ (ACCOUNT/DATE/SAMPLENAME) by assumption, not yet bench-tested
+ against a real printer the way FlOMNI's template 1 is.
+
+ OMNY has no FlOMNI-style measured-sample log to pop a record from
+ -- status and account already live directly on the slot itself
+ (see ``OMNYSampleStorage``'s status+account VAL encoding), so this
+ reads them fresh from the device right before the clear, instead.
+ There's also no stored measurement date (OMNY doesn't track one),
+ so the label's DATE field is simply today's date, at print time.
+ """
+ assert key[0] == "shuttle"
+ _, letter, n = key
+ try:
+ was_used = self._samples.is_sample_slot_used(letter, n)
+ prev_name = self._samples.get_sample_name(letter, n)
+ status = self._samples.get_sample_status(letter, n)
+ account = self._samples.get_sample_account(letter, n)
+ except Exception as exc:
+ logger.warning(
+ f"OMNY_SampleStorage: could not read previous state of slot {key} for the"
+ f" measured-label check: {exc}"
+ )
+ return
+ if not was_used or status == "none":
+ return
+ reply = QMessageBox.question(
+ self,
+ "Print label?",
+ f"Sample '{prev_name}' was measured ({status}), account {account}. Print a label?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ )
+ if reply != QMessageBox.StandardButton.Yes:
+ return
+ fields = {
+ PTOUCH_FIELD_ACCOUNT: str(account),
+ PTOUCH_FIELD_DATE: datetime.date.today().strftime("%Y-%m-%d"),
+ PTOUCH_FIELD_SAMPLENAME: prev_name,
+ }
+ # Retry loop, same reasoning as _offer_print_label_for_previous_occupant():
+ # the slot is about to be cleared regardless of whether printing
+ # succeeds, so give the operator a real chance to fix the printer
+ # and retry rather than losing the label silently.
+ while not self.label_printer_omny.print_label(fields):
+ reply = QMessageBox.question(
+ self,
+ "Print failed",
+ f"Could not print label for '{prev_name}' — printer unreachable.\n\n"
+ "Power it on / check it has tape loaded, then Retry, or Ignore to"
+ " give up on printing this label.\n\nThe slot will be updated"
+ " regardless, once you close this dialog.",
+ QMessageBox.StandardButton.Retry | QMessageBox.StandardButton.Ignore,
+ QMessageBox.StandardButton.Retry,
+ )
+ if reply != QMessageBox.StandardButton.Retry:
+ break
+
def _write_slot(self, slot: int, used: int, name: str, owner: str = "") -> bool:
"""
Write (used, name, owner) to a FlOMNI storage slot 0–20 or the
@@ -826,13 +924,21 @@ class OMNY_SampleStorage(BECWidget, QWidget):
no packing needed. Measured status is reset to "none" on every
write here, mirroring ``OMNYSampleStorage.set_sample_slot()``'s/
``unset_sample_slot()``'s own behavior -- a fresh name (even a
- rename) or a cleared slot isn't mid-measurement. No P-touch
- label-printing offer for the previous occupant (FlOMNI-only, see
- module docstring).
+ rename) or a cleared slot isn't mid-measurement.
+
+ Offers a P-touch label print for the previous occupant only when
+ *clearing* a *shuttle* slot (see
+ ``_offer_print_label_for_previous_occupant_omny``) -- shuttles are
+ the only OMNY samples that can physically leave the vacuum
+ chamber, so that's the only "sample remove" this widget can
+ meaningfully print a label for; every other clear (fixed-O/stage/
+ gripper) is a bookkeeping correction, not a physical removal.
"""
+ kind = key[0]
+ if used == 0 and kind == "shuttle":
+ self._offer_print_label_for_previous_occupant_omny(key)
try:
status_none = OMNY_STATUS_NONE
- kind = key[0]
if kind == "stage":
self._samples.sample_in_samplestage.set(used)
self._samples.sample_in_samplestage_name.set(name)
@@ -1224,11 +1330,18 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def action_set_parking(self, letter: str, current_slot: Optional[int]) -> None:
"""Assign OMNY shuttle `letter` to a parking slot (1-6), via
- ``_ShuttleStatusLabel``'s context menu. Overwrites that slot's
- previous occupant, if any, without clearing wherever `letter` was
- previously parked -- mirrors ``set_shuttle_slot()``'s own
- semantics exactly; keeping the two in sync is operator discipline,
- same as everywhere else in this widget (see module docstring).
+ ``_ShuttleStatusLabel``'s context menu. Rejects the assignment if
+ that slot is already assigned to a *different* shuttle -- unlike
+ ``set_shuttle_slot()`` itself, which would silently overwrite it
+ (see module docstring on why writes elsewhere in this widget don't
+ add interlocks: parking is the one exception, since two different
+ shuttles both being "in" the same slot is a bookkeeping
+ impossibility, not a legitimate concurrent-edit race). Re-assigning
+ the *same* shuttle to a slot it's already in is a no-op-safe
+ confirm, not an error.
+
+ Doesn't clear wherever `letter` was previously parked, if anywhere
+ else -- still the operator's job, same as everywhere else here.
"""
slot_nr, ok = QInputDialog.getInt(
self,
@@ -1241,6 +1354,19 @@ class OMNY_SampleStorage(BECWidget, QWidget):
)
if not ok:
return
+ data = self._bulk_read_omny()
+ parking_assignment = self._parse_parking_assignment_omny(data) if data is not None else {}
+ occupant = parking_assignment.get(slot_nr)
+ if occupant is not None and occupant != letter:
+ QMessageBox.warning(
+ self,
+ "Slot in use",
+ f"Parking slot {slot_nr} is already assigned to shuttle {occupant}.\n\n"
+ f"Clear shuttle {occupant}'s parking assignment first (right-click its "
+ f"status line → Clear parking assignment), then assign shuttle "
+ f"{letter} to slot {slot_nr}.",
+ )
+ return
if self._write_parking_omny(slot_nr, letter):
self.refresh()
@@ -1358,10 +1484,16 @@ class OMNY_SampleStorage(BECWidget, QWidget):
return path
def _build_report_pdf_omny(self) -> str:
- """Render a page mirroring the on-screen OMNY layout (stage +
- gripper header, fixed-O grid, 3 shuttle sections with their
- installed/active status text) from a fresh device read, and return
- the path of the temp PDF file it was written to.
+ """Render a page with just the 3 shuttles and their samples (status
+ line + slot grid) from a fresh device read, and return the path of
+ the temp PDF file it was written to.
+
+ Deliberately excludes the stage/gripper/fixed-O positions that the
+ on-screen widget also shows -- unlike the shuttles, none of those
+ samples can physically leave the vacuum chamber, so a printed
+ report (meant to travel with a shuttle's physical samples) has
+ nothing useful to say about them. The widget itself still displays
+ and manages all of it; only this printed report is scoped down.
"""
from fpdf import XPos, YPos
@@ -1375,7 +1507,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
with PDFWriter(path) as file:
pdf = file._pdf
pdf.set_font("Helvetica", "B", 16)
- pdf.cell(0, 10, "OMNY Sample Storage", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C")
+ pdf.cell(0, 10, "OMNY Shuttle Samples", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C")
pdf.set_font("Helvetica", "", 10)
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
pdf.cell(0, 6, timestamp, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C")
@@ -1383,27 +1515,10 @@ class OMNY_SampleStorage(BECWidget, QWidget):
margin = pdf.l_margin
usable_w = pdf.w - 2 * margin
- cell_w = usable_w / 5
cell_h = 28
- # header: stage + gripper, same widths as two fixed-O cells
- y0 = pdf.get_y()
- self._draw_report_cell(pdf, margin, y0, cell_w, cell_h, ("stage",), state.get(("stage",)))
- self._draw_report_cell(
- pdf, margin + cell_w, y0, cell_w, cell_h, ("gripper",), state.get(("gripper",))
- )
-
- # fixed O positions, same 5-wide grid as the on-screen layout
- fixed_y0 = y0 + cell_h + 4
- for idx, n in enumerate(OMNY_FIXED_POSITIONS):
- r, c = divmod(idx, 5)
- x = margin + c * cell_w
- y = fixed_y0 + r * cell_h
- key = ("O", n)
- self._draw_report_cell(pdf, x, y, cell_w, cell_h, key, state.get(key))
-
# shuttles A/B/C: status line + 3-wide slot grid, side by side
- shuttle_y0 = fixed_y0 + 2 * cell_h + 8
+ shuttle_y0 = pdf.get_y()
shuttle_w = usable_w / 3
for si, letter in enumerate(OMNY_SHUTTLE_LETTERS):
x0 = margin + si * shuttle_w
diff --git a/csaxs_bec/devices/omny/omny_sample_storage.py b/csaxs_bec/devices/omny/omny_sample_storage.py
index 000cb19a..362fc537 100644
--- a/csaxs_bec/devices/omny/omny_sample_storage.py
+++ b/csaxs_bec/devices/omny/omny_sample_storage.py
@@ -1,4 +1,5 @@
import time
+from typing import Optional
from ophyd import Component as Cpt
from ophyd import Device
@@ -27,13 +28,16 @@ class OMNYSampleStorage(Device):
"get_sample_owner_in_gripper",
"set_sample_status_in_samplestage",
"get_sample_status_in_samplestage",
+ "get_sample_account_in_samplestage",
"set_sample_status_in_gripper",
"get_sample_status_in_gripper",
+ "get_sample_account_in_gripper",
"get_sample_name",
"get_sample_owner",
"get_sample_name_and_owner",
"set_sample_status",
"get_sample_status",
+ "get_sample_account",
"is_sample_in_samplestage",
"set_shuttle_slot",
"unset_shuttle_slot",
@@ -46,17 +50,26 @@ class OMNYSampleStorage(Device):
SUB_VALUE = "value"
_default_sub = SUB_VALUE
- # Measured status, one of these three -- an "ai" record's VAL (see
- # omny_samples_status.template in the OMNY/OMNY.git IOC repo), not a
- # proper EPICS enum: this IOC's sample DB uses plain "ai" records
- # uniformly even for boolean-ish fields (see the placed-flag records),
- # so the new STATUS record follows the same convention rather than
- # introducing an mbbo/mbbi record type used nowhere else here. The
- # int<->str mapping lives entirely in this class.
+ # Measured status *and* account, packed into one number -- an "ai"
+ # record's VAL (see omny_samples_status.template in the OMNY/OMNY.git
+ # IOC repo), not a proper EPICS enum: this IOC's sample DB uses plain
+ # "ai" records uniformly even for boolean-ish fields (see the
+ # placed-flag records), so the STATUS record follows the same
+ # convention rather than introducing a new record type used nowhere
+ # else here.
+ #
+ # Encoding: VAL == 0 means "not measured"; otherwise VAL's magnitude is
+ # the 5-digit beamtime account and its sign is the status -- negative
+ # while a measurement is in progress ("started"), positive once it's
+ # done ("completed"). This packs status+account into the one VAL this
+ # IOC's "ai"-record convention already gives us, with no new EPICS
+ # record needed. An account that isn't exactly 5 digits (e.g. a
+ # non-numeric test account) falls back to FALLBACK_ACCOUNT (11111) --
+ # safe because real account numbers are already above 20000, so this
+ # sentinel can never collide with a real one, and only ever shows up
+ # for test accounts.
STATUS_NONE = 0
- STATUS_STARTED = 1
- STATUS_COMPLETED = 2
- _STATUS_LABELS = {STATUS_NONE: "none", STATUS_STARTED: "started", STATUS_COMPLETED: "completed"}
+ FALLBACK_ACCOUNT = 11111
sample_shuttle_A_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}", {"auto_monitor": True}) for i in range(1, 7)
@@ -119,11 +132,11 @@ class OMNYSampleStorage(Device):
}
sample_names = Dcpt(sample_names)
- # Measured status (VAL) + owner (DESC) -- one new record per sample
- # position, added alongside the existing placed/name records (see
- # STATUS_NONE/STARTED/COMPLETED above and omny_samples_status.template
- # in OMNY/OMNY.git). Parking slots have no equivalent -- they hold a
- # shuttle letter, not a sample.
+ # Measured status+account (VAL) + owner (DESC) -- one new record per
+ # sample position, added alongside the existing placed/name records
+ # (see the status+account VAL encoding described above and
+ # omny_samples_status.template in OMNY/OMNY.git). Parking slots have no
+ # equivalent -- they hold a shuttle letter, not a sample.
sample_shuttle_A_status = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}:STATUS", {"auto_monitor": True})
for i in range(1, 7)
@@ -242,27 +255,53 @@ class OMNYSampleStorage(Device):
self._run_subs(sub_type=self.SUB_VALUE, timestamp=timestamp, obj=self)
@classmethod
- def _status_str_to_val(cls, status: str) -> int:
- status = (status or "none").strip().lower()
- for val, label in cls._STATUS_LABELS.items():
- if label == status:
+ def _normalize_account(cls, account) -> int:
+ """A positive 5-digit account number for the status+account VAL
+ encoding (see the class docstring comment above), or
+ FALLBACK_ACCOUNT if `account` doesn't parse as exactly 5 digits.
+ """
+ digits = "".join(ch for ch in str(account) if ch.isdigit()) if account is not None else ""
+ if len(digits) == 5:
+ val = int(digits)
+ if 10000 <= val <= 99999:
return val
- raise OMNYSampleStorageError(
- f"Invalid status {status!r}; expected one of {sorted(cls._STATUS_LABELS.values())}."
- )
+ return cls.FALLBACK_ACCOUNT
@classmethod
- def _status_val_to_str(cls, val) -> str:
+ def _status_to_val(cls, status: str, account=None) -> int:
+ status = (status or "none").strip().lower()
+ if status == "none":
+ return 0
+ if status not in ("started", "completed"):
+ raise OMNYSampleStorageError(
+ f"Invalid status {status!r}; expected one of ['none', 'started', 'completed']."
+ )
+ acc = cls._normalize_account(account)
+ return -acc if status == "started" else acc
+
+ @classmethod
+ def _val_to_status(cls, val) -> str:
try:
- return cls._STATUS_LABELS.get(int(round(float(val))), "none")
+ v = int(round(float(val)))
except (TypeError, ValueError):
return "none"
+ if v == 0:
+ return "none"
+ return "started" if v < 0 else "completed"
+
+ @classmethod
+ def _val_to_account(cls, val) -> Optional[int]:
+ try:
+ v = int(round(float(val)))
+ except (TypeError, ValueError):
+ return None
+ return abs(v) if v != 0 else None
def set_sample_slot(self, container: str, slot_nr: int, name: str, owner: str = "") -> bool:
if slot_nr > 20:
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
- status = self._status_str_to_val("none")
+ status = self._status_to_val("none")
if container == "A":
getattr(self.sample_shuttle_A_placed, f"sample{slot_nr}").set(1)
getattr(self.sample_shuttle_A_names, f"sample{slot_nr}").set(name)
@@ -288,7 +327,7 @@ class OMNYSampleStorage(Device):
if slot_nr > 20:
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
- status = self._status_str_to_val("none")
+ status = self._status_to_val("none")
if container == "A":
getattr(self.sample_shuttle_A_placed, f"sample{slot_nr}").set(0)
getattr(self.sample_shuttle_A_names, f"sample{slot_nr}").set("-")
@@ -310,8 +349,8 @@ class OMNYSampleStorage(Device):
getattr(self.sample_owners, f"sample{slot_nr}").set("")
getattr(self.sample_status, f"sample{slot_nr}").set(status)
- def set_sample_status(self, container: str, slot_nr: int, status: str) -> bool:
- val = self._status_str_to_val(status)
+ def set_sample_status(self, container: str, slot_nr: int, status: str, account=None) -> bool:
+ val = self._status_to_val(status, account)
if container == "A":
getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").set(val)
elif container == "B":
@@ -332,7 +371,24 @@ class OMNYSampleStorage(Device):
val = getattr(self.sample_status, f"sample{slot_nr}").get()
else:
return "none"
- return self._status_val_to_str(val)
+ return self._val_to_status(val)
+
+ def get_sample_account(self, container: str, slot_nr: int) -> Optional[int]:
+ """The account packed into this slot's status VAL (see the
+ status+account encoding described above ``STATUS_NONE``), or None
+ if the slot isn't measured ("none" status).
+ """
+ if container == "A":
+ val = getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").get()
+ elif container == "B":
+ val = getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").get()
+ elif container == "C":
+ val = getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").get()
+ elif container == "O":
+ val = getattr(self.sample_status, f"sample{slot_nr}").get()
+ else:
+ return None
+ return self._val_to_account(val)
def get_sample_owner(self, container: str, slot_nr: int) -> str:
if container == "A":
@@ -366,25 +422,25 @@ class OMNYSampleStorage(Device):
self.sample_in_gripper.set(1)
self.sample_in_gripper_name.set(name)
self.sample_in_gripper_owner.set(owner)
- self.sample_in_gripper_status.set(self._status_str_to_val("none"))
+ self.sample_in_gripper_status.set(self._status_to_val("none"))
def unset_sample_in_gripper(self) -> bool:
self.sample_in_gripper.set(0)
self.sample_in_gripper_name.set("-")
self.sample_in_gripper_owner.set("")
- self.sample_in_gripper_status.set(self._status_str_to_val("none"))
+ self.sample_in_gripper_status.set(self._status_to_val("none"))
def set_sample_in_samplestage(self, name: str, owner: str = "") -> bool:
self.sample_in_samplestage.set(1)
self.sample_in_samplestage_name.set(name)
self.sample_in_samplestage_owner.set(owner)
- self.sample_in_samplestage_status.set(self._status_str_to_val("none"))
+ self.sample_in_samplestage_status.set(self._status_to_val("none"))
def unset_sample_in_samplestage(self) -> bool:
self.sample_in_samplestage.set(0)
self.sample_in_samplestage_name.set("-")
self.sample_in_samplestage_owner.set("")
- self.sample_in_samplestage_status.set(self._status_str_to_val("none"))
+ self.sample_in_samplestage_status.set(self._status_to_val("none"))
def get_sample_owner_in_gripper(self) -> str:
val = self.sample_in_gripper_owner.get()
@@ -394,17 +450,23 @@ class OMNYSampleStorage(Device):
val = self.sample_in_samplestage_owner.get()
return str(val) if val else ""
- def set_sample_status_in_gripper(self, status: str) -> bool:
- self.sample_in_gripper_status.set(self._status_str_to_val(status))
+ def set_sample_status_in_gripper(self, status: str, account=None) -> bool:
+ self.sample_in_gripper_status.set(self._status_to_val(status, account))
def get_sample_status_in_gripper(self) -> str:
- return self._status_val_to_str(self.sample_in_gripper_status.get())
+ return self._val_to_status(self.sample_in_gripper_status.get())
- def set_sample_status_in_samplestage(self, status: str) -> bool:
- self.sample_in_samplestage_status.set(self._status_str_to_val(status))
+ def get_sample_account_in_gripper(self) -> Optional[int]:
+ return self._val_to_account(self.sample_in_gripper_status.get())
+
+ def set_sample_status_in_samplestage(self, status: str, account=None) -> bool:
+ self.sample_in_samplestage_status.set(self._status_to_val(status, account))
def get_sample_status_in_samplestage(self) -> str:
- return self._status_val_to_str(self.sample_in_samplestage_status.get())
+ return self._val_to_status(self.sample_in_samplestage_status.get())
+
+ def get_sample_account_in_samplestage(self) -> Optional[int]:
+ return self._val_to_account(self.sample_in_samplestage_status.get())
def is_sample_slot_used(self, container, slot_nr: int) -> bool:
if container == "A":
@@ -473,9 +535,11 @@ class OMNYSampleStorage(Device):
status = self.get_sample_status(container, slot_nr)
suffix = f" ({owner})" if owner else ""
if status == "started":
- suffix += " ◐ started"
+ account = self.get_sample_account(container, slot_nr)
+ suffix += f" ◐ started (acc {account})"
elif status == "completed":
- suffix += " ✓ completed"
+ account = self.get_sample_account(container, slot_nr)
+ suffix += f" ✓ completed (acc {account})"
return name + suffix
def show_all(self):
@@ -508,9 +572,9 @@ class OMNYSampleStorage(Device):
status = self.get_sample_status_in_samplestage()
suffix = f" ({owner})" if owner else ""
if status == "started":
- suffix += " ◐ started"
+ suffix += f" ◐ started (acc {self.get_sample_account_in_samplestage()})"
elif status == "completed":
- suffix += " ✓ completed"
+ suffix += f" ✓ completed (acc {self.get_sample_account_in_samplestage()})"
print(f"\n\n Sample stage: {name}{suffix}")
else:
print(f"\n\n Sample stage: no sample")
@@ -544,9 +608,12 @@ class OMNYSampleStorage(Device):
print(" dev.omny_samples.set_sample_slot('system',position,'name','owner')")
print(" system can be A, B, C, O")
print(" dev.omny_samples.get_sample_owner('system',position)")
- print(" dev.omny_samples.set_sample_status('system',position,'status')")
+ print(" dev.omny_samples.set_sample_status('system',position,'status',account)")
print(" status is one of 'none', 'started', 'completed'")
+ print(" account is required for 'started'/'completed' (a 5-digit account number,")
+ print(" falls back to 11111 if not exactly 5 digits)")
print(" dev.omny_samples.get_sample_status('system',position)")
+ print(" dev.omny_samples.get_sample_account('system',position)")
print(" dev.omny_samples.set_sample_in_gripper('name','owner') / unset_sample_in_gripper()")
print(" dev.omny_samples.set_sample_in_samplestage('name','owner'), unset_sample_in_samplestage()")
print(" dev.omny_samples.set_shuttle_slot(container, slot_nr) / unset_shuttle_slot(slot_nr)")
diff --git a/tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py b/tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py
index b91c833c..5f4deea3 100644
--- a/tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py
+++ b/tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py
@@ -31,6 +31,26 @@ def test_oparkz_active_parking_slot_matches_each_target(readback, expected_slot)
assert oparkz_active_parking_slot(readback) == expected_slot
+@pytest.mark.parametrize(
+ "readback,expected_slot",
+ [
+ (-6, 6),
+ (-6 - 29, 5),
+ (-6 - 29 * 2, 4),
+ (-6 - 29 * 3, 3),
+ (-6 - 29 * 3 - 42 - 0.05, 2),
+ ],
+)
+def test_oparkz_active_parking_slot_matches_each_leica_target(readback, expected_slot):
+ """otransfer_park_loadlock_slot()'s targets (_oparkz_leica_slot(), a
+ different oparkz offset than the regular otransfer_park_slot() table)
+ must be recognized as "active" too -- a real bug: the GUI never showed
+ the active badge after a loadlock-driven park, since only the regular
+ target table was checked.
+ """
+ assert oparkz_active_parking_slot(readback) == expected_slot
+
+
def test_oparkz_active_parking_slot_none_when_far_from_any_target():
assert oparkz_active_parking_slot(0.0) is None
diff --git a/tests/tests_devices/test_omny_sample_storage.py b/tests/tests_devices/test_omny_sample_storage.py
new file mode 100644
index 00000000..71a730e2
--- /dev/null
+++ b/tests/tests_devices/test_omny_sample_storage.py
@@ -0,0 +1,47 @@
+import pytest
+
+from csaxs_bec.devices.omny.omny_sample_storage import OMNYSampleStorage, OMNYSampleStorageError
+
+
+def test_status_none_encodes_to_zero():
+ assert OMNYSampleStorage._status_to_val("none") == 0
+ assert OMNYSampleStorage._val_to_status(0) == "none"
+ assert OMNYSampleStorage._val_to_account(0) is None
+
+
+@pytest.mark.parametrize("account", [20123, "20123", "e20123"])
+def test_started_encodes_negative_account(account):
+ val = OMNYSampleStorage._status_to_val("started", account)
+ assert val == -20123
+ assert OMNYSampleStorage._val_to_status(val) == "started"
+ assert OMNYSampleStorage._val_to_account(val) == 20123
+
+
+@pytest.mark.parametrize("account", [20123, "20123", "e20123"])
+def test_completed_encodes_positive_account(account):
+ val = OMNYSampleStorage._status_to_val("completed", account)
+ assert val == 20123
+ assert OMNYSampleStorage._val_to_status(val) == "completed"
+ assert OMNYSampleStorage._val_to_account(val) == 20123
+
+
+@pytest.mark.parametrize("bad_account", [None, "", "not-a-number", "123", "123456", "e-test"])
+def test_unparseable_account_falls_back_to_sentinel(bad_account):
+ val = OMNYSampleStorage._status_to_val("started", bad_account)
+ assert OMNYSampleStorage._val_to_account(val) == OMNYSampleStorage.FALLBACK_ACCOUNT
+ assert OMNYSampleStorage.FALLBACK_ACCOUNT == 11111
+
+
+def test_invalid_status_raises():
+ with pytest.raises(OMNYSampleStorageError):
+ OMNYSampleStorage._status_to_val("bogus")
+
+
+@pytest.mark.parametrize("raw", ["not-a-number", None, object()])
+def test_val_to_status_defaults_to_none_on_bad_input(raw):
+ assert OMNYSampleStorage._val_to_status(raw) == "none"
+
+
+@pytest.mark.parametrize("raw", ["not-a-number", None, object()])
+def test_val_to_account_defaults_to_none_on_bad_input(raw):
+ assert OMNYSampleStorage._val_to_account(raw) is None
From bae3094761a1d8291c691f69f5372d928f638722 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 20:02:04 +0200
Subject: [PATCH 045/102] fix(omny): use GUI confirm dialogs in transfer flow;
fix sim sensor voltage
- omny_sample_transfer_mixin.py: add otransfer_confirm_dialog(), mirroring
flomni.py's ftransfer_confirm_dialog() -- routes sample-transfer
confirmations through the GUI console widget (already opened by
_omnycam_parking()/_omnycam_samplestage()) when available, instead of
always blocking on a terminal input() prompt. Falls back to the plain
yesno() prompt if the camera view hasn't been opened yet. All 8
self.OMNYTools.yesno() call sites in the transfer flow now go through it.
- simulated_omny.yaml: otransy's gripper-sensor check
(_otransfer_check_sensor_connected(), @AN[8001]) always failed in sim --
it fell back to the simulated Galil's shared default for unseeded analog
channels (-8.0V, tuned for motor-temperature registers elsewhere), against
an expected -2.3V +-0.5V. Added sim_analog_inputs: {8001: -2.3} to seed
that specific channel.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../omny/omny_sample_transfer_mixin.py | 37 +++++++++++++++----
.../simulated_omny/simulated_omny.yaml | 8 ++++
2 files changed, 37 insertions(+), 8 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 58b3b093..81d5e7e4 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -51,6 +51,27 @@ class OMNYSampleTransferMixin:
raise ValueError(f"Device {device} has no user parameter definition for {var}.")
return param.get(var)
+ def otransfer_confirm_dialog(self, message: str, default: str = "none") -> bool:
+ """
+ Yes/No confirmation for sample-transfer steps -- OMNY's counterpart
+ to flomni.py's ``ftransfer_confirm_dialog()``.
+
+ Uses the GUI console widget (ConsoleButtonsWidget, opened by
+ ``_omnycam_parking()``/``_omnycam_samplestage()`` in gui_tools.py)
+ when it's already available, so the operator can confirm/abort
+ right next to the live camera view, instead of a blocking terminal
+ ``input()`` prompt. Falls back to the regular console ``yesno()``
+ prompt if the cameras/console haven't been opened yet -- e.g. if a
+ transfer step is called directly, outside the usual
+ ``otransfer_get_sample``/``otransfer_put_sample`` sequence that
+ opens them first. ``self.console`` is set by ``OMNYGuiTools``
+ (a sibling mixin on the composed ``OMNY`` class), not this one.
+ """
+ if self.console is not None:
+ print("Using GUI console for confirmation dialog.")
+ return self.OMNYTools.gui_yesno(message, gui=self.console, default=default)
+ return self.OMNYTools.yesno(message, default)
+
def _otransfer_shuttle_aligner_down(self):
if not dev.oshuttlealign.get_motor_limit_switch()[1]:
dev.oshuttlealign.drive_axis_to_limit("forward")
@@ -143,7 +164,7 @@ class OMNYSampleTransferMixin:
if not dev.oshuttlealign.get_motor_limit_switch()[0]:
print("Not in correct start position for alignment.")
dev.oshuttlealign.controller.socket_put_confirmed("MO")
- if self.OMNYTools.yesno(
+ if self.otransfer_confirm_dialog(
"Shall I try to move it to the correct start position? Do this only once!"
):
print("starting...")
@@ -373,7 +394,7 @@ class OMNYSampleTransferMixin:
def _otransfer_gripper_to_park_z(self):
if not self._otransfer_gripper_querry_status_closed():
- if self.OMNYTools.yesno(
+ if self.otransfer_confirm_dialog(
"The gripper appears to be open, while it was expected to be closed.May it be closed now?",
"y",
):
@@ -474,7 +495,7 @@ class OMNYSampleTransferMixin:
)
# if the valve of the loadlock is not closed, ask before movement
# todo if(epics_get("XOMNY-ES1-LL1VG1:POSITION")!="CLOSED") {
- if not self.OMNYTools.yesno("Please confirm that the leica transfer is in back position."):
+ if not self.otransfer_confirm_dialog("Please confirm that the leica transfer is in back position."):
raise OMNYTransferError("Leica shuttle is not in back position.")
else:
self._oparkz_leica_slot(slot_number)
@@ -780,7 +801,7 @@ class OMNYSampleTransferMixin:
# keep shuttle open and ask user which sample to pick
# but have to make sure that shield will be closed for sample pos movements beyond shuttle positions
if pin_position in range(1, 7):
- if self.OMNYTools.yesno(
+ if self.otransfer_confirm_dialog(
"Pick another sample from this shuttle? Keep shield open?", "y"
):
print(
@@ -811,7 +832,7 @@ class OMNYSampleTransferMixin:
toppin = self._otransfer_get_toppin_position()
print(f"TopPin position is {toppin:.3f} mm")
if self._otransfer_get_toppin_position() > -25.4:
- if not self.OMNYTools.yesno(
+ if not self.otransfer_confirm_dialog(
"THIS IS LARGER THAN THE CURRENT THRESHOLD OF -25.4. ARE YOU SURE THE PIN IS OK AND DOES NOT HAVE A DAMAGE IN THE BOTTOM? Continue?"
):
raise OMNYTransferError("Abort by user because of top pin position.")
@@ -822,7 +843,7 @@ class OMNYSampleTransferMixin:
elif mntprgs == 0:
text = "All OK? Please confirm"
- if self.OMNYTools.yesno(text, "y"):
+ if self.otransfer_confirm_dialog(text, "y"):
self.transfer_step += 1
self._otransfer_save_reference_images(file_suffix=f"{step_name}_{self.transfer_step}")
dev.otransy.controller.socket_put_confirmed("confirm=1")
@@ -878,7 +899,7 @@ class OMNYSampleTransferMixin:
def otransfer_get_sample(self, pin_position: int):
self.transfer_step = 0
self._omnycam_parking()
- if not self.OMNYTools.yesno(
+ if not self.otransfer_confirm_dialog(
"Please confirm that currently there is no sample in the gripper. It would be dropped!",
"y",
):
@@ -957,7 +978,7 @@ class OMNYSampleTransferMixin:
feedback_status = self.device_manager.devices.rtx.controller.feedback_is_running()
if feedback_status == True:
# feedback is running
- if self.OMNYTools.yesno("May the feedback be disabled", "y"):
+ if self.otransfer_confirm_dialog("May the feedback be disabled", "y"):
raise OMNYTransferError(
"Manual abort of sample transfer, need to disable feedback has not been confirmed."
)
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
index 199d010e..6f888f8c 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
@@ -279,6 +279,14 @@ otransy:
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
+ # _otransfer_check_sensor_connected() reads @AN[8001] as the gripper's
+ # sensor voltage and expects it within 0.5V of gripper_sensorvoltagetarget
+ # below (-2.3). Without an override here it falls back to the simulated
+ # Galil's shared default for unseeded analog channels (-8.0V, tuned for
+ # motor-temperature registers elsewhere, not this one), which always
+ # fails the check -- see sim_galil.py's _DEFAULT_ANALOG_INPUT_V.
+ sim_analog_inputs:
+ 8001: -2.3
enabled: true
onFailure: buffer
readOnly: false
From 53f3686d225d4f306d8227783727877c5bb54ce5 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 20:09:12 +0200
Subject: [PATCH 046/102] fix(omny): quote sim_analog_inputs key as a string,
fixes config reload
The bare YAML int key (8001:) broke config reload entirely: BEC's msgpack
config transport uses strict_map_key=True, which rejects non-string map
keys outright ("int is not allowed for map key"). Quoting it ("8001":)
matches the existing convention already used in simulated_flomni.yaml's
own sim_analog_inputs, and round-trips through msgpack correctly --
configure_axis() already does int(chan) internally, so the string key
still resolves to the same channel.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
index 6f888f8c..a8c82cc6 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
@@ -285,8 +285,12 @@ otransy:
# Galil's shared default for unseeded analog channels (-8.0V, tuned for
# motor-temperature registers elsewhere, not this one), which always
# fails the check -- see sim_galil.py's _DEFAULT_ANALOG_INPUT_V.
+ # Key must be a quoted string, not a bare YAML int: BEC's msgpack config
+ # transport uses strict_map_key=True, which rejects int map keys outright
+ # (a bare 8001: here breaks config reload entirely) -- matches the
+ # existing convention in simulated_flomni.yaml's own sim_analog_inputs.
sim_analog_inputs:
- 8001: -2.3
+ "8001": -2.3
enabled: true
onFailure: buffer
readOnly: false
From fbc76b4fb16ec05638890189bc3685adcba87135 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Thu, 3 Sep 2026 20:19:12 +0200
Subject: [PATCH 047/102] fix(omny): simulate oshield's digital open-switch
from its live position
_oshield_is_ST_open() reads a digital switch (@IN[8014]) on otransx's Galil
controller to confirm the sample-stage thermal shield reached its physical
"open" limit -- but oshield is a Smaract stage, simulated by a completely
separate simulator (sim_smaract.py), so that switch had no connection to
oshield's actual simulated position and always read a stale default.
sim_galil.py's @IN[8014] handler now computes the switch value live from
oshield's real position via SimStateRegistry (cross-simulator lookup),
instead of a disconnected default -- 1.0 within tolerance of the "open"
target (-11.88, _oshield_ST_open()'s own umv() target), 0.0 otherwise.
Also fixes oshield's simulated starting position: it started at 0 (neither
the "open" -11.88 nor "closed" 15.5), so _otransfer_stage_to_ST()'s
oshield_current < 15 closed-check always failed fresh. Now starts at 15.5,
matching the real idle/closed resting state.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../simulated_omny/simulated_omny.yaml | 6 +-
csaxs_bec/devices/sim/sim_galil.py | 37 ++++++
.../test_sim_galil_oshield_switch.py | 118 ++++++++++++++++++
3 files changed, 160 insertions(+), 1 deletion(-)
create mode 100644 tests/tests_devices/test_sim_galil_oshield_switch.py
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
index a8c82cc6..b49e5d5b 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
@@ -709,7 +709,11 @@ oshield:
- 15.8
port: 3334
sign: 1
- sim_initial_position: 0
+ # Real hardware idles with the sample-stage shield closed; 15.5 is
+ # _oshield_ST_close()'s own umv() target (omny_sample_transfer_mixin.py).
+ # Starting at 0 (neither open -11.88 nor closed 15.5) always failed
+ # _otransfer_stage_to_ST()'s "oshield_current < 15" closed check.
+ sim_initial_position: 15.5
enabled: true
onFailure: buffer
readOnly: false
diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py
index d6a21fc3..c61e8b86 100644
--- a/csaxs_bec/devices/sim/sim_galil.py
+++ b/csaxs_bec/devices/sim/sim_galil.py
@@ -46,6 +46,37 @@ _OMNY_ROTATION_AXIS = 2
# 150 degC on every axis without an explicit sim_analog_inputs override.
_DEFAULT_ANALOG_INPUT_V = -8.0
+# oshield (the sample-stage thermal shield) is a Smaract stage, not a Galil axis --
+# but its "fully open" limit switch is wired into *this* Galil controller's digital
+# input @IN[8014] on real hardware, read via otransx's socket since they share the
+# same controller (see _oshield_is_ST_open() in omny_sample_transfer_mixin.py: "the
+# switch for detecting status can be reached via galil controller 1, so we use
+# otransx for access"). oshield's own simulated position lives entirely in
+# sim_smaract.py's SimSmaractState (a different simulator, keyed by a different
+# host/port), so without this, @IN[8014] would read a static default forever,
+# disagreeing with wherever oshield actually simulated-moved to. Computed dynamically
+# below instead of seeded once, since oshield's position changes during a session.
+_OSHIELD_OPEN_SWITCH_CHANNEL = 8014
+_OSHIELD_GALIL_HOST = "mpc3217.psi.ch"
+_OSHIELD_GALIL_PORT = 8081 # otransx/otransy's controller
+_OSHIELD_SMARACT_HOST = "mpc3217.psi.ch"
+_OSHIELD_SMARACT_PORT = 3334
+_OSHIELD_SMARACT_AXIS = 2 # axis_Id "C"
+_OSHIELD_OPEN_POSITION_MM = -11.88 # _oshield_ST_open()'s own umv() target
+_OSHIELD_OPEN_TOLERANCE_MM = 0.1
+
+
+def _oshield_open_switch_value() -> float:
+ # Imported lazily to avoid a module-level import cycle risk between the two
+ # simulator modules; sim_smaract.py doesn't import sim_galil.py today, but
+ # keeping this import local avoids ever having to care if that changes.
+ from csaxs_bec.devices.sim.sim_smaract import SimSmaractState
+
+ state = SimStateRegistry.get(SimSmaractState, _OSHIELD_SMARACT_HOST, _OSHIELD_SMARACT_PORT)
+ position_mm = state.axis(_OSHIELD_SMARACT_AXIS).position() / 1.0e6 # nm -> mm
+ is_open = abs(position_mm - _OSHIELD_OPEN_POSITION_MM) < _OSHIELD_OPEN_TOLERANCE_MM
+ return 1.0 if is_open else 0.0
+
class _SimGalilAxis:
"""State of a single simulated Galil axis (positions in microsteps)."""
@@ -494,6 +525,12 @@ class SimGalilSocket(SimSocketBase):
return "0.0000"
if arg.startswith("@IN["):
channel = int(arg[4:].split("]")[0])
+ if (
+ channel == _OSHIELD_OPEN_SWITCH_CHANNEL
+ and state.host == _OSHIELD_GALIL_HOST
+ and state.port == _OSHIELD_GALIL_PORT
+ ):
+ return f"{_oshield_open_switch_value():.4f}"
return f"{state.digital_inputs.get(channel, 0.0):.4f}"
if arg.startswith("@OUT["):
channel = int(arg[5:].split("]")[0])
diff --git a/tests/tests_devices/test_sim_galil_oshield_switch.py b/tests/tests_devices/test_sim_galil_oshield_switch.py
new file mode 100644
index 00000000..f33d1f07
--- /dev/null
+++ b/tests/tests_devices/test_sim_galil_oshield_switch.py
@@ -0,0 +1,118 @@
+import pytest
+from bec_server.device_server.tests.utils import DMMock
+
+from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilController
+from csaxs_bec.devices.sim.sim_galil import (
+ _OSHIELD_GALIL_HOST,
+ _OSHIELD_GALIL_PORT,
+ _OSHIELD_OPEN_POSITION_MM,
+ _OSHIELD_SMARACT_AXIS,
+ _OSHIELD_SMARACT_PORT,
+)
+from csaxs_bec.devices.sim.sim_omny import SimOMNYGalilMotor
+from csaxs_bec.devices.sim.sim_smaract import SimSmaractMotor, SimSmaractState
+from csaxs_bec.devices.sim.sim_socket import SimStateRegistry
+
+# otransx's own axis letter doesn't matter here -- only its host/port (shared with
+# oshield's "open" limit switch on real hardware, see sim_galil.py's
+# _OSHIELD_GALIL_HOST/_OSHIELD_GALIL_PORT).
+_OTRANSX_AXIS_ID = "D"
+
+
+@pytest.fixture(autouse=True)
+def _reset_sim_state():
+ OMNYGalilController._reset_controller()
+ SimStateRegistry.reset()
+ yield
+ SimStateRegistry.reset()
+ OMNYGalilController._reset_controller()
+
+
+def _make_devices(oshield_initial_position):
+ otransx = SimOMNYGalilMotor(
+ _OTRANSX_AXIS_ID,
+ name="otransx",
+ host=_OSHIELD_GALIL_HOST,
+ port=_OSHIELD_GALIL_PORT,
+ sign=1,
+ device_manager=DMMock(),
+ sim_initial_position=0,
+ sim_velocity=10.0,
+ )
+ otransx.wait_for_connection()
+ oshield = SimSmaractMotor(
+ "C",
+ name="oshield",
+ host=_OSHIELD_GALIL_HOST,
+ port=_OSHIELD_SMARACT_PORT,
+ sign=1,
+ device_manager=DMMock(),
+ sim_initial_position=oshield_initial_position,
+ sim_velocity=10.0,
+ )
+ oshield.wait_for_connection()
+ return otransx, oshield
+
+
+def _switch_value(otransx) -> float:
+ return float(otransx.controller.socket_put_and_receive("MG @IN[8014]"))
+
+
+def test_switch_reads_closed_when_oshield_is_closed():
+ """oshield closed (15.5, _oshield_ST_close()'s own target) -> switch not
+ triggered, matching _oshield_is_ST_open()'s real-hardware semantics."""
+ otransx, oshield = _make_devices(15.5)
+ try:
+ assert _switch_value(otransx) == 0.0
+ finally:
+ otransx.destroy()
+ oshield.destroy()
+
+
+def test_switch_reads_open_when_oshield_is_at_open_position():
+ otransx, oshield = _make_devices(_OSHIELD_OPEN_POSITION_MM)
+ try:
+ assert _switch_value(otransx) == 1.0
+ finally:
+ otransx.destroy()
+ oshield.destroy()
+
+
+def test_switch_tracks_oshield_position_live_not_just_at_startup():
+ """The switch must reflect oshield's *current* simulated position, not
+ just whatever it started at -- this is the actual bug being fixed:
+ the digital switch and oshield's position are simulated by two
+ different simulator classes (Galil vs Smaract) that don't otherwise
+ talk to each other."""
+ otransx, oshield = _make_devices(15.5)
+ try:
+ assert _switch_value(otransx) == 0.0
+ state = SimStateRegistry.get(SimSmaractState, _OSHIELD_GALIL_HOST, _OSHIELD_SMARACT_PORT)
+ state.axis(_OSHIELD_SMARACT_AXIS).pos_nm = _OSHIELD_OPEN_POSITION_MM * 1.0e6
+ assert _switch_value(otransx) == 1.0
+ finally:
+ otransx.destroy()
+ oshield.destroy()
+
+
+def test_switch_reads_closed_at_an_intermediate_position():
+ otransx, oshield = _make_devices(0.0)
+ try:
+ assert _switch_value(otransx) == 0.0
+ finally:
+ otransx.destroy()
+ oshield.destroy()
+
+
+def test_other_channels_unaffected_by_the_oshield_special_case():
+ """A different digital-input channel on the same controller must still
+ use the plain seeded/default value, not get swept up in the oshield
+ special-case."""
+ otransx, oshield = _make_devices(15.5)
+ try:
+ # channel 14 (light curtain) has its own module-level default (1.0),
+ # independent of oshield's position
+ assert float(otransx.controller.socket_put_and_receive("MG @IN[14]")) == 1.0
+ finally:
+ otransx.destroy()
+ oshield.destroy()
From eef1c7eb469f4d956d93b656828e108944d59de1 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Fri, 4 Sep 2026 16:36:14 +0200
Subject: [PATCH 048/102] fix(omny): reuse camera docks on parking/samplestage
switch, GUI-confirm mount step, speed up otransx/oshield sim
- gui_tools.py: _omnycam_parking()/_omnycam_samplestage() now share one
_omnygui_show_omnycam_pair() helper that creates the two camera Image
docks + console once and re-points them via Image.image(device=...)
on later switches instead of tearing down and rebuilding the whole
GUI every time (bec_widgets' Image.image() already supports being
called again to swap the monitored device). Also stops live mode on
the outgoing camera pair on a switch, and drops the dead
_omnycam_check_device_exists no-op guard in favor of the `in dev`
idiom already used for cam_xeye in this file.
- omny_sample_transfer_mixin.py: the two remaining blocking
input("Ready. Press Enter...") prompts in otransfer_put_sample/
otransfer_get_sample now go through otransfer_confirm_dialog() like
every other transfer confirmation, so they show up in the GUI console
instead of blocking on a terminal keypress; fixed the get_sample
prompt's copy-pasted "mount" wording to "unmount".
- simulated_omny.yaml: otransx sim_velocity 10->100 mm/s; oshield
gained sim_velocity: 10.0 (previously unset, defaulting to the
Smaract sim's 1 mm/s) -- both x10 per request.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../plugins/omny/gui_tools.py | 112 +++++++-----------
.../omny/omny_sample_transfer_mixin.py | 6 +-
.../simulated_omny/simulated_omny.yaml | 3 +-
.../test_omny_gui_tools.py | 46 +++++++
4 files changed, 97 insertions(+), 70 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
index e770cf2a..367248a2 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py
@@ -33,11 +33,10 @@ class OMNYGuiTools:
self.progressbar = None
self.xeyegui = None
self.idle_text_box = None
- self.fig200 = None
- self.fig201 = None
- self.fig202 = None
- self.fig203 = None
+ self.cam_dock_a = None
+ self.cam_dock_b = None
self.console = None
+ self._omnycam_shown_pair = None
def set_client(self, client):
self.client = client
@@ -68,14 +67,6 @@ class OMNYGuiTools:
return True
return False
- def _omnycam_check_device_exists(self, device):
- try:
- device
- except:
- return False
- else:
- return True
-
def omnygui_show_xeyealign(self):
self.omnygui_show_gui()
if self._omnygui_is_missing("xeyegui"):
@@ -104,30 +95,37 @@ class OMNYGuiTools:
self._omnycam_active_pair = ("cam200", "cam203")
def omnygui_show_omnycam_parking(self):
+ self._omnygui_show_omnycam_pair("cam200", "cam203", num_rotation_90=3)
+
+ def _omnycam_samplestage(self):
+ self.omnygui_show_omnycam_samplestage()
+ self._omnycam_active_pair = ("cam201", "cam202")
+
+ def omnygui_show_omnycam_samplestage(self):
+ self._omnygui_show_omnycam_pair("cam201", "cam202", num_rotation_90=0)
+
+ def _omnygui_show_omnycam_pair(self, cam_a: str, cam_b: str, num_rotation_90: int = 0):
+ """Show a live camera pair, reusing the two camera docks + console
+ across parking<->samplestage switches instead of tearing the whole
+ GUI down and rebuilding it every time.
+
+ ``Image.image(device=..., signal=...)`` (bec_widgets) disconnects
+ the previous monitor and reconnects to the new one on an existing
+ widget, so switching pairs is just re-pointing ``cam_dock_a``/
+ ``cam_dock_b`` at the new devices -- the docks and console are only
+ (re)created here if they don't exist yet (first camera view this
+ session, or after a full teardown via another GUI state such as
+ idle/progress/xeyealign).
+ """
self.omnygui_show_gui()
if (
- self._omnygui_is_missing("fig200")
- or self._omnygui_is_missing("fig203")
+ self._omnygui_is_missing("cam_dock_a")
+ or self._omnygui_is_missing("cam_dock_b")
or self._omnygui_is_missing("console")
):
self.omnygui_remove_all_docks()
- self.fig200 = self.gui.omny.new("Image")
- if self._omnycam_check_device_exists(dev.cam200):
- self.fig200.image(device="cam200", signal="preview")
- self.fig200.num_rotation_90 = 3
- self.fig200.lock_aspect_ratio = True
- dev.cam200.start_live_mode()
- else:
- print("Cannot open cam200. Device does not exist.")
- self.fig203 = self.gui.omny.new("Image")
- if self._omnycam_check_device_exists(dev.cam203):
- self.fig203.image(device="cam203", signal="preview")
- self.fig203.num_rotation_90 = 3
- self.fig203.lock_aspect_ratio = True
- dev.cam203.start_live_mode()
- else:
- print("Cannot open cam203. Device does not exist.")
-
+ self.cam_dock_a = self.gui.omny.new("Image")
+ self.cam_dock_b = self.gui.omny.new("Image")
# Hard-stop console, docked below the cameras. otransy is the
# closest omny analog to flomni's ftransy (both the transfer-Y
# axis) -- no secondary/extra stop device wired yet.
@@ -140,41 +138,22 @@ class OMNYGuiTools:
)
self.gui.omny.set_layout_ratios(vertical=[5, 1])
- def _omnycam_samplestage(self):
- self.omnygui_show_omnycam_samplestage()
- self._omnycam_active_pair = ("cam201", "cam202")
+ outgoing_pair = self._omnycam_shown_pair
+ if outgoing_pair and outgoing_pair != (cam_a, cam_b):
+ for cam_name in outgoing_pair:
+ if cam_name in dev:
+ getattr(dev, cam_name).stop_live_mode()
- def omnygui_show_omnycam_samplestage(self):
- self.omnygui_show_gui()
- if (
- self._omnygui_is_missing("fig201")
- or self._omnygui_is_missing("fig202")
- or self._omnygui_is_missing("console")
- ):
- self.omnygui_remove_all_docks()
- self.fig201 = self.gui.omny.new("Image")
- if self._omnycam_check_device_exists(dev.cam201):
- self.fig201.image(device="cam201", signal="preview")
- self.fig201.lock_aspect_ratio = True
- dev.cam201.start_live_mode()
+ for dock, cam_name in ((self.cam_dock_a, cam_a), (self.cam_dock_b, cam_b)):
+ if cam_name in dev:
+ dock.image(device=cam_name, signal="preview")
+ dock.num_rotation_90 = num_rotation_90
+ dock.lock_aspect_ratio = True
+ getattr(dev, cam_name).start_live_mode()
else:
- print("Cannot open cam201. Device does not exist.")
- self.fig202 = self.gui.omny.new("Image")
- if self._omnycam_check_device_exists(dev.cam202):
- self.fig202.image(device="cam202", signal="preview")
- self.fig202.lock_aspect_ratio = True
- dev.cam202.start_live_mode()
- else:
- print("Cannot open cam202. Device does not exist.")
+ print(f"Cannot open {cam_name}. Device does not exist.")
- self.console = self.gui.omny.new(
- "z_ConsoleButtonsWidget",
- object_name="console",
- where="bottom",
- hard_stop_device_name="otransy",
- hard_stop_label="OMNY Motion Stop",
- )
- self.gui.omny.set_layout_ratios(vertical=[5, 1])
+ self._omnycam_shown_pair = (cam_a, cam_b)
def omnygui_remove_all_docks(self):
if hasattr(self.gui, "omny"):
@@ -183,11 +162,10 @@ class OMNYGuiTools:
self.text_box = None
self.xeyegui = None
self.idle_text_box = None
- self.fig200 = None
- self.fig201 = None
- self.fig202 = None
- self.fig203 = None
+ self.cam_dock_a = None
+ self.cam_dock_b = None
self.console = None
+ self._omnycam_shown_pair = None
def omnygui_idle(self):
self.omnygui_show_gui()
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 81d5e7e4..7ca98c90 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -779,7 +779,8 @@ class OMNYSampleTransferMixin:
self._otransfer_controller_enable_mount_mode()
dev.otransy.controller.socket_put_confirmed(f"mntaprch={_otransy_approach_height:.2f}")
- input("Ready. Press Enter to start the mount process now...")
+ if not self.otransfer_confirm_dialog("Ready to start the mount process now?", "y"):
+ raise OMNYTransferError("Transfer process has been manually aborted.")
dev.otransy.controller.socket_put_confirmed("XQ#GRPUT")
print("The mount process started.")
@@ -932,7 +933,8 @@ class OMNYSampleTransferMixin:
dev.otransy.controller.socket_put_confirmed(f"getaprch={_otransy_approach_height:.2f}")
- input("Ready. Press Enter to start the mount process now...")
+ if not self.otransfer_confirm_dialog("Ready to start the unmount process now?", "y"):
+ raise OMNYTransferError("Transfer process has been manually aborted.")
dev.otransy.controller.socket_put_confirmed("XQ#GRGET")
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
index b49e5d5b..dbd84c4c 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
@@ -255,7 +255,7 @@ otransx:
- 0
port: 8081
sign: 1
- sim_velocity: 10.0
+ sim_velocity: 100.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -709,6 +709,7 @@ oshield:
- 15.8
port: 3334
sign: 1
+ sim_velocity: 10.0
# Real hardware idles with the sample-stage shield closed; 15.5 is
# _oshield_ST_close()'s own umv() target (omny_sample_transfer_mixin.py).
# Starting at 0 (neither open -11.88 nor closed 15.5) always failed
diff --git a/tests/tests_bec_ipython_client/test_omny_gui_tools.py b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
index 9e50bf59..a1e579f2 100644
--- a/tests/tests_bec_ipython_client/test_omny_gui_tools.py
+++ b/tests/tests_bec_ipython_client/test_omny_gui_tools.py
@@ -122,6 +122,7 @@ def test_omnygui_show_omnycam_parking_uses_current_image_widget_api():
client.gui.omny.new.side_effect = [fig200, fig203, console]
dev_mock = mock.MagicMock()
+ dev_mock.__contains__ = mock.Mock(return_value=True)
with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True):
tools.omnygui_show_omnycam_parking()
@@ -240,6 +241,7 @@ def test_omnygui_show_omnycam_samplestage_uses_current_image_widget_api():
client.gui.omny.new.side_effect = [fig201, fig202, console]
dev_mock = mock.MagicMock()
+ dev_mock.__contains__ = mock.Mock(return_value=True)
with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True):
tools.omnygui_show_omnycam_samplestage()
@@ -250,3 +252,47 @@ def test_omnygui_show_omnycam_samplestage_uses_current_image_widget_api():
fig202.image.assert_called_once_with(device="cam202", signal="preview")
dev_mock.cam202.start_live_mode.assert_called_once()
assert tools.console is console
+
+
+def test_omnycam_switch_repoints_existing_docks_instead_of_recreating():
+ """Switching parking<->samplestage while the camera view is already open
+ must not tear down and rebuild the GUI (visible flicker) -- it should
+ reuse the same two Image docks + console and just re-point them at the
+ new camera pair via Image.image(), per bec_widgets' current API."""
+ tools, client = _make_gui_tools()
+ client.gui.windows = {"omny": mock.MagicMock()}
+ dock_a = mock.MagicMock()
+ dock_a._is_deleted.return_value = False
+ dock_b = mock.MagicMock()
+ dock_b._is_deleted.return_value = False
+ console = mock.MagicMock()
+ console._is_deleted.return_value = False
+ client.gui.omny.new.side_effect = [dock_a, dock_b, console]
+
+ dev_mock = mock.MagicMock()
+ dev_mock.__contains__ = mock.Mock(return_value=True)
+
+ with mock.patch(f"{GUI_TOOLS}.dev", dev_mock, create=True):
+ tools.omnygui_show_omnycam_parking()
+ client.gui.omny.new.reset_mock()
+ dock_a.image.reset_mock()
+ dock_b.image.reset_mock()
+
+ tools.omnygui_show_omnycam_samplestage()
+
+ # no new docks/console created on the switch
+ client.gui.omny.new.assert_not_called()
+ assert tools.cam_dock_a is dock_a
+ assert tools.cam_dock_b is dock_b
+ assert tools.console is console
+
+ # the same two docks were re-pointed at the new devices
+ dock_a.image.assert_called_once_with(device="cam201", signal="preview")
+ assert dock_a.num_rotation_90 == 0
+ dock_b.image.assert_called_once_with(device="cam202", signal="preview")
+
+ # the outgoing pair's live mode was stopped, the incoming pair's started
+ dev_mock.cam200.stop_live_mode.assert_called_once()
+ dev_mock.cam203.stop_live_mode.assert_called_once()
+ dev_mock.cam201.start_live_mode.assert_called_once()
+ dev_mock.cam202.start_live_mode.assert_called_once()
From 2086c9e4e6527ced07c10c724ecc3dbf1144b4c9 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Fri, 4 Sep 2026 16:51:21 +0200
Subject: [PATCH 049/102] fix(omny): always route fast-shutter control through
dev.fsh; carry owner/status/account across sample transfer
- shutter.py (OMNYFastShutter): drop the "use dev.fsh if configured, else
fall back to a local dummy signal" branching -- always delegate to the
'fsh' device. Removed the now-unused MonitorSignal class and the
dummy shutter Cpt along with it. Rewrote test_omny_shutter.py to assert
delegation to a mocked 'fsh' device instead of exercising the removed
dummy fallback.
- omny_sample_transfer_mixin.py: picking a sample up (get_sample, three
source kinds: shuttle slot, fixed position, sample stage) only carried
the sample name into set_sample_in_gripper(), silently dropping owner
(defaulted to "") and measured status/account (reset to "none" by
set_sample_in_gripper() itself). Same gap in reverse on put_sample.
All six call sites (three pick, three put) now read owner/status/account
from the source before clearing it and carry them onto the destination.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../omny/omny_sample_transfer_mixin.py | 55 ++++++++++---
csaxs_bec/devices/omny/shutter.py | 72 ++++------------
tests/tests_devices/test_omny_shutter.py | 82 ++++++++-----------
3 files changed, 92 insertions(+), 117 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 7ca98c90..2ae14aa3 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -12,8 +12,6 @@ from typeguard import typechecked
# from functools import wraps
-from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_get, epics_put, fshopen, fshclose
-
if builtins.__dict__.get("bec") is not None:
bec = builtins.__dict__.get("bec")
@@ -987,7 +985,11 @@ class OMNYSampleTransferMixin:
self.device_manager.devices.rtx.controller.feedback_disable()
- fshclose()
+ # dev.fsh (not the raw cSAXS_beamline.fshclose(), which does a bare
+ # epics.caput() straight to X12SA-ES1-TTL:OUT_01 outside BEC entirely
+ # -- unreachable/unmocked in a simulated session) -- same device
+ # omny_optics_mixin.py already uses for shutter control.
+ dev.fsh.fshclose()
if oeyez_current < -20:
raise OMNYTransferError(
@@ -1294,9 +1296,20 @@ class OMNYSampleTransferMixin:
samplename = dev.omny_samples.get_sample_name(
installedshuttle, _osampleposition
)
- # remove sample from slot and put to gripper
+ owner = dev.omny_samples.get_sample_owner(
+ installedshuttle, _osampleposition
+ )
+ status = dev.omny_samples.get_sample_status(
+ installedshuttle, _osampleposition
+ )
+ account = dev.omny_samples.get_sample_account(
+ installedshuttle, _osampleposition
+ )
+ # remove sample from slot and put to gripper -- owner/status/account
+ # carry over with it, the same physical sample
dev.omny_samples.unset_sample_slot(installedshuttle, _osampleposition)
- dev.omny_samples.set_sample_in_gripper(samplename)
+ dev.omny_samples.set_sample_in_gripper(samplename, owner)
+ dev.omny_samples.set_sample_status_in_gripper(status, account)
return True
else:
print("There is no sample availabe at that position.")
@@ -1385,8 +1398,12 @@ class OMNYSampleTransferMixin:
if dev.omny_samples.is_sample_slot_used("O", _osampleposition):
if _picked_it:
samplename = dev.omny_samples.get_sample_name("O", _osampleposition)
+ owner = dev.omny_samples.get_sample_owner("O", _osampleposition)
+ status = dev.omny_samples.get_sample_status("O", _osampleposition)
+ account = dev.omny_samples.get_sample_account("O", _osampleposition)
dev.omny_samples.unset_sample_slot("O", _osampleposition)
- dev.omny_samples.set_sample_in_gripper(samplename)
+ dev.omny_samples.set_sample_in_gripper(samplename, owner)
+ dev.omny_samples.set_sample_status_in_gripper(status, account)
# FZP was picked
# if (_osampleposition == 10 || _osampleposition == 11 || _osampleposition == 100)
# _otransfer_modify_sample("omny",110,2,samplename)
@@ -1406,8 +1423,12 @@ class OMNYSampleTransferMixin:
if dev.omny_samples.is_sample_in_samplestage():
if _picked_it:
samplename = dev.omny_samples.get_sample_name_in_samplestage()
+ owner = dev.omny_samples.get_sample_owner_in_samplestage()
+ status = dev.omny_samples.get_sample_status_in_samplestage()
+ account = dev.omny_samples.get_sample_account_in_samplestage()
dev.omny_samples.unset_sample_in_samplestage()
- dev.omny_samples.set_sample_in_gripper(samplename)
+ dev.omny_samples.set_sample_in_gripper(samplename, owner)
+ dev.omny_samples.set_sample_status_in_gripper(status, account)
# FZP was picked
# if (_osampleposition == 10 || _osampleposition == 11 || _osampleposition == 100)
# _otransfer_modify_sample("omny",110,2,samplename)
@@ -1480,9 +1501,15 @@ class OMNYSampleTransferMixin:
if not dev.omny_samples.is_sample_slot_used(installedshuttle, _osampleposition):
if _put_it:
samplename = dev.omny_samples.get_sample_name_in_gripper()
+ owner = dev.omny_samples.get_sample_owner_in_gripper()
+ status = dev.omny_samples.get_sample_status_in_gripper()
+ account = dev.omny_samples.get_sample_account_in_gripper()
dev.omny_samples.unset_sample_in_gripper()
dev.omny_samples.set_sample_slot(
- installedshuttle, _osampleposition, samplename
+ installedshuttle, _osampleposition, samplename, owner
+ )
+ dev.omny_samples.set_sample_status(
+ installedshuttle, _osampleposition, status, account
)
return True
else:
@@ -1572,7 +1599,11 @@ class OMNYSampleTransferMixin:
if not dev.omny_samples.is_sample_slot_used("O", _osampleposition):
if _put_it:
samplename = dev.omny_samples.get_sample_name_in_gripper()
- dev.omny_samples.set_sample_slot("O", _osampleposition, samplename)
+ owner = dev.omny_samples.get_sample_owner_in_gripper()
+ status = dev.omny_samples.get_sample_status_in_gripper()
+ account = dev.omny_samples.get_sample_account_in_gripper()
+ dev.omny_samples.set_sample_slot("O", _osampleposition, samplename, owner)
+ dev.omny_samples.set_sample_status("O", _osampleposition, status, account)
dev.omny_samples.unset_sample_in_gripper()
return True
else:
@@ -1584,7 +1615,11 @@ class OMNYSampleTransferMixin:
if not dev.omny_samples.is_sample_in_samplestage():
if _put_it:
samplename = dev.omny_samples.get_sample_name_in_gripper()
- dev.omny_samples.set_sample_in_samplestage(samplename)
+ owner = dev.omny_samples.get_sample_owner_in_gripper()
+ status = dev.omny_samples.get_sample_status_in_gripper()
+ account = dev.omny_samples.get_sample_account_in_gripper()
+ dev.omny_samples.set_sample_in_samplestage(samplename, owner)
+ dev.omny_samples.set_sample_status_in_samplestage(status, account)
dev.omny_samples.unset_sample_in_gripper()
return True
else:
diff --git a/csaxs_bec/devices/omny/shutter.py b/csaxs_bec/devices/omny/shutter.py
index 6e5e4fe8..56e9b219 100644
--- a/csaxs_bec/devices/omny/shutter.py
+++ b/csaxs_bec/devices/omny/shutter.py
@@ -1,95 +1,53 @@
"""
-Fast Shutter control for OMNY setup. If started with a config file in which the device_manager
-has a 'fsh' device (cSAXSFastEpicsShutter), this device will be used as the shutter.
-Otherwise, the device will create a dummy shutter device that will log warnings when shutter
-methods are called, but will not raise exceptions.
+Fast Shutter control for OMNY setup. Delegates directly to the 'fsh' device
+(cSAXSFastEpicsShutter, or its simulated counterpart) via the device manager
+-- 'fsh' must be present and enabled in the running config.
"""
from bec_lib.logger import bec_logger
-from ophyd import Component as Cpt
-from ophyd import Device, Signal
+from ophyd import Device
from ophyd_devices import PSIDeviceBase
logger = bec_logger.logger
-class MonitorSignal(Signal):
- """A simple wrapper around ophyd Signal that automatically monitors the signal for changes."""
-
- def __init__(self, *, name, auto_monitor=False, **kwargs):
- super().__init__(name=name, **kwargs)
- self.auto_monitor = auto_monitor
-
-
class OMNYFastShutter(PSIDeviceBase, Device):
"""
- Fast Shutter control for OMNY setup. If started with at the beamline, it will expose
- the shutter control methods (fshopen, fshclose, fshstatus, fshinfo) from the
- cSAXSFastEpicsShutter device. The device is identified by the 'fsh' name in the device manager.
- If the 'fsh' device is not found in the device manager, this device will create a dummy shutter
- and log warnings when shutter methods are called, but will not raise exceptions.
+ Fast Shutter control for OMNY setup. Exposes the shutter control methods
+ (fshopen, fshclose, fshstatus, fshinfo, fshstatus_readback) by delegating
+ to the 'fsh' device (cSAXSFastEpicsShutter) found in the device manager.
"""
USER_ACCESS = ["fshopen", "fshclose", "fshstatus", "fshinfo", "help", "fshstatus_readback"]
SUB_VALUE = "value"
_default_sub = SUB_VALUE
- shutter = Cpt(MonitorSignal, name="shutter", auto_monitor=True)
-
# -----------------------------------------------------
# User-facing shutter control functions
# -----------------------------------------------------
- # pylint: disable=invalid-name
- def _check_if_cSAXS_shutter_exists_in_config(self) -> bool:
- """
- Check on the device manager if the shutter device exists.
-
- Returns:
- bool: True if the 'fsh' device exists in the device manager, False otherwise
- """
- if self.device_manager.devices.get("fsh", None) is None:
- logger.warning(f"Fast shutter device not found for {self.name}.")
- return False
- return True
-
def fshopen(self):
"""Open the fast shutter."""
- if self._check_if_cSAXS_shutter_exists_in_config():
- return self.device_manager.devices["fsh"].fshopen()
- else:
- self.shutter.put(1)
+ return self.device_manager.devices["fsh"].fshopen()
def fshclose(self):
"""Close the fast shutter."""
- if self._check_if_cSAXS_shutter_exists_in_config():
- return self.device_manager.devices["fsh"].fshclose()
- else:
- self.shutter.put(0)
+ return self.device_manager.devices["fsh"].fshclose()
def fshstatus(self):
"""Return the fast shutter status (0=closed, 1=open)."""
- if self._check_if_cSAXS_shutter_exists_in_config():
- return self.device_manager.devices["fsh"].fshstatus()
- else:
- return self.shutter.get()
+ return self.device_manager.devices["fsh"].fshstatus()
def fshinfo(self):
"""Print information about which EPICS PV channel is being used."""
- if self._check_if_cSAXS_shutter_exists_in_config():
- return self.device_manager.devices["fsh"].fshinfo()
- else:
- print("Using dummy fast shutter device. No EPICS channel is connected.")
+ return self.device_manager.devices["fsh"].fshinfo()
+
+ def fshstatus_readback(self):
+ """Return the fast shutter status (0=closed, 1=open) from the readback signal."""
+ return self.device_manager.devices["fsh"].fshstatus_readback()
def help(self):
"""Display available user methods."""
print("Available methods:")
for method in self.USER_ACCESS:
print(f" - {method}")
-
- def fshstatus_readback(self):
- """Return the fast shutter status (0=closed, 1=open) from the readback signal."""
- if self._check_if_cSAXS_shutter_exists_in_config():
- return self.device_manager.devices["fsh"].fshstatus_readback()
- else:
- self.shutter.get()
diff --git a/tests/tests_devices/test_omny_shutter.py b/tests/tests_devices/test_omny_shutter.py
index b2bc1c9d..85161b69 100644
--- a/tests/tests_devices/test_omny_shutter.py
+++ b/tests/tests_devices/test_omny_shutter.py
@@ -1,69 +1,51 @@
+from unittest import mock
+
import pytest
from bec_server.device_server.tests.utils import DMMock
-from csaxs_bec.devices.omny.shutter import MonitorSignal, OMNYFastShutter
-
-
-@pytest.mark.parametrize("auto_monitor", [False, True])
-def test_monitor_signal_stores_auto_monitor(auto_monitor):
- signal = MonitorSignal(name="signal", auto_monitor=auto_monitor)
-
- assert signal.auto_monitor is auto_monitor
-
-
-def test_monitor_signal_put_propagates_value_to_readback_callback():
- signal = MonitorSignal(name="signal", auto_monitor=True)
- initial_value = signal.read()[signal.name]["value"]
- callback_values = []
- callback_reads = []
-
- def _test_cb(value, old_value, **kwargs):
- callback_values.append((value, old_value))
- callback_reads.append(kwargs["obj"].read())
-
- signal.subscribe(_test_cb, event_type=signal.SUB_VALUE, run=False)
-
- signal.put(1)
-
- assert callback_values == [(1, initial_value)]
- assert len(callback_reads) == 1
- assert callback_reads[0][signal.name]["value"] == 1
- assert signal.read()[signal.name]["value"] == 1
-
- signal.put(0)
- assert callback_values == [(1, initial_value), (0, 1)]
- assert len(callback_reads) == 2
- assert callback_reads[1][signal.name]["value"] == 0
- assert signal.read()[signal.name]["value"] == 0
+from csaxs_bec.devices.omny.shutter import OMNYFastShutter
@pytest.fixture
def omny_fast_shutter():
- shutter = OMNYFastShutter(name="omny_fast_shutter", device_manager=DMMock())
+ device_manager = DMMock()
+ fsh = mock.MagicMock()
+ device_manager.devices["fsh"] = fsh
+ shutter = OMNYFastShutter(name="omny_fast_shutter", device_manager=device_manager)
try:
- yield shutter
+ yield shutter, fsh
finally:
shutter.destroy()
-def test_omny_fast_shutter_uses_monitor_signal_with_auto_monitor(omny_fast_shutter):
- assert isinstance(omny_fast_shutter.shutter, MonitorSignal)
- assert omny_fast_shutter.shutter.auto_monitor is True
+def test_omny_fast_shutter_fshopen_delegates_to_fsh(omny_fast_shutter):
+ shutter, fsh = omny_fast_shutter
+ shutter.fshopen()
+ fsh.fshopen.assert_called_once()
-def test_omny_fast_shutter_propagates_signal_changes_to_device_readback(omny_fast_shutter):
- signal_name = omny_fast_shutter.shutter.name
- callback_reads = []
+def test_omny_fast_shutter_fshclose_delegates_to_fsh(omny_fast_shutter):
+ shutter, fsh = omny_fast_shutter
+ shutter.fshclose()
+ fsh.fshclose.assert_called_once()
- def _test_cb(**kwargs):
- callback_reads.append(omny_fast_shutter.read())
- omny_fast_shutter.shutter.subscribe(_test_cb, event_type=omny_fast_shutter.shutter.SUB_VALUE, run=False)
+def test_omny_fast_shutter_fshstatus_delegates_to_fsh(omny_fast_shutter):
+ shutter, fsh = omny_fast_shutter
+ fsh.fshstatus.return_value = 1
+ assert shutter.fshstatus() == 1
+ fsh.fshstatus.assert_called_once()
- omny_fast_shutter.shutter.put(1)
- assert len(callback_reads) == 1
- assert callback_reads[0][signal_name]["value"] == 1
- assert omny_fast_shutter.read()[signal_name]["value"] == 1
- assert omny_fast_shutter.fshstatus() == 1
+def test_omny_fast_shutter_fshstatus_readback_delegates_to_fsh(omny_fast_shutter):
+ shutter, fsh = omny_fast_shutter
+ fsh.fshstatus_readback.return_value = 0
+ assert shutter.fshstatus_readback() == 0
+ fsh.fshstatus_readback.assert_called_once()
+
+
+def test_omny_fast_shutter_fshinfo_delegates_to_fsh(omny_fast_shutter):
+ shutter, fsh = omny_fast_shutter
+ shutter.fshinfo()
+ fsh.fshinfo.assert_called_once()
From 7dc8de741c64767022dbcfd9076b81210213fd99 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Fri, 4 Sep 2026 16:55:00 +0200
Subject: [PATCH 050/102] fix(sample_storage widget): rename "Change name..."
context-menu action to "Change sample..."
The action replaces both name and owner and resets the slot's measured
status/account back to "none" -- "change name" undersold what it
actually does. Renamed action_change_name() -> action_change_sample()
to match, and documented the status-reset behavior in its docstring.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../widgets/sample_storage/sample_storage.py | 26 +++++++++++--------
1 file changed, 15 insertions(+), 11 deletions(-)
diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
index 64f5e99c..b8294780 100644
--- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
+++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
@@ -420,11 +420,11 @@ class _SlotCell(QFrame):
def _show_menu(self, pos) -> None:
menu = QMenu(self)
if self._occupied:
- act_rename = menu.addAction("Change name…")
+ act_rename = menu.addAction("Change sample…")
act_clear = menu.addAction("Clear (set empty)")
chosen = menu.exec_(self.mapToGlobal(pos))
if chosen == act_rename:
- self._owner.action_change_name(self._slot, self._name, self._sample_owner)
+ self._owner.action_change_sample(self._slot, self._name, self._sample_owner)
elif chosen == act_clear:
self._owner.action_clear(self._slot)
else:
@@ -1002,7 +1002,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def _write_sample(self, slot, used: int, name: str, owner: str = "") -> bool:
"""Shared write dispatcher used by the sample-cell mutation actions
- (action_change_name/action_new_sample/action_clear) for both
+ (action_change_sample/action_new_sample/action_clear) for both
setups: OMNY keys are tuples (see _SlotCell), FlOMNI keys are
plain ints.
"""
@@ -1210,7 +1210,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def _find_duplicate_name(self, slot, name: str):
"""Return the slot key that already holds `name`, if any OTHER
occupied slot of the active setup currently has this exact name, or
- None. Used by action_change_name()/action_new_sample() to enforce
+ None. Used by action_change_sample()/action_new_sample() to enforce
that sample names are unique -- otherwise the measured-sample log
(FlOMNI, keyed by name+owner) and operators reading the storage
overview could no longer tell two same-named samples apart.
@@ -1230,11 +1230,15 @@ class OMNY_SampleStorage(BECWidget, QWidget):
return key
return None
- def action_change_name(self, slot, current_name: str, current_owner: str = "") -> None:
- """Rename an occupied slot (stays occupied). Shared by both setups
- (see ``_SlotCell``) -- both require a name and an owner (see module
- docstring), and both require the name to be unique across the
- active setup's slots (see ``_find_duplicate_name``).
+ def action_change_sample(self, slot, current_name: str, current_owner: str = "") -> None:
+ """Replace an occupied slot's sample identity (name + owner), stays
+ occupied. Named "change sample" rather than "rename" because
+ _write_sample() resets the slot's measured status/account back to
+ "none" -- appropriate since a new name+owner means a different
+ physical sample, not a correction to the existing one's record.
+ Shared by both setups (see ``_SlotCell``) -- both require a name and
+ an owner (see module docstring), and both require the name to be
+ unique across the active setup's slots (see ``_find_duplicate_name``).
"""
name, ok = QInputDialog.getText(
self,
@@ -1279,7 +1283,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def action_new_sample(self, slot) -> None:
"""Mark an empty slot occupied and give it a name. Shared by both
- setups (see ``action_change_name``), including the uniqueness check.
+ setups (see ``action_change_sample``), including the uniqueness check.
"""
name, ok = QInputDialog.getText(
self, "New sample", f"Sample name for slot {self._slot_title(slot)}:"
@@ -1313,7 +1317,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def action_clear(self, slot) -> None:
"""Mark a slot empty (destructive; confirmed). Shared by both
- setups (see ``action_change_name``).
+ setups (see ``action_change_sample``).
"""
reply = QMessageBox.question(
self,
From 953d381acdc782a6c8ef455f6c7d7455bad06cea Mon Sep 17 00:00:00 2001
From: x01dc
Date: Fri, 4 Sep 2026 17:00:07 +0200
Subject: [PATCH 051/102] revert(omny): otransfer_park_loadlock_slot
confirmation back to CLI, not GUI
Requested revert of one call site from the earlier GUI-console-confirm
sweep: this confirmation is about the operator physically checking the
leica transfer's back position, not something they'd be watching the
camera GUI console for, so it stays a plain terminal yesno() prompt.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../plugins/omny/omny_sample_transfer_mixin.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 2ae14aa3..7bd6b50a 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -493,7 +493,10 @@ class OMNYSampleTransferMixin:
)
# if the valve of the loadlock is not closed, ask before movement
# todo if(epics_get("XOMNY-ES1-LL1VG1:POSITION")!="CLOSED") {
- if not self.otransfer_confirm_dialog("Please confirm that the leica transfer is in back position."):
+ # CLI prompt on purpose, not otransfer_confirm_dialog() -- the operator
+ # is physically at the loadlock/leica transfer, not watching the GUI
+ # camera console, when confirming this.
+ if not self.OMNYTools.yesno("Please confirm that the leica transfer is in back position."):
raise OMNYTransferError("Leica shuttle is not in back position.")
else:
self._oparkz_leica_slot(slot_number)
From f50be3aeb4c23780bac21a57db89c63950242be3 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Fri, 4 Sep 2026 17:05:04 +0200
Subject: [PATCH 052/102] fix(omny): _oparkz_slot_check() recognize
loadlock/leica parking targets too
otransfer_park_loadlock_slot() (_oparkz_leica_slot()) parks oparkz at a
different offset (base -6) than otransfer_park_slot() (base -0.9) for
the same slot numbers. _oparkz_slot_check() -- used throughout the
transfer flow, including otransfer_put_sample()/otransfer_get_sample()'s
availability checks -- only recognized the regular-gripper (-0.9) table,
so after parking via the loadlock (as the GUI correctly showed "ACTIVE"
for, per the earlier oparkz_active_parking_slot() widget fix), any
transfer call still raised "Parking stage is not at an active parking
position." Now checks both tables, mirroring sample_storage.py's
oparkz_active_parking_slot() on the widget side.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../omny/omny_sample_transfer_mixin.py | 46 ++++++++++++----
.../test_omny_sample_transfer_mixin.py | 54 +++++++++++++++++++
2 files changed, 90 insertions(+), 10 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_omny_sample_transfer_mixin.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 7bd6b50a..2077d965 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -26,6 +26,30 @@ class OMNYTransferError(Exception):
pass
+# otransfer_park_slot()'s oparkz targets (regular gripper transfer path).
+_OPARKZ_TARGET_POSITIONS = {
+ 2: -0.9 - 29 * 3 - 42 - 0.05,
+ 3: -0.9 - 29 * 3,
+ 4: -0.9 - 29 * 2,
+ 5: -0.9 - 29,
+ 6: -0.9,
+}
+# otransfer_park_loadlock_slot()'s targets (_oparkz_leica_slot()) -- the
+# *same* slot numbers, reached via the loadlock/leica transfer path instead
+# of the regular gripper path, at a different oparkz offset (base -6, not
+# -0.9). Both genuinely mean "the parking stage is at that slot", so
+# _oparkz_slot_check() must recognize either -- see also
+# sample_storage.py's oparkz_active_parking_slot(), which duplicates this
+# same pair of tables on the widget side of the device-server/GUI boundary.
+_OPARKZ_LEICA_TARGET_POSITIONS = {
+ 2: -6 - 29 * 3 - 42 - 0.05,
+ 3: -6 - 29 * 3,
+ 4: -6 - 29 * 2,
+ 5: -6 - 29,
+ 6: -6,
+}
+
+
class OMNYSampleTransferMixin:
def __init__(self) -> None:
@@ -535,17 +559,19 @@ class OMNYSampleTransferMixin:
return False
def _oparkz_slot_check(self):
+ # Checks both the regular-gripper (_OPARKZ_TARGET_POSITIONS) and
+ # loadlock/leica (_OPARKZ_LEICA_TARGET_POSITIONS) target tables --
+ # otransfer_park_loadlock_slot() parks at a different oparkz offset
+ # than otransfer_park_slot() for the same slot number, and both
+ # genuinely mean the parking stage is at that slot.
parkingposition = 10
- if self._oparkz_check(-0.9):
- parkingposition = 6
- elif self._oparkz_check(-0.9 - 29):
- parkingposition = 5
- elif self._oparkz_check(-0.9 - 29 * 2):
- parkingposition = 4
- elif self._oparkz_check(-0.9 - 29 * 3):
- parkingposition = 3
- elif self._oparkz_check(-0.9 - 29 * 3 - 42 - 0.05):
- parkingposition = 2
+ for targets in (_OPARKZ_TARGET_POSITIONS, _OPARKZ_LEICA_TARGET_POSITIONS):
+ for slot, target in targets.items():
+ if self._oparkz_check(target):
+ parkingposition = slot
+ break
+ if parkingposition != 10:
+ break
if parkingposition == 10:
print("Oparkz is not in a parking position.")
diff --git a/tests/tests_bec_ipython_client/test_omny_sample_transfer_mixin.py b/tests/tests_bec_ipython_client/test_omny_sample_transfer_mixin.py
new file mode 100644
index 00000000..54c18730
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_omny_sample_transfer_mixin.py
@@ -0,0 +1,54 @@
+from unittest import mock
+
+import pytest
+
+from csaxs_bec.bec_ipython_client.plugins.omny.omny_sample_transfer_mixin import (
+ _OPARKZ_LEICA_TARGET_POSITIONS,
+ _OPARKZ_TARGET_POSITIONS,
+ OMNYSampleTransferMixin,
+)
+
+# pylint: disable=protected-access
+
+MIXIN = "csaxs_bec.bec_ipython_client.plugins.omny.omny_sample_transfer_mixin"
+
+
+def _mixin_at(readback: float) -> OMNYSampleTransferMixin:
+ mixin = OMNYSampleTransferMixin()
+ dev_mock = mock.MagicMock()
+ dev_mock.oparkz.get.return_value.readback = readback
+ mixin._dev_patch = mock.patch(f"{MIXIN}.dev", dev_mock, create=True)
+ mixin._dev_patch.start()
+ return mixin
+
+
+@pytest.mark.parametrize("slot,target", sorted(_OPARKZ_TARGET_POSITIONS.items()))
+def test_oparkz_slot_check_matches_each_regular_gripper_target(slot, target):
+ mixin = _mixin_at(target)
+ try:
+ assert mixin._oparkz_slot_check() == slot
+ finally:
+ mixin._dev_patch.stop()
+
+
+@pytest.mark.parametrize("slot,target", sorted(_OPARKZ_LEICA_TARGET_POSITIONS.items()))
+def test_oparkz_slot_check_matches_each_leica_target(slot, target):
+ """otransfer_park_loadlock_slot() parks at the leica offset (base -6),
+ not the regular gripper offset (base -0.9) -- both must be recognized
+ as a valid active parking slot, or otransfer_put_sample()/
+ otransfer_get_sample() wrongly raise "Parking stage is not at an
+ active parking position" right after a loadlock park even though the
+ GUI (and the operator) can see it parked there."""
+ mixin = _mixin_at(target)
+ try:
+ assert mixin._oparkz_slot_check() == slot
+ finally:
+ mixin._dev_patch.stop()
+
+
+def test_oparkz_slot_check_returns_10_between_positions():
+ mixin = _mixin_at(-3.0)
+ try:
+ assert mixin._oparkz_slot_check() == 10
+ finally:
+ mixin._dev_patch.stop()
From 96fabeb1c7032bee6bc5ac679a5a731e024c1f9d Mon Sep 17 00:00:00 2001
From: x01dc
Date: Fri, 4 Sep 2026 17:16:25 +0200
Subject: [PATCH 053/102] fix(omny): simulate oshuttlealign's quirky
low-limit-trips-positive hardware behavior
_otransfer_shuttle_align()'s own comment documents this as "a routine
for broken hardware": on real hardware, driving the shuttle aligner
(oshuttlealign, axis H) positive of its forward-reference zero also
trips the low/reverse limit switch, due to how the two switches are
wired -- not just the normal reference-axis routine. The sim had no
concept of this: plain PR/BG moves never update limit_low_active/
limit_high_active at all (those flags are only ever set by the
separate #FES/#FRM reference-axis path), so the routine's final
"searchwrongendswitch" search loop (a 22-iteration, +200-step-at-a-time
hunt for the low switch) could never find it and the whole
otransfer_put_sample()/otransfer_get_sample() flow hung until RPC
timeout, raising "The shuttle aligner did not move back."
Added a per-axis opt-in flag (quirky_positive_trips_low_limit, wired
through configure_axis()/_sim_state_kwargs()/SimOMNYGalilMotor) that
makes the simulated low limit switch position-derived (active whenever
position > 0) instead of purely flag-based, and set it only for
oshuttlealign in simulated_omny.yaml. Verified end-to-end against a
replay of the real command sequence (reference, +200 recovery nudge,
the five alignment excursions, then the search loop) -- the low switch
is now found within the 22-iteration budget, same as real hardware.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../simulated_omny/simulated_omny.yaml | 7 +
csaxs_bec/devices/sim/sim_galil.py | 15 +++
csaxs_bec/devices/sim/sim_omny.py | 2 +
...st_sim_galil_oshuttlealign_quirky_limit.py | 122 ++++++++++++++++++
4 files changed, 146 insertions(+)
create mode 100644 tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
index dbd84c4c..ce3a87dd 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
@@ -493,6 +493,13 @@ oshuttlealign:
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
+ # _otransfer_shuttle_align()'s "routine for broken hardware" comment
+ # (omny_sample_transfer_mixin.py): the negative/low limit switch is
+ # wired close enough to the positive/high one that it also trips once
+ # driven positive of the forward-reference zero point, not just via
+ # normal referencing. Without this, the alignment routine's final
+ # "searchwrongendswitch" loop never finds the low switch and times out.
+ sim_quirky_positive_trips_low_limit: true
enabled: true
onFailure: buffer
readOnly: true
diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py
index c61e8b86..e13cf17f 100644
--- a/csaxs_bec/devices/sim/sim_galil.py
+++ b/csaxs_bec/devices/sim/sim_galil.py
@@ -99,6 +99,13 @@ class _SimGalilAxis:
self._move_t1 = 0.0
self.limit_low_active = False
self.limit_high_active = False
+ # Models oshuttlealign's documented "broken hardware" quirk
+ # (omny_sample_transfer_mixin.py's _otransfer_shuttle_align()): the
+ # low/reverse limit switch is wired close enough to the high/forward
+ # one that it also trips once the axis is driven positive of its
+ # forward-reference zero point -- not just via the normal
+ # reference-axis routine. See the _LR handling in _handle_mg().
+ self.quirky_positive_trips_low_limit = False
def position(self) -> float:
now = time.time()
@@ -222,6 +229,10 @@ class SimGalilState:
ax.limit_low_active = bool(kwargs["limit_low_active"])
if kwargs.get("limit_high_active") is not None:
ax.limit_high_active = bool(kwargs["limit_high_active"])
+ if kwargs.get("quirky_positive_trips_low_limit") is not None:
+ ax.quirky_positive_trips_low_limit = bool(
+ kwargs["quirky_positive_trips_low_limit"]
+ )
self.recompute_allaxref()
def thread_active(self, thread_id: int) -> bool:
@@ -516,6 +527,8 @@ class SimGalilSocket(SimSocketBase):
for token, axis_char in zip(re.findall(r"_L[RF]", arg), axes):
ax = state.axis(ord(axis_char.lower()) - 97)
active = ax.limit_low_active if token == "_LR" else ax.limit_high_active
+ if token == "_LR" and ax.quirky_positive_trips_low_limit and ax.position() > 0:
+ active = True
parts.append(f"{0.0 if active else 1.0:.4f}")
return " ".join(parts)
if arg.startswith("_TD"):
@@ -640,6 +653,7 @@ def _sim_state_kwargs(
sim_encpermm=None,
sim_digital_outputs=None,
sim_variables=None,
+ sim_quirky_positive_trips_low_limit=None,
) -> dict:
initial_position = None
if sim_initial_position is not None:
@@ -651,6 +665,7 @@ def _sim_state_kwargs(
"referenced": sim_referenced,
"analog_inputs": sim_analog_inputs,
"digital_inputs": sim_digital_inputs,
+ "quirky_positive_trips_low_limit": sim_quirky_positive_trips_low_limit,
"limit_low_active": sim_limit_low_active,
"limit_high_active": sim_limit_high_active,
"encpermm": sim_encpermm,
diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py
index 19a6015e..1ed23d9f 100644
--- a/csaxs_bec/devices/sim/sim_omny.py
+++ b/csaxs_bec/devices/sim/sim_omny.py
@@ -72,6 +72,7 @@ class SimOMNYGalilMotor(OMNYGalilMotor):
sim_limit_low_active=None,
sim_limit_high_active=None,
sim_variables=None,
+ sim_quirky_positive_trips_low_limit=None,
**kwargs,
):
super().__init__(
@@ -112,6 +113,7 @@ class SimOMNYGalilMotor(OMNYGalilMotor):
self.sign,
sim_digital_outputs=sim_digital_outputs,
sim_variables=variables,
+ sim_quirky_positive_trips_low_limit=sim_quirky_positive_trips_low_limit,
),
)
diff --git a/tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py b/tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py
new file mode 100644
index 00000000..98022baf
--- /dev/null
+++ b/tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py
@@ -0,0 +1,122 @@
+import time
+
+import pytest
+from bec_server.device_server.tests.utils import DMMock
+
+from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilController
+from csaxs_bec.devices.sim.sim_omny import SimOMNYGalilMotor
+from csaxs_bec.devices.sim.sim_socket import SimStateRegistry
+
+_HOST = "mpc3217.psi.ch"
+_PORT = 8082
+
+
+@pytest.fixture(autouse=True)
+def _reset_sim_state():
+ OMNYGalilController._reset_controller()
+ SimStateRegistry.reset()
+ yield
+ SimStateRegistry.reset()
+ OMNYGalilController._reset_controller()
+
+
+def _make_align(quirky: bool) -> SimOMNYGalilMotor:
+ align = SimOMNYGalilMotor(
+ "H",
+ name="oshuttlealign",
+ host=_HOST,
+ port=_PORT,
+ sign=1,
+ device_manager=DMMock(),
+ sim_initial_position=0,
+ sim_velocity=100.0,
+ sim_quirky_positive_trips_low_limit=quirky,
+ )
+ align.wait_for_connection()
+ return align
+
+
+def _drive_relative(align: SimOMNYGalilMotor, steps: int):
+ align.controller.socket_put_confirmed(f"PRH={steps}")
+ align.controller.socket_put_confirmed("BGH")
+ while bool(float(align.controller.socket_put_and_receive("MG_BGH"))):
+ time.sleep(0.01)
+
+
+def test_low_limit_inactive_at_position_zero_even_when_quirky():
+ """Matches the real hardware log: right after reaching the forward/high
+ reference (position 0), the low switch has NOT yet tripped -- only a
+ subsequent positive move trips it. Otherwise
+ _otransfer_shuttle_align()'s "Not in correct start position for
+ alignment." branch (and the whole recovery routine it guards) would
+ never even run."""
+ align = _make_align(quirky=True)
+ try:
+ assert align.get_motor_limit_switch()[0] is False
+ finally:
+ align.destroy()
+
+
+def test_quirky_low_limit_trips_after_positive_move_past_zero():
+ align = _make_align(quirky=True)
+ try:
+ _drive_relative(align, 200)
+ assert align.get_motor_limit_switch()[0] is True
+ finally:
+ align.destroy()
+
+
+def test_quirky_low_limit_inactive_when_negative_of_zero():
+ align = _make_align(quirky=True)
+ try:
+ _drive_relative(align, -5000)
+ assert align.get_motor_limit_switch()[0] is False
+ finally:
+ align.destroy()
+
+
+def test_non_quirky_axis_never_trips_low_limit_from_position_alone():
+ """The quirk is opt-in per axis (only oshuttlealign has it configured) --
+ an ordinary axis at a positive position must not spuriously report its
+ low limit switch as active."""
+ align = _make_align(quirky=False)
+ try:
+ _drive_relative(align, 200)
+ assert align.get_motor_limit_switch()[0] is False
+ finally:
+ align.destroy()
+
+
+def test_shuttle_align_search_loop_finds_low_switch_within_budget():
+ """End-to-end replay of _otransfer_shuttle_align()'s real command
+ sequence (omny_sample_transfer_mixin.py): reach the forward/high
+ reference, nudge +200 (recovery), run the big alignment excursions,
+ then the final +200-step search loop (budget: 22 iterations) must find
+ the low switch -- this is the exact sequence that timed out before
+ sim_quirky_positive_trips_low_limit was wired up."""
+ align = _make_align(quirky=True)
+ try:
+ # reach the forward/high reference the same way drive_axis_to_limit() does
+ align.controller.socket_put_confirmed("naxis=7")
+ align.controller.socket_put_confirmed("ndir=1")
+ align.controller.socket_put_confirmed("XQ#NEWPAR")
+ align.controller.socket_put_confirmed("XQ#FES")
+ assert align.get_motor_limit_switch() == [False, True]
+
+ # recovery nudge
+ _drive_relative(align, 200)
+
+ # big alignment excursions
+ for steps in (-5000, -19500, 3000, -3000, 23500):
+ _drive_relative(align, steps)
+ assert align.get_motor_limit_switch()[0] is False
+
+ # final search loop
+ for i in range(22):
+ _drive_relative(align, 200)
+ if align.get_motor_limit_switch()[0]:
+ break
+ else:
+ pytest.fail("search loop never found the low limit switch")
+ finally:
+ align.destroy()
From a68f48d9136afd9c6b3b18ac7667365681ab6662 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Fri, 4 Sep 2026 17:26:46 +0200
Subject: [PATCH 054/102] fix(omny): oshuttlealign starts pre-referenced;
simulate oshuttleopen's open-detection limit
- oshuttlealign: start the sim already at the forward/high (down)
reference (sim_limit_high_active: true), matching real hardware's
typical idle state, instead of re-driving to a limit it was already
at on the first alignment each session.
- oshuttleopen: _otransfer_shield_open() drives it open with plain
PRG=17000/PRG=37000 (never through the naxis/XQ#NEWPAR reference-axis
routine that normally flips limit-switch flags), so
_otransfer_is_shuttle_open()'s high-limit check could never see it as
open -- "Did not manage to open the lid" fired even though the move
completed. Added a general, opt-in position-derived high-limit
mechanism to the Galil sim (high_limit_trip_position_steps, wired
through configure_axis()/_sim_state_kwargs()/SimOMNYGalilMotor) and
set it for oshuttleopen at 1.0 mm (comfortably below the ~1.05 mm/
54000-step total commanded excursion).
Verified both against a standalone replay of the real command
sequences before writing tests.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../simulated_omny/simulated_omny.yaml | 13 +++
csaxs_bec/devices/sim/sim_galil.py | 20 +++++
csaxs_bec/devices/sim/sim_omny.py | 2 +
...st_sim_galil_oshuttlealign_quirky_limit.py | 25 ++++++
.../test_sim_galil_oshuttleopen_high_limit.py | 80 +++++++++++++++++++
5 files changed, 140 insertions(+)
create mode 100644 tests/tests_devices/test_sim_galil_oshuttleopen_high_limit.py
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
index ce3a87dd..4943cff2 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml
@@ -471,6 +471,14 @@ oshuttleopen:
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
+ # _otransfer_shield_open() drives this axis open with plain PRG=17000
+ # then PRG=37000 (omny_sample_transfer_mixin.py) -- never through the
+ # naxis/XQ#NEWPAR reference-axis routine that normally flips the
+ # limit-switch flags -- so without a position-derived trip point,
+ # _otransfer_is_shuttle_open() (checks the high/forward limit) never
+ # sees it as open. 1.0 mm is comfortably below the ~1.05 mm (54000
+ # step) total commanded excursion.
+ sim_high_limit_trip_position: 1.0
enabled: true
onFailure: buffer
readOnly: true
@@ -493,6 +501,11 @@ oshuttlealign:
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
+ # Real hardware idles with the shuttle aligner down (at the forward/high
+ # limit, _otransfer_shuttle_aligner_down()'s target) -- start the sim
+ # already there so the first alignment each session doesn't spend time
+ # re-driving to a limit it was already sitting at.
+ sim_limit_high_active: true
# _otransfer_shuttle_align()'s "routine for broken hardware" comment
# (omny_sample_transfer_mixin.py): the negative/low limit switch is
# wired close enough to the positive/high one that it also trips once
diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py
index e13cf17f..3beedc2e 100644
--- a/csaxs_bec/devices/sim/sim_galil.py
+++ b/csaxs_bec/devices/sim/sim_galil.py
@@ -106,6 +106,14 @@ class _SimGalilAxis:
# forward-reference zero point -- not just via the normal
# reference-axis routine. See the _LR handling in _handle_mg().
self.quirky_positive_trips_low_limit = False
+ # Optional position-derived high/forward limit (steps): some OMNY
+ # axes are driven open-loop with plain PR{axis}=/BG{axis} (no
+ # naxis/XQ#NEWPAR reference-axis routine involved at all -- see
+ # e.g. _otransfer_shield_open()'s oshuttleopen "PRG=17000"/"PRG=37000"
+ # sequence), so limit_high_active would otherwise never become True
+ # for them. When set, the high/forward limit reads active once
+ # position() reaches this threshold, same as a real physical switch.
+ self.high_limit_trip_position_steps = None
def position(self) -> float:
now = time.time()
@@ -233,6 +241,10 @@ class SimGalilState:
ax.quirky_positive_trips_low_limit = bool(
kwargs["quirky_positive_trips_low_limit"]
)
+ if kwargs.get("high_limit_trip_position") is not None:
+ ax.high_limit_trip_position_steps = (
+ float(kwargs["high_limit_trip_position"]) * ax.stppermm
+ )
self.recompute_allaxref()
def thread_active(self, thread_id: int) -> bool:
@@ -529,6 +541,12 @@ class SimGalilSocket(SimSocketBase):
active = ax.limit_low_active if token == "_LR" else ax.limit_high_active
if token == "_LR" and ax.quirky_positive_trips_low_limit and ax.position() > 0:
active = True
+ if (
+ token == "_LF"
+ and ax.high_limit_trip_position_steps is not None
+ and ax.position() >= ax.high_limit_trip_position_steps
+ ):
+ active = True
parts.append(f"{0.0 if active else 1.0:.4f}")
return " ".join(parts)
if arg.startswith("_TD"):
@@ -654,6 +672,7 @@ def _sim_state_kwargs(
sim_digital_outputs=None,
sim_variables=None,
sim_quirky_positive_trips_low_limit=None,
+ sim_high_limit_trip_position=None,
) -> dict:
initial_position = None
if sim_initial_position is not None:
@@ -666,6 +685,7 @@ def _sim_state_kwargs(
"analog_inputs": sim_analog_inputs,
"digital_inputs": sim_digital_inputs,
"quirky_positive_trips_low_limit": sim_quirky_positive_trips_low_limit,
+ "high_limit_trip_position": sim_high_limit_trip_position,
"limit_low_active": sim_limit_low_active,
"limit_high_active": sim_limit_high_active,
"encpermm": sim_encpermm,
diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py
index 1ed23d9f..5cfb4f00 100644
--- a/csaxs_bec/devices/sim/sim_omny.py
+++ b/csaxs_bec/devices/sim/sim_omny.py
@@ -73,6 +73,7 @@ class SimOMNYGalilMotor(OMNYGalilMotor):
sim_limit_high_active=None,
sim_variables=None,
sim_quirky_positive_trips_low_limit=None,
+ sim_high_limit_trip_position=None,
**kwargs,
):
super().__init__(
@@ -114,6 +115,7 @@ class SimOMNYGalilMotor(OMNYGalilMotor):
sim_digital_outputs=sim_digital_outputs,
sim_variables=variables,
sim_quirky_positive_trips_low_limit=sim_quirky_positive_trips_low_limit,
+ sim_high_limit_trip_position=sim_high_limit_trip_position,
),
)
diff --git a/tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py b/tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py
index 98022baf..ccf9fbf3 100644
--- a/tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py
+++ b/tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py
@@ -57,6 +57,31 @@ def test_low_limit_inactive_at_position_zero_even_when_quirky():
align.destroy()
+def test_can_start_pre_referenced_already_down():
+ """simulated_omny.yaml sets sim_limit_high_active: true for
+ oshuttlealign so the sim starts already at the forward/high (down)
+ reference -- matching real hardware's typical idle state -- so
+ _otransfer_shuttle_aligner_down() doesn't need to re-drive to a limit
+ it was already sitting at on the first alignment each session."""
+ align = SimOMNYGalilMotor(
+ "H",
+ name="oshuttlealign",
+ host=_HOST,
+ port=_PORT,
+ sign=1,
+ device_manager=DMMock(),
+ sim_initial_position=0,
+ sim_velocity=100.0,
+ sim_quirky_positive_trips_low_limit=True,
+ sim_limit_high_active=True,
+ )
+ align.wait_for_connection()
+ try:
+ assert align.get_motor_limit_switch() == [False, True]
+ finally:
+ align.destroy()
+
+
def test_quirky_low_limit_trips_after_positive_move_past_zero():
align = _make_align(quirky=True)
try:
diff --git a/tests/tests_devices/test_sim_galil_oshuttleopen_high_limit.py b/tests/tests_devices/test_sim_galil_oshuttleopen_high_limit.py
new file mode 100644
index 00000000..adb1eb67
--- /dev/null
+++ b/tests/tests_devices/test_sim_galil_oshuttleopen_high_limit.py
@@ -0,0 +1,80 @@
+import time
+
+import pytest
+from bec_server.device_server.tests.utils import DMMock
+
+from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilController
+from csaxs_bec.devices.sim.sim_omny import SimOMNYGalilMotor
+from csaxs_bec.devices.sim.sim_socket import SimStateRegistry
+
+_HOST = "mpc3217.psi.ch"
+_PORT = 8082
+
+
+@pytest.fixture(autouse=True)
+def _reset_sim_state():
+ OMNYGalilController._reset_controller()
+ SimStateRegistry.reset()
+ yield
+ SimStateRegistry.reset()
+ OMNYGalilController._reset_controller()
+
+
+def _make_opener(high_limit_trip_position=None) -> SimOMNYGalilMotor:
+ opener = SimOMNYGalilMotor(
+ "G",
+ name="oshuttleopen",
+ host=_HOST,
+ port=_PORT,
+ sign=1,
+ device_manager=DMMock(),
+ sim_initial_position=0,
+ sim_velocity=100.0,
+ sim_high_limit_trip_position=high_limit_trip_position,
+ )
+ opener.wait_for_connection()
+ return opener
+
+
+def _drive_relative(opener: SimOMNYGalilMotor, steps: int):
+ opener.controller.socket_put_confirmed(f"PRG={steps}")
+ opener.controller.socket_put_confirmed("BGG")
+ while bool(float(opener.controller.socket_put_and_receive("MG_BGG"))):
+ time.sleep(0.01)
+
+
+def test_high_limit_inactive_before_reaching_trip_position():
+ opener = _make_opener(high_limit_trip_position=1.0)
+ try:
+ assert opener.get_motor_limit_switch()[1] is False
+ finally:
+ opener.destroy()
+
+
+def test_high_limit_active_matches_real_shield_open_command_sequence():
+ """Replays _otransfer_shield_open()'s exact oshuttleopen excursion
+ (omny_sample_transfer_mixin.py: PRG=17000 then PRG=37000, ~1.05 mm
+ total at this axis's resolution) -- the high/forward limit must read
+ active afterward, or _otransfer_is_shuttle_open() never sees the
+ shuttle as open and otransfer_put_sample()/otransfer_get_sample()
+ raise "Did not manage to open the lid" even though the move completed."""
+ opener = _make_opener(high_limit_trip_position=1.0)
+ try:
+ _drive_relative(opener, 17000)
+ _drive_relative(opener, 37000)
+ assert opener.get_motor_limit_switch()[1] is True
+ finally:
+ opener.destroy()
+
+
+def test_high_limit_trip_position_is_opt_in():
+ """Without sim_high_limit_trip_position configured, plain PR/BG moves
+ must not spuriously report the high limit as active (matches every
+ other axis's existing behavior)."""
+ opener = _make_opener(high_limit_trip_position=None)
+ try:
+ _drive_relative(opener, 17000)
+ _drive_relative(opener, 37000)
+ assert opener.get_motor_limit_switch()[1] is False
+ finally:
+ opener.destroy()
From a0cc2167fd1a688f9a5a1a0dd5c3621115f4d968 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Fri, 4 Sep 2026 17:57:25 +0200
Subject: [PATCH 055/102] fix(omny): seed shuttle A at parking slot 6 by
default; drop redundant y/n hint from GUI confirm
- sim_omny.py: SimOMNYSampleStorage now seeds shuttle A (which already
holds the default sim_sample_1/2/3) as parked at slot 6 -- saves
calling dev.omny_samples.set_shuttle_slot('A', 6) by hand at the
start of every test session. (oshuttlealign already starts
pre-referenced/down from the previous commit's sim_limit_high_active
change -- confirmed still in place, no further change needed there.)
- omny_general_tools.py: gui_yesno() no longer appends a "[y]/n?"-style
text hint to the message -- the ConsoleButtonsWidget already shows
actual Yes/No buttons, so the text was redundant (only the
terminal-only yesno() prompt still needs it).
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../plugins/OMNY_shared/omny_general_tools.py | 5 +--
csaxs_bec/devices/sim/sim_omny.py | 7 +++++
.../test_sim_omny_sample_storage_defaults.py | 31 +++++++++++++++++++
3 files changed, 41 insertions(+), 2 deletions(-)
create mode 100644 tests/tests_devices/test_sim_omny_sample_storage_defaults.py
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 218105fa..2b333ebd 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
@@ -145,9 +145,10 @@ class OMNYTools:
self.printgreen(message + " Automatically confirming default: no")
return False
- suffix = {"y": " [Y]/n?", "n": " y/[N]?"}.get(default, " y/n?")
+ # No "[y]/n?" suffix here (unlike yesno()'s terminal prompt) -- the
+ # widget shows actual Yes/No buttons, so the text hint is redundant.
gui.clear_response()
- gui.message = message + suffix
+ gui.message = message
while True:
response = gui.response()
if response == "yes":
diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py
index 5cfb4f00..4f91b56d 100644
--- a/csaxs_bec/devices/sim/sim_omny.py
+++ b/csaxs_bec/devices/sim/sim_omny.py
@@ -312,6 +312,13 @@ class SimOMNYSampleStorage(OMNYSampleStorage):
storage.set_sample_in_samplestage("sim_stage_sample")
for slot_nr, name in samples.items():
storage.set_sample_slot("A", int(slot_nr), str(name))
+ # shuttle A (holding the seeded samples above) starts parked at
+ # slot 6 -- the parking-carousel bookkeeping only, not oparkz's
+ # own position, which still needs otransfer_park_slot(6)/
+ # otransfer_park_loadlock_slot(6) to actually drive there. Saves
+ # having to set_shuttle_slot('A', 6) by hand at the start of
+ # every test session.
+ storage.set_shuttle_slot("A", 6)
return storage
def __init__(self, *args, sim_samples: dict | None = None, **kwargs):
diff --git a/tests/tests_devices/test_sim_omny_sample_storage_defaults.py b/tests/tests_devices/test_sim_omny_sample_storage_defaults.py
new file mode 100644
index 00000000..9aac632f
--- /dev/null
+++ b/tests/tests_devices/test_sim_omny_sample_storage_defaults.py
@@ -0,0 +1,31 @@
+import pytest
+
+from csaxs_bec.devices.sim.sim_omny import SimOMNYSampleStorage
+
+
+@pytest.fixture
+def storage():
+ dev = SimOMNYSampleStorage(name="omny_samples", prefix="XOMNY-")
+ try:
+ yield dev
+ finally:
+ dev.destroy()
+
+
+def test_shuttle_a_starts_parked_at_slot_6(storage):
+ """Saves having to set_shuttle_slot('A', 6) by hand at the start of
+ every test session -- shuttle A already holds the default seeded
+ samples (sim_sample_1/2/3), so it makes sense for it to already be
+ the one parked and ready to test against."""
+ assert storage.get_shuttle_name_slot(6) == "A"
+
+
+def test_other_parking_slots_start_empty(storage):
+ for slot in (1, 2, 3, 4, 5):
+ assert storage.get_shuttle_name_slot(slot) == "none"
+
+
+def test_default_samples_still_seeded_in_shuttle_a(storage):
+ assert storage.get_sample_name("A", 1) == "sim_sample_1"
+ assert storage.get_sample_name("A", 2) == "sim_sample_2"
+ assert storage.get_sample_name("A", 3) == "sim_sample_3"
From bd14eae6b97ecf85e28e4f0b543eb898b8c2310c Mon Sep 17 00:00:00 2001
From: x01dc
Date: Fri, 4 Sep 2026 18:01:41 +0200
Subject: [PATCH 056/102] docs(omny): make the sample storage widget the
primary workflow, CLI a reference section
The "Managing sample storage" section documented dev.omny_samples.*
calls as the normal way to view/edit storage, but day-to-day usage is
now the OMNY Sample Storage GUI widget (added from BEC Widgets' widget
palette) -- documented this session's right-click actions (Change
sample.../Clear/New sample.../Set parking slot...), the measured-status
badges, and that measured status is intentionally CLI/scan-only, not
settable from the widget.
Split into three subsections: widget usage (primary), sample transfer
(still CLI -- the widget only edits the record, it doesn't drive
hardware), and a "CLI reference for sample storage" section for
scripting/GUI-unavailable cases, updated with the owner parameter and
the measured-status+account otransfer_* commands added this session.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
docs/user/ptychography/omny.md | 68 ++++++++++++++++++++--------------
1 file changed, 41 insertions(+), 27 deletions(-)
diff --git a/docs/user/ptychography/omny.md b/docs/user/ptychography/omny.md
index 8c5719d0..855a8819 100644
--- a/docs/user/ptychography/omny.md
+++ b/docs/user/ptychography/omny.md
@@ -62,14 +62,8 @@ If this error happens after a sample was mounted or unmounted, it is important t
(user.ptychography.omny.samples)=
### Sample storage and transfer
-Following commands will provide help within BEC:
-- `omny.otransfer_help()` print help related to sample transfer
-- `dev.omny_samples.help()` print help related to sample storage
-
-#### Managing sample storage
-
-The sample holders used are [OMNY pins](https://www.dora.lib4ri.ch/psi/islandora/object/psi:4528). These are instsalled in sample transfer shuttles, which can load up to six pins. These shuttles are transferred via a vacuum load lock to the vacuum chamber. The loading procedure can be handled at room temperature or under cryogenic conditions.
-The thee __shuttles__ that exist are named __“A”, “B”, “C”__. The __pin positions__ within a shuttle are number __1 to 6__ according to the following sketch:
+The sample holders used are [OMNY pins](https://www.dora.lib4ri.ch/psi/islandora/object/psi:4528). These are installed in sample transfer shuttles, which can load up to six pins. These shuttles are transferred via a vacuum load lock to the vacuum chamber. The loading procedure can be handled at room temperature or under cryogenic conditions.
+The three __shuttles__ that exist are named __“A”, “B”, “C”__. The __pin positions__ within a shuttle are numbered __1 to 6__ according to the following sketch:
```{figure} omny_shuttle.png
OMNY sample shuttle
@@ -83,34 +77,27 @@ Each shuttle can be placed in a __parking slot__ in OMNY. The parking slots are
OMNY parking station
```
-The status of the sample storage has to be correct in BEC. This means that the status of OMNY pins within OMNY ("O") as well as the shuttles has to be correct, the pin status within the shuttles ("A", "B", "C"), as well as the status of the shuttles within the OMNY parking.
-This loading status is handled via a OMNY samples device: `dev.omny_samples`
+The status of the sample storage has to be correct in BEC. This means that the status of OMNY pins within OMNY ("O") as well as the shuttles has to be correct, the pin status within the shuttles ("A", "B", "C"), as well as the status of the shuttles within the OMNY parking.
-Within the BEC client session `dev.omny_samples.help()` will display all required commands with a short explanation.
+#### Viewing and adjusting sample storage (widget)
-To get an overview use `dev.omny_samples.show_all()` or `omny.otransfer_show_all()`
+Day to day, sample storage is viewed and adjusted through the __OMNY Sample Storage__ widget in the BEC GUI, not the CLI — add it from BEC Widgets' widget palette if it isn't already part of your layout.
-Modify a slot position of systems "A", "B", "C", "O":
-`dev.omny_samples.unset_sample_slot('system',position)` free a sample position.
-`dev.omny_samples.set_sample_slot('system',position,'name')` set a sample position. The sample has to get a _name_.
+The widget shows every position (shuttle pins A/B/C, fixed "O" positions, the gripper, and the sample stage) with its occupied state, sample name, owner, and measured status (unmeasured / ◐ started / ✓ completed). The parking-carousel view also shows which shuttle is in which parking slot and marks the currently __ACTIVE__ one (oparkz is physically sitting there, ready for transfer).
-There are two __special sample slots__:
-If a sample is in the __gripper__ and the information has to be manually changed use
-`dev.omny_samples.unset_sample_in_gripper()`
-`dev.omny_samples.set_sample_in_gripper('name')`
+Right-click a slot for:
+- __Change sample…__ — set a new name and owner for an occupied slot (replaces the sample record, including resetting its measured status back to unmeasured — use this when a physically different sample is now there, not to correct a typo in the current one's account).
+- __Clear (set empty)__ — mark a slot empty after physically removing the sample. Only offered for shuttle slots, since only samples in a shuttle can leave vacuum: this also offers to print a P-touch label for the sample that was just removed.
+- __New sample…__ — occupy an empty slot with a name and owner.
+- __Set parking slot…__ / __Clear parking assignment__ (shuttle labels) — record which parking slot a shuttle occupies; a slot already held by a different shuttle is rejected, clear it there first.
-In the case of the sample stage position, the commands are
-`dev.omny_samples.unset_sample_in_samplestage()`
-`dev.omny_samples.set_sample_in_samplestage('name')`
+Measured status itself (unmeasured/started/completed) is set automatically as part of a tomographic scan, or from the CLI (see reference below) — it is intentionally not settable from the widget.
-The shuttles are mounted in the parking station. Typically oparkz slot 3 is used for shuttle A, slot 4 for shuttle B, slot 5 for shuttle C. If that status has to be modified, the following commands can be used:
-`dev.omny_samples.unset_shuttle_slot(slot_nr)`
-`dev.omny_samples.set_shuttle_slot(container, slot_nr)`
-Here is an _example_: dev.omny_samples.set_shuttle_slot('A',2)
+Use `omny.otransfer_show_all()` from the CLI for the same overview as plain text (useful when the GUI isn't available).
#### Sample transfer
-Once the sample places are set correctly and checked by using `omny.otransfer_show_all()`, the following commands are available for sample change. To pick a sample from a parking slot, the parking slot stage has to be moved to the correct parking slot position. If a parking position is active, this information is displayed in sample storage overview.
+Physically moving samples still goes through the CLI — the widget only edits the storage record, it does not drive hardware. Once the storage record is correct (check with the widget or `omny.otransfer_show_all()`), the following commands are available for sample change. To pick a sample from a parking slot, the parking slot stage has to be moved to the correct parking slot position first. If a parking position is active, this is shown in the widget (and in `otransfer_show_all()`'s text overview).
| Command | Explanation |
| --- | --- |
@@ -132,6 +119,33 @@ Advanced commands ... in case something goes wrong
When closing a shuttle of the shield, the gripper will be moved to the parking position prior closing.
+#### CLI reference for sample storage
+
+Not part of the normal workflow above (that's the widget) — kept here for scripting, and for the rare case the GUI isn't available. All of it lives on the OMNY samples device, `dev.omny_samples`; `dev.omny_samples.help()` prints the same reference with argument details from within a BEC session, and `omny.otransfer_help()` includes it too.
+
+Modify a slot position of systems "A", "B", "C", "O" (owner is optional, defaults to empty; setting a slot resets its measured status to unmeasured):
+`dev.omny_samples.unset_sample_slot('system', position)` free a sample position.
+`dev.omny_samples.set_sample_slot('system', position, 'name', 'owner')` set a sample position.
+
+There are two __special sample slots__:
+If a sample is in the __gripper__ and the information has to be manually changed use
+`dev.omny_samples.unset_sample_in_gripper()`
+`dev.omny_samples.set_sample_in_gripper('name', 'owner')`
+
+In the case of the sample stage position, the commands are
+`dev.omny_samples.unset_sample_in_samplestage()`
+`dev.omny_samples.set_sample_in_samplestage('name', 'owner')`
+
+The shuttles are mounted in the parking station. Typically oparkz slot 3 is used for shuttle A, slot 4 for shuttle B, slot 5 for shuttle C. If that status has to be modified, the following commands can be used:
+`dev.omny_samples.unset_shuttle_slot(slot_nr)`
+`dev.omny_samples.set_shuttle_slot(container, slot_nr)`
+Here is an _example_: dev.omny_samples.set_shuttle_slot('A',2)
+
+Measured status/account (normally set automatically during a tomographic scan) can be read and, if really needed, corrected manually via the `omny.otransfer_*` wrappers, e.g.:
+`omny.otransfer_get_sample_status('system', position)` / `otransfer_get_sample_status_in_gripper()` / `otransfer_get_sample_status_in_samplestage()`
+`omny.otransfer_set_sample_status('system', position, status, account)` (`status` is one of `"none"`/`"started"`/`"completed"`) and the matching `_in_gripper`/`_in_samplestage` variants
+`omny.otransfer_get_sample_account(...)` and its `_in_gripper`/`_in_samplestage` variants read back the account tied to a "started"/"completed" status.
+
### Status of OMNY
To see the status of the insrument, following commands can be used. Most of the components mentioned below are controlled via devices with naming starting with omny. TAB completion on dev.omny can be a quick way to find the commands.
From 9fe0678899246b205739499d25a20828c84581b7 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Sun, 6 Sep 2026 11:59:33 +0200
Subject: [PATCH 057/102] fix(omny): inverted feedback-disable confirm in
_otransfer_stage_to_ST
Pre-existing bug from 55aa712 (before this session): confirming "yes,
disable the feedback" raised OMNYTransferError instead of proceeding --
inverted condition, missing a `not`. Same class of bug as the
otransfer_park_loadlock_slot fix earlier this session. Audited every
other otransfer_confirm_dialog() call site in this file; this was the
only inverted one.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../plugins/omny/omny_sample_transfer_mixin.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 2077d965..d90cb64e 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -1007,7 +1007,7 @@ class OMNYSampleTransferMixin:
feedback_status = self.device_manager.devices.rtx.controller.feedback_is_running()
if feedback_status == True:
# feedback is running
- if self.otransfer_confirm_dialog("May the feedback be disabled", "y"):
+ if not self.otransfer_confirm_dialog("May the feedback be disabled", "y"):
raise OMNYTransferError(
"Manual abort of sample transfer, need to disable feedback has not been confirmed."
)
From a7429b2de266218231d2b3cff25a568050cf1799 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Sun, 6 Sep 2026 12:21:01 +0200
Subject: [PATCH 058/102] fix(omny): save reference images at every manual
confirm gate, not just the RT confirm handshake
_otransfer_save_reference_images() was only called from _otransfer_confirm()'s
"All OK?" checkpoint. Now also called at otransfer_get_sample()'s "no sample
in gripper" and "ready to start the unmount process" gates, and
otransfer_put_sample()'s "ready to start the mount process" gate -- all
three already run after the camera pair is live, so no extra setup needed.
Checked flomni.py for the equivalent gap per request: there isn't one --
ftransfer_get_sample()/ftransfer_put_sample() have no manual confirm
dialog before starting mount/unmount at all (just an automated check +
raise), so there's no existing flomni gate missing an image-save call.
Documented in AI_docs/OPEN_ISSUES.md, including a forward-looking note in
case flomni ever gains an interactive gripper-empty confirm of its own.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 32 +++++++++++++++++++
.../omny/omny_sample_transfer_mixin.py | 3 ++
2 files changed, 35 insertions(+)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 4d161436..802c382d 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -814,6 +814,38 @@ work above). **Mirko's intent: port the same real-EPICS status+account channel t
point** -- a separate, dedicated task (its own `.template` record + `FlomniSampleStorage` changes
+ retiring/reconciling `_MeasuredSampleLog`), not bundled into this branch.
+## Reference images now saved at every manual confirm gate in otransfer_get_sample/put_sample -- flomni has no equivalent gate to save at
+
+`_otransfer_save_reference_images()` used to be called only from inside `_otransfer_confirm()`'s
+"All OK?" checkpoint (the RT-controller confirm handshake during the actual mount/unmount motion).
+It's now also called at every earlier manual `otransfer_confirm_dialog()` gate in
+`omny_sample_transfer_mixin.py`:
+- `otransfer_get_sample()`'s "Please confirm that currently there is no sample in the gripper..."
+ gate (`file_suffix="get_gripper_empty_check"`)
+- `otransfer_get_sample()`'s "Ready to start the unmount process now?" gate
+ (`file_suffix="get_unmount_start"`)
+- `otransfer_put_sample()`'s "Ready to start the mount process now?" gate
+ (`file_suffix="put_mount_start"`)
+
+All three already run after `_omnycam_parking()`/`_omnycam_samplestage()` has made the camera pair
+live, so no extra setup was needed -- just one more call to the existing, generic
+`_otransfer_save_reference_images(file_suffix=...)` helper at each site.
+
+**Checked flOMNI for the equivalent gap, per Mirko's request: there isn't one to fix.**
+`flomni.py`'s `ftransfer_get_sample()`/`ftransfer_put_sample()` have no manual confirm dialog at
+all before starting the mount/unmount motion -- unlike OMNY, they just do an automated
+`is_sample_in_gripper()`/`is_sample_slot_used()` check and raise `FlomniError` directly, no
+`ftransfer_confirm_dialog()` gate to save an image at. FlOMNI's `save_reference_image()` is (like
+OMNY's `_otransfer_save_reference_images()` used to be) only ever called from its own "All OK?
+Continue?" handshake (`flomni.py` around line 1107-1112) -- so flOMNI and OMNY are consistent
+with each other again after this change, just via different means (OMNY has extra manual gates
+FlOMNI never had, not a missing image-save call on an existing FlOMNI gate).
+
+If FlOMNI ever gains an interactive "confirm gripper is empty" gate of its own (closing this
+long-standing behavioral difference from OMNY), it should get a `save_reference_image()` call at
+the same point, for consistency with this change -- noted here so it isn't forgotten if/when that
+gate gets added, not because anything is broken today.
+
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index d90cb64e..e0703aed 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -808,6 +808,7 @@ class OMNYSampleTransferMixin:
if not self.otransfer_confirm_dialog("Ready to start the mount process now?", "y"):
raise OMNYTransferError("Transfer process has been manually aborted.")
+ self._otransfer_save_reference_images(file_suffix="put_mount_start")
dev.otransy.controller.socket_put_confirmed("XQ#GRPUT")
print("The mount process started.")
@@ -932,6 +933,7 @@ class OMNYSampleTransferMixin:
"y",
):
raise OMNYTransferError("Transfer process has been manually aborted.")
+ self._otransfer_save_reference_images(file_suffix="get_gripper_empty_check")
# clean abort before starting
dev.otransx.controller.socket_put_confirmed("XQ#STOP,1")
time.sleep(0.1)
@@ -962,6 +964,7 @@ class OMNYSampleTransferMixin:
if not self.otransfer_confirm_dialog("Ready to start the unmount process now?", "y"):
raise OMNYTransferError("Transfer process has been manually aborted.")
+ self._otransfer_save_reference_images(file_suffix="get_unmount_start")
dev.otransy.controller.socket_put_confirmed("XQ#GRGET")
From 415d55f599da4757033c80e1481e7a52ae3af9d2 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Sun, 6 Sep 2026 12:25:39 +0200
Subject: [PATCH 059/102] fix(omny): widen misleading "position not free"/"no
sample available" errors
Both _otransfer_check_free_slot_available_at_position() and
_otransfer_check_sample_available_at_position() distinguish "slot
occupied/empty" from "no shuttle parked at the active parking
position" internally (via print()), but only returned bool -- so the
raised OMNYTransferError always said "position N is not free"/"no
sample available at position N" even when the real cause was no
shuttle being parked at oparkz's current slot at all, which reads as
if the pin itself were the problem. Widened both messages to cover
either cause, per Mirko's simplest-fix suggestion.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../plugins/omny/omny_sample_transfer_mixin.py | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index e0703aed..4e6bbdd0 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -793,7 +793,10 @@ class OMNYSampleTransferMixin:
self.transfer_step = 0
self._otransfer_check_sensor_connected()
if not self._otransfer_check_free_slot_available_at_position(pin_position, 0):
- raise OMNYTransferError(f"position {pin_position} is not free")
+ raise OMNYTransferError(
+ f"position {pin_position} is not free, or no shuttle is parked at the"
+ " active parking position"
+ )
# global _ogalil_encoder_steps_per_mm
if pin_position == 0:
@@ -940,7 +943,10 @@ class OMNYSampleTransferMixin:
self._otransfer_check_sensor_connected()
if not self._otransfer_check_sample_available_at_position(pin_position, 0):
- raise OMNYTransferError(f"There is no sample available at position {pin_position}")
+ raise OMNYTransferError(
+ f"There is no sample available at position {pin_position}, or no shuttle is"
+ " parked at the active parking position"
+ )
# global _otransy_approach_height
# global _ogalil_encoder_steps_per_mm
From 3241e880cee15fe954e72c4349f5e63ddc6225f2 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Sun, 6 Sep 2026 12:57:48 +0200
Subject: [PATCH 060/102] docs(omny): design plan for machine-vision
auto-confirm on sample transfer
Explores classical CV (registration + edge/SSIM/contour matching against
multiple reference images) to reduce the manual y/n confirms in
otransfer_get_sample()/otransfer_put_sample(), reusing the existing
reference-image capture and the push_smear_preview() GUI-overlay pattern
already proven for LamNI's rotation-center calibration. Exploration only;
implementation to follow on a separate branch.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../MACHINE_VISION_AUTOCONFIRM_PLAN.md | 147 ++++++++++++++++++
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 6 +-
2 files changed, 151 insertions(+), 2 deletions(-)
create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
new file mode 100644
index 00000000..a150b4ff
--- /dev/null
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
@@ -0,0 +1,147 @@
+# OMNY sample-transfer machine-vision auto-confirm — design plan (2026-09-06)
+
+Exploration only — **no code in this pass**. Written on `update-omny` so a future session/branch
+can pick this up without re-deriving it. Companion docs: `OPEN_ISSUES.md` (the live-testing effort
+this plan grows out of — see its "full gripper sample-transfer chain... untested" item) and
+`SAMPLE_STORAGE_TEST_CHECKLIST.md`.
+
+## Motivation
+
+Mirko's starting question: is there a Python package for comparing a live gripper/pin camera image
+against a set of reference images, robust to exposure changes and small camera-alignment drift, to
+decide "is the pin sitting in the gripper correctly" — classic machine-vision, not deep learning.
+Applied to BEC: could this let OMNY's sample-transfer flow (`otransfer_get_sample()` /
+`otransfer_put_sample()`) auto-approve routine steps instead of stopping for a human `y/n` at every
+single one, while still catching real problems (pin damage, missed grip, wrong shuttle state)?
+
+This doc maps that idea onto OMNY's actual confirm gates and camera/GUI plumbing, and proposes a
+staged plan. **Goal for this session: write this plan down. Implementation is a separate, later
+branch.**
+
+## Where today's human confirmations actually live
+
+All of these are in `omny_sample_transfer_mixin.py` and go through
+`otransfer_confirm_dialog()` (line 76 — GUI `ConsoleButtonsWidget` yes/no if the camera console is
+already open, else a blocking CLI `yesno()`):
+
+1. **`_otransfer_confirm()`** (line 848) — the main per-step handshake during an actual mount/get,
+ driven by the Galil controller's `confirm`/`mntprgs` variables (`XQ#GRPUT`/`XQ#GRGET` macros).
+ Loops until the controller reports `mntprgs == 0`; each iteration is one manual "All OK?"
+ gate. For a *get*, the first iteration additionally hard-checks the top-pin position against a
+ fixed threshold (`-25.4`, line 866) and asks an extra, more alarming confirm if it's exceeded —
+ this is a **physical safety check**, not a generic step confirm, and should stay independent of
+ any vision system.
+2. **`otransfer_get_sample()`** (line 934) — "confirm there is no sample in the gripper, it would
+ be dropped" before starting at all.
+3. **`otransfer_get_sample()`** (line 971) / **`otransfer_put_sample()`** (line 812) — "Ready to
+ start the [un]mount process now?" right before triggering `XQ#GRGET`/`XQ#GRPUT`.
+4. **`otransfer_put_sample()`** (line 836) — "Pick another sample from this shuttle? Keep shield
+ open?" after a successful put.
+5. **`_otransfer_shuttle_align()`** (line 189) — one-off "shall I try to move the aligner to the
+ correct start position" recovery prompt, only on a specific bad-limit-switch state. Edge case,
+ not a routine per-transfer gate — out of scope for auto-confirm.
+6. **`otransfer_park_loadlock_slot()`** (line 523) — CLI-only `yesno()` by design (operator is
+ physically at the loadlock, not watching the GUI camera console) — also out of scope here.
+
+Gates 1–4 are the routine, repeated-every-transfer confirmations this plan targets. Every one of
+them already calls **`_otransfer_save_reference_images()`** (line 885, landed in `a7429b2`) right
+at the confirm point, which dumps the currently-active camera pair's `get_last_image()` frames to
+an HDF5 file under `~/data/raw/logs/omny_sample_transfer_images/`, named by step + timestamp. **This
+is already the reference-image corpus this plan would compare against** — it exists purely as a
+post-hoc audit log today, with no comparison logic reading it back.
+
+## Existing infrastructure to build on (no new plumbing needed for a prototype)
+
+- **`IDSCamera.get_last_image()`** (`csaxs_bec/devices/ids_cameras/ids_camera.py:234`) — already
+ how `_otransfer_save_reference_images()` gets frames; same call works for a live comparison.
+- **`OMNYGuiTools._omnycam_active_pair`** (`gui_tools.py:88-105`) — tracks which camera pair
+ (`cam200`+`cam203` parking/shuttle, or `cam201`+`cam202` sample stage) is currently shown, exactly
+ the info needed to pick the matching reference subset.
+- **`IDSCamera.push_preview_image()` / `push_smear_preview()`** (`ids_camera.py:240,269`) — a
+ device-server-side hook to publish a processed/composite array back through the same
+ `device_preview` channel the GUI already subscribes to, without disturbing the live acquisition
+ feed. **This exact mechanism is already proven for a similar purpose**: LamNI's rotation-center
+ calibration (`LamNI/x_ray_eye_align.py:737-797`, see `[[project_lamni_rotation_center_calibration]]`-era
+ work) pushes a growing composite image via `push_smear_preview()`, and
+ `bec_widgets/widgets/xray_eye/x_ray_eye.py` (`smear_preview_toggle`, lines 386-873) already has a
+ working UI toggle to switch an `Image` dock between the live feed and this diagnostic channel.
+ **A vision-diff overlay for OMNY sample transfer should reuse this pattern** — push an
+ aligned-reference/diff/contour-overlay composite via `push_smear_preview()` on whichever of
+ cam200/201/202/203 is active, and add the same toggle affordance to OMNY's camera docks
+ (`gui_tools.py:127-152`) — rather than inventing a third dock or a new preview channel.
+- **`opencv-python`** is already a project dependency (`pyproject.toml:27`); `scikit-image` is not
+ yet (needed only if SSIM specifically is wanted — see below).
+
+## Proposed CV pipeline (classical, no deep learning)
+
+Per the original discussion this grew out of: classical machine vision is enough for a
+well-constrained "is the pin geometry right" question; no training data, no model to keep in sync
+with hardware changes.
+
+1. **Registration first.** Camera mounts can drift slightly, so raw pixel comparison isn't
+ meaningful until the current frame is aligned to the reference. `cv2.findTransformECC` for
+ small translation/rotation (the expected case — a bolted-down camera); an ORB/SIFT +
+ `cv2.findHomography` fallback if perspective drift turns out to be larger than ECC handles.
+2. **Exposure-robust representation.** Compare edges/gradients (Sobel/Canny) or CLAHE-normalized
+ images, not raw intensity — illumination changes should not look like a defect.
+3. **Similarity/anomaly scoring**, two complementary signals:
+ - **Contour/silhouette match** (`cv2.findContours` + `cv2.matchShapes`) on the extracted
+ pin/gripper silhouette — the most directly relevant signal for "is the pin sitting right",
+ and close to fully lighting-invariant.
+ - **SSIM** (`skimage.metrics.structural_similarity`) on the registered, normalized pair as a
+ general structural-anomaly fallback, in case a defect doesn't show up as a clean contour
+ change. (Pulls in `scikit-image` as a new dependency — only if this signal proves worth its
+ weight; contour matching alone may be sufficient given how constrained the geometry is.)
+4. **Multi-reference scoring.** Score against *every* reference image for the given
+ `(pin_position, step_name, camera)` combination and take the best (minimum-distance) match, not
+ a single fixed reference — natural sample-to-sample and lighting variation shouldn't inflate the
+ anomaly score just because one particular reference happened to be a bad match.
+5. **Escalate only if needed.** If classical contour/SSIM scoring proves insufficiently robust to
+ subtle real defects, `anomalib` (e.g. PatchCore) is the documented fallback — but per the
+ original assessment this is very likely overkill for a fixed pin/gripper geometry, and shouldn't
+ be built preemptively.
+
+## What "getting rid of human confirmations" should actually mean — staged rollout
+
+Physical hardware, real samples, real damage risk (see the top-pin threshold in gate 1 above) means
+this should not be an all-or-nothing flag flip. Proposed phases for the later implementation branch:
+
+- **Phase 0 — instrument, don't automate.** Compute and log the vision score at every existing gate
+ (1–4 above); still always show the human the same confirm prompt, but now alongside the score and
+ a diff/overlay visualization (via the `push_smear_preview()` pattern above). Purpose: build a
+ labeled dataset of real operator pass/fail decisions vs. score, across real lighting and sample
+ variety, before trusting any threshold. No behavior change to the transfer flow itself.
+- **Phase 1 — assist, never silently reject.** A comfortably-high score can auto-answer "yes" at a
+ gate (with the decision logged and visibly flagged as auto-confirmed, and an easy override); a
+ low score never fails silently or auto-aborts — it escalates to the same confirm prompt, now with
+ the diff visualization made prominent, so the human's attention goes exactly where the anomaly
+ is. This mirrors how the existing top-pin-position check already works (a hard numeric threshold
+ that *escalates* the confirm text, never silently proceeds or silently aborts).
+- **Phase 2 — skip the prompt on high-confidence routine steps.** Only once Phase 0/1 data shows
+ the scoring is reliable across real operational conditions, let specific gates (candidates: gate
+ 2's "no sample in gripper" check, gate 4's "pick another sample" prompt — the more purely
+ informational ones) skip the prompt outright on a high score, behind an explicit opt-in/kill
+ switch. The physical safety checks (top-pin threshold, `_otransfer_check_sensor_connected()`)
+ stay hard-coded and independent of the vision system regardless of phase — vision augments the
+ judgment calls, it doesn't replace the interlocks.
+
+## Open questions / decisions needed before implementation starts
+
+- **Reference corpus curation.** Today's saved images are a timestamped append-only run log, not a
+ curated "golden" set — every run adds a new file, nothing marks one as trustworthy for
+ comparison. Need either a promotion step (operator marks a saved image as a reference) or a
+ separate deliberately-curated directory, distinct from the audit-log files
+ `_otransfer_save_reference_images()` already writes.
+- **Per-camera-pair segmentation.** Parking/shuttle view (cam200+cam203) and sample-stage view
+ (cam201+cam202) have different backgrounds/lighting; silhouette extraction likely needs tuning
+ per pair, not one global set of CV parameters.
+- **Threshold calibration.** Can't be picked a priori — needs the Phase 0 logged-score dataset
+ across real sessions before any auto-confirm threshold is chosen.
+- **Where scores/results live.** Likely alongside the existing HDF5 reference-image files (same
+ per-step file, additional dataset for score + registration transform), rather than a new storage
+ mechanism.
+
+## Non-goals for this session
+
+No dependencies added, no code written, no new GUI elements built. This document is the handoff for
+the follow-up branch.
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index 802c382d..ad400426 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -3,9 +3,11 @@
Tracks the live-testing effort for bringing OMNY up to flomni's/BEC's current standards on
branch `update-omny`: bugs found (fixed and unfixed) and what's still untested. Written so a
future session can pick this up without re-deriving it. Companion docs:
-`../../flomni/AI_docs/SIMULATED_ENDSTATIONS.md` (the sim architecture) and
+`../../flomni/AI_docs/SIMULATED_ENDSTATIONS.md` (the sim architecture),
`../../flomni/AI_docs/FLOMNI_TO_LAMNI_COMPARISON.md` (the flomni→lamni migration this OMNY work
-is meant to mirror).
+is meant to mirror), and `MACHINE_VISION_AUTOCONFIRM_PLAN.md` (design-only exploration of using
+classical machine vision on the sample-transfer camera feeds to reduce/replace the manual confirm
+gates in `otransfer_get_sample()`/`otransfer_put_sample()` — not started, separate future branch).
## Current status
From 9d4cd0347ebf4a1ab26bdeaa9736c71e75fdd6dd Mon Sep 17 00:00:00 2001
From: x01dc
Date: Sun, 6 Sep 2026 13:07:34 +0200
Subject: [PATCH 061/102] docs(omny): make the vision pipeline a generic
camera-agnostic device
Reworks the plan per feedback: instead of building CV logic into
IDSCamera / inline in the sample-transfer mixin, propose a standalone
SampleVisionMonitor device whose only dependency is the get_last_image()
contract already shared by IDSCamera and AlliedVisionAravisCamera, wired
to its camera(s) by configured name (ddg_1.py's device_manager.devices.get()
pattern, generalized). Same class is then reusable for any endstation's
camera(s) via config alone.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../MACHINE_VISION_AUTOCONFIRM_PLAN.md | 122 ++++++++++++++----
1 file changed, 95 insertions(+), 27 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
index a150b4ff..134ae6ba 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
@@ -50,28 +50,87 @@ an HDF5 file under `~/data/raw/logs/omny_sample_transfer_images/`, named by step
is already the reference-image corpus this plan would compare against** — it exists purely as a
post-hoc audit log today, with no comparison logic reading it back.
-## Existing infrastructure to build on (no new plumbing needed for a prototype)
+## Existing infrastructure to build on
-- **`IDSCamera.get_last_image()`** (`csaxs_bec/devices/ids_cameras/ids_camera.py:234`) — already
- how `_otransfer_save_reference_images()` gets frames; same call works for a live comparison.
+- **`get_last_image() -> np.ndarray`** is already a shared, RPC-exposed contract across camera
+ device classes, not an `IDSCamera` idiosyncrasy: both `IDSCamera`
+ (`csaxs_bec/devices/ids_cameras/ids_camera.py:234`, `rpc_access` line 72) and
+ `AlliedVisionAravisCamera` (`csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py:216`,
+ `rpc_access` line 62) implement it identically, and client-side code already calls it directly
+ through the device-server RPC boundary — `LamNI/x_ray_eye_align.py:202,821` and
+ `flomni/x_ray_eye_align.py:127` do exactly this (`dev.cam_xeye.get_last_image()`) regardless of
+ which concrete camera class backs `cam_xeye` at a given beamline. This is the natural minimal
+ interface for a generic vision component to depend on — see the architecture section below.
- **`OMNYGuiTools._omnycam_active_pair`** (`gui_tools.py:88-105`) — tracks which camera pair
(`cam200`+`cam203` parking/shuttle, or `cam201`+`cam202` sample stage) is currently shown, exactly
the info needed to pick the matching reference subset.
-- **`IDSCamera.push_preview_image()` / `push_smear_preview()`** (`ids_camera.py:240,269`) — a
- device-server-side hook to publish a processed/composite array back through the same
- `device_preview` channel the GUI already subscribes to, without disturbing the live acquisition
- feed. **This exact mechanism is already proven for a similar purpose**: LamNI's rotation-center
- calibration (`LamNI/x_ray_eye_align.py:737-797`, see `[[project_lamni_rotation_center_calibration]]`-era
- work) pushes a growing composite image via `push_smear_preview()`, and
- `bec_widgets/widgets/xray_eye/x_ray_eye.py` (`smear_preview_toggle`, lines 386-873) already has a
- working UI toggle to switch an `Image` dock between the live feed and this diagnostic channel.
- **A vision-diff overlay for OMNY sample transfer should reuse this pattern** — push an
- aligned-reference/diff/contour-overlay composite via `push_smear_preview()` on whichever of
- cam200/201/202/203 is active, and add the same toggle affordance to OMNY's camera docks
- (`gui_tools.py:127-152`) — rather than inventing a third dock or a new preview channel.
+- **Precedent for one device depending on another by configured name, resolved through the device
+ manager**: `DDG1.prepare_mcs_on_trigger()` (`csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py:369-397`)
+ looks up a sibling device via `self.device_manager.devices.get(mcs_name, None)` at call time (not
+ at `__init__`, since device construction order across a YAML config isn't guaranteed) and raises
+ clearly if it's missing. Same pattern, generalized to a *configurable* name instead of `ddg_1.py`'s
+ hardcoded `"mcs"`, is the mechanism proposed below for a vision device's camera dependency.
+- **`IDSCamera.push_preview_image()` / `push_smear_preview()`** (`ids_camera.py:240,269`) — an
+ `IDSCamera`-specific hook to publish a processed/composite array through its own
+ `device_preview` channel, already proven for a similar purpose in LamNI's rotation-center
+ calibration (`LamNI/x_ray_eye_align.py:737-797`) together with the matching UI toggle in
+ `bec_widgets/widgets/xray_eye/x_ray_eye.py:386-873`. Usable as a visualization *fallback* when the
+ configured camera happens to be an `IDSCamera`, but not something the generic vision device
+ should depend on — see "own diagnostic signal vs. borrowing the camera's channel" below.
- **`opencv-python`** is already a project dependency (`pyproject.toml:27`); `scikit-image` is not
yet (needed only if SSIM specifically is wanted — see below).
+## Proposed architecture: a generic, camera-agnostic vision device
+
+Earlier drafts of this plan sketched the CV pipeline as logic living inside `IDSCamera` itself, or
+inline in `omny_sample_transfer_mixin.py` calling `cv2`/`skimage` directly against whichever
+`cam200`-`cam203` frame it just pulled. Mirko's feedback: keep the imaging pipeline generic and
+reusable against *any* camera, structured as its own device with a camera as a declared dependency,
+rather than building it into a specific camera class. Concretely:
+
+- **A new, endstation-agnostic device class** (working name `SampleVisionMonitor`), living outside
+ `omny/` — e.g. `csaxs_bec/devices/vision/sample_vision_monitor.py` — so it isn't OMNY-specific
+ code, just OMNY's first user of it.
+- **Its only hard dependency is the shared `get_last_image()` contract** above — nothing
+ `IDSCamera`-specific (no reliance on `push_smear_preview`, the `image` `PreviewSignal`'s
+ rotation/transpose handling, etc.). Any current or future camera class that implements
+ `get_last_image() -> np.ndarray | None` can be wired in, including `AlliedVisionAravisCamera` or a
+ simulated camera, with zero changes to this device.
+- **Config-driven camera dependency, resolved lazily.** A `camera_names: list[str]` constructor
+ parameter (a list, not a single name, so OMNY's camera-*pairs* — `["cam200", "cam203"]` for
+ parking, `["cam201", "cam202"]` for the sample stage — are a natural fit; a single-camera setup
+ like flomni's gripper cam is just a length-1 list). Resolved through
+ `self.device_manager.devices.get(name, None)` at call time, mirroring `ddg_1.py`'s pattern above,
+ with a clear error if a configured name isn't present in the active session's device config.
+ **This is what makes the device reusable across endstations without touching its code**: the same
+ class, pointed at a different `camera_names` list in a different beamline's device-config YAML,
+ is the entire integration cost for a new camera or a new endstation.
+- **Runs device-server-side** (a `PSIDeviceBase` subclass, like `IDSCamera` itself), consistent with
+ where camera image processing already happens in this codebase, and appropriate for the
+ registration/scoring compute cost.
+- **Opaque, caller-defined reference tags**, not OMNY vocabulary baked into the device: reference
+ images and scores are grouped by a plain string tag the caller picks (for OMNY,
+ `f"{step_name}_{pin_position}"`; a different endstation could tag however makes sense to it) — the
+ device itself has no notion of "pin position" or "shuttle slot".
+- **Client-facing RPC surface** (sketch, names not final):
+ - `save_reference(tag: str) -> None` — pulls the current frame(s) from the configured camera(s)
+ and stores them as a labeled reference under `tag`.
+ - `score(tag: str) -> dict` — pulls the current frame(s), registers + scores each against every
+ stored reference under `tag` (see CV pipeline below), publishes a diagnostic overlay (see next
+ bullet), and returns the best-match score(s) plus enough detail to build a confirm message.
+ - `omny_sample_transfer_mixin.py`'s gates (1-4 above) become *callers* of this device
+ (`dev.omny_vision_parking.score(...)`, `dev.omny_vision_samplestage.score(...)`, one device
+ instance per camera pair) instead of doing any `cv2`/`skimage` work themselves — the mixin only
+ turns the returned score into a confirm-dialog decision (per the staged rollout below).
+ - **Own diagnostic signal vs. borrowing the target camera's channel** — open decision, not
+ resolved here: (a) give `SampleVisionMonitor` its own `diagnostic_image` `PreviewSignal`
+ component, fully decoupled from whichever camera it's pointed at, requiring one new `Image` dock
+ in `gui_tools.py` per vision device; vs. (b) when the configured camera happens to expose
+ `push_smear_preview()` (i.e. it's an `IDSCamera`), feature-detect that and reuse the existing
+ dock + toggle pattern instead of adding a new one. (a) is more genuinely camera-agnostic (matches
+ the rest of this design); (b) is less new GUI surface for OMNY specifically, at the cost of only
+ working when the backing camera happens to support it. Worth prototyping both before deciding.
+
## Proposed CV pipeline (classical, no deep learning)
Per the original discussion this grew out of: classical machine vision is enough for a
@@ -92,10 +151,10 @@ with hardware changes.
general structural-anomaly fallback, in case a defect doesn't show up as a clean contour
change. (Pulls in `scikit-image` as a new dependency — only if this signal proves worth its
weight; contour matching alone may be sufficient given how constrained the geometry is.)
-4. **Multi-reference scoring.** Score against *every* reference image for the given
- `(pin_position, step_name, camera)` combination and take the best (minimum-distance) match, not
- a single fixed reference — natural sample-to-sample and lighting variation shouldn't inflate the
- anomaly score just because one particular reference happened to be a bad match.
+4. **Multi-reference scoring.** Score against *every* reference image stored under the given tag
+ and take the best (minimum-distance) match, not a single fixed reference — natural
+ sample-to-sample and lighting variation shouldn't inflate the anomaly score just because one
+ particular reference happened to be a bad match.
5. **Escalate only if needed.** If classical contour/SSIM scoring proves insufficiently robust to
subtle real defects, `anomalib` (e.g. PatchCore) is the documented fallback — but per the
original assessment this is very likely overkill for a fixed pin/gripper geometry, and shouldn't
@@ -107,10 +166,11 @@ Physical hardware, real samples, real damage risk (see the top-pin threshold in
this should not be an all-or-nothing flag flip. Proposed phases for the later implementation branch:
- **Phase 0 — instrument, don't automate.** Compute and log the vision score at every existing gate
- (1–4 above); still always show the human the same confirm prompt, but now alongside the score and
- a diff/overlay visualization (via the `push_smear_preview()` pattern above). Purpose: build a
- labeled dataset of real operator pass/fail decisions vs. score, across real lighting and sample
- variety, before trusting any threshold. No behavior change to the transfer flow itself.
+ (1–4 above) via `SampleVisionMonitor.score()`; still always show the human the same confirm
+ prompt, but now alongside the score and the diagnostic diff/overlay visualization it publishes.
+ Purpose: build a labeled dataset of real operator pass/fail decisions vs. score, across real
+ lighting and sample variety, before trusting any threshold. No behavior change to the transfer
+ flow itself.
- **Phase 1 — assist, never silently reject.** A comfortably-high score can auto-answer "yes" at a
gate (with the decision logged and visibly flagged as auto-confirmed, and an easy override); a
low score never fails silently or auto-aborts — it escalates to the same confirm prompt, now with
@@ -129,17 +189,25 @@ this should not be an all-or-nothing flag flip. Proposed phases for the later im
- **Reference corpus curation.** Today's saved images are a timestamped append-only run log, not a
curated "golden" set — every run adds a new file, nothing marks one as trustworthy for
- comparison. Need either a promotion step (operator marks a saved image as a reference) or a
- separate deliberately-curated directory, distinct from the audit-log files
- `_otransfer_save_reference_images()` already writes.
+ comparison. Need either a promotion step (operator marks a saved image as a reference, e.g. via
+ `save_reference()` above) or a separate deliberately-curated directory, distinct from the
+ audit-log files `_otransfer_save_reference_images()` already writes.
- **Per-camera-pair segmentation.** Parking/shuttle view (cam200+cam203) and sample-stage view
(cam201+cam202) have different backgrounds/lighting; silhouette extraction likely needs tuning
- per pair, not one global set of CV parameters.
+ per pair, not one global set of CV parameters — one `SampleVisionMonitor` instance per pair (its
+ own config, own reference store) is the natural place for that tuning to live.
- **Threshold calibration.** Can't be picked a priori — needs the Phase 0 logged-score dataset
across real sessions before any auto-confirm threshold is chosen.
- **Where scores/results live.** Likely alongside the existing HDF5 reference-image files (same
per-step file, additional dataset for score + registration transform), rather than a new storage
mechanism.
+- **Diagnostic visualization: own `PreviewSignal` vs. borrowing the camera's `smear_preview`** —
+ see the architecture section above; needs a short prototype of both before committing.
+- **One `SampleVisionMonitor` config per camera pair vs. one instance whose `camera_names` gets
+ reconfigured at call time** — static per-pair config (e.g. `omny_vision_parking`,
+ `omny_vision_samplestage` as two fixed device-config entries) is simpler and matches how OMNY's
+ own two camera pairs are already fixed, not dynamic; leaning toward this unless a concrete need
+ for a dynamic pairing shows up.
## Non-goals for this session
From d04932b68d0225a91489f0f63aa4556f699b4255 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Sun, 6 Sep 2026 13:15:11 +0200
Subject: [PATCH 062/102] docs(omny): split vision plan into a generic
toolkit/interface + confirm app
Per feedback: split the design into three layers instead of one
confirm-scoring device. Layer 0 is a plain image-manipulation toolkit
(grayscale, edge detection, registration, overlays) with no device/BEC
dependency. Layer 1 is a new generic VisionInterface device -- camera in,
processed image out, mode selectable at runtime -- usable as an
in-between interface for any camera consumer, not just sample-transfer
confirms; x_ray_eye_align.py's alignment GUI is called out as a second,
independent consumer. Layer 2 (SampleVisionMonitor, the sample-transfer
confirm/scoring device from the previous pass) is now framed as one
application built on top of Layers 0/1.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../MACHINE_VISION_AUTOCONFIRM_PLAN.md | 159 ++++++++++++------
1 file changed, 106 insertions(+), 53 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
index 134ae6ba..1e624ba8 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
@@ -15,8 +15,12 @@ Applied to BEC: could this let OMNY's sample-transfer flow (`otransfer_get_sampl
single one, while still catching real problems (pin damage, missed grip, wrong shuttle state)?
This doc maps that idea onto OMNY's actual confirm gates and camera/GUI plumbing, and proposes a
-staged plan. **Goal for this session: write this plan down. Implementation is a separate, later
-branch.**
+staged plan. Follow-up feedback broadened the scope: rather than building the image-processing logic
+into a single confirm/scoring device, keep it generic — a reusable camera-in/processed-image-out
+interface (grayscale, edge detection, overlays, ...) that the sample-transfer confirm use case is
+only the *first* consumer of; `x_ray_eye_align.py`'s alignment GUI is identified below as a second,
+independent consumer of the same layer. **Goal for this session: write this plan down.
+Implementation is a separate, later branch.**
## Where today's human confirmations actually live
@@ -80,56 +84,95 @@ post-hoc audit log today, with no comparison logic reading it back.
- **`opencv-python`** is already a project dependency (`pyproject.toml:27`); `scikit-image` is not
yet (needed only if SSIM specifically is wanted — see below).
-## Proposed architecture: a generic, camera-agnostic vision device
+## Proposed architecture: a generic, camera-agnostic vision layer
Earlier drafts of this plan sketched the CV pipeline as logic living inside `IDSCamera` itself, or
inline in `omny_sample_transfer_mixin.py` calling `cv2`/`skimage` directly against whichever
-`cam200`-`cam203` frame it just pulled. Mirko's feedback: keep the imaging pipeline generic and
-reusable against *any* camera, structured as its own device with a camera as a declared dependency,
-rather than building it into a specific camera class. Concretely:
+`cam200`-`cam203` frame it just pulled. Mirko's feedback, in two parts: (1) keep the imaging
+pipeline generic and reusable against *any* camera, structured as its own device with a camera as a
+declared dependency, rather than building it into a specific camera class; (2) go further than a
+single-purpose confirm/scoring device — make the underlying image manipulation (grayscale, edge
+detection, and similar) a generic, reusable *interface* that can sit between a raw camera and its
+consumer for other purposes too, e.g. an alternative processed/overlay view for
+`x_ray_eye_align.py`'s alignment GUI, not just sample-transfer scoring. Three layers, each usable on
+its own:
-- **A new, endstation-agnostic device class** (working name `SampleVisionMonitor`), living outside
- `omny/` — e.g. `csaxs_bec/devices/vision/sample_vision_monitor.py` — so it isn't OMNY-specific
- code, just OMNY's first user of it.
-- **Its only hard dependency is the shared `get_last_image()` contract** above — nothing
- `IDSCamera`-specific (no reliance on `push_smear_preview`, the `image` `PreviewSignal`'s
- rotation/transpose handling, etc.). Any current or future camera class that implements
- `get_last_image() -> np.ndarray | None` can be wired in, including `AlliedVisionAravisCamera` or a
- simulated camera, with zero changes to this device.
-- **Config-driven camera dependency, resolved lazily.** A `camera_names: list[str]` constructor
- parameter (a list, not a single name, so OMNY's camera-*pairs* — `["cam200", "cam203"]` for
- parking, `["cam201", "cam202"]` for the sample stage — are a natural fit; a single-camera setup
- like flomni's gripper cam is just a length-1 list). Resolved through
- `self.device_manager.devices.get(name, None)` at call time, mirroring `ddg_1.py`'s pattern above,
- with a clear error if a configured name isn't present in the active session's device config.
- **This is what makes the device reusable across endstations without touching its code**: the same
- class, pointed at a different `camera_names` list in a different beamline's device-config YAML,
- is the entire integration cost for a new camera or a new endstation.
-- **Runs device-server-side** (a `PSIDeviceBase` subclass, like `IDSCamera` itself), consistent with
- where camera image processing already happens in this codebase, and appropriate for the
- registration/scoring compute cost.
-- **Opaque, caller-defined reference tags**, not OMNY vocabulary baked into the device: reference
- images and scores are grouped by a plain string tag the caller picks (for OMNY,
- `f"{step_name}_{pin_position}"`; a different endstation could tag however makes sense to it) — the
- device itself has no notion of "pin position" or "shuttle slot".
+### Layer 0 — a plain image-processing toolkit (pure functions, no device, no BEC dependency)
+
+Grayscale conversion, CLAHE, Sobel/Canny edge detection, ECC/ORB-based registration, contour
+extraction, and overlay drawing (crosshair, contour outline, diff highlight) as plain
+`numpy`/`opencv` functions — array in, array (or array + metadata, e.g. a fitted transform or a
+contour) out. No devices, no BEC imports, trivially unit-testable in isolation, matching this
+repo's existing preference for pure-function "widget math" test files (e.g.
+`test_omny_tomo_params_widget_math.py`). This is the shared vocabulary both layers below build on —
+neither the confirm/scoring use case nor a live processed-view use case should each hand-roll their
+own edge-detection/registration code.
+
+### Layer 1 — `VisionInterface`: a generic camera-in, processed-image-out device
+
+The direct answer to "a camera interface that allows image manipulation... could be used as an
+intermediate interface for x_ray_eye align, bypass the normal image, activate edge detection,
+overlay graphics": a device that sits *between* a source camera and whatever is watching it,
+publishing a transformed view instead of (or alongside) the raw feed.
+
+- **Endstation-agnostic**, living outside `omny/` — e.g. `csaxs_bec/devices/vision/vision_interface.py`.
+- **Same camera-dependency mechanism as before**: config-driven `camera_names: list[str]`, resolved
+ lazily via `self.device_manager.devices.get(name, None)` (the `ddg_1.py` pattern), requiring only
+ the shared `get_last_image()` contract — no dependency on which concrete camera class it's pointed
+ at.
+- **A selectable processing mode**, built from Layer 0's toolkit — `"raw"`, `"grayscale"`,
+ `"edges"`, `"clahe"`, `"overlay"` (e.g. a stored reference's contour drawn over the live frame),
+ or a small ordered pipeline of these — settable at runtime via an RPC/`Signal` (e.g.
+ `set_mode(name: str)`), not hardcoded per device instance. This generalizes what
+ `push_smear_preview()`/the existing `smear_preview_toggle` do today (one hardcoded composite, one
+ fixed second channel) into "any of a small library of transforms, chosen at runtime".
+- **Own `diagnostic_image` `PreviewSignal`**, decoupled from whichever camera it's pointed at (the
+ "own signal" option from the previous draft of this doc) — this is what makes it a genuine
+ in-between interface rather than a camera-specific patch: the GUI dock watches the
+ `VisionInterface`'s output, and swapping/reconfiguring the source camera or the active mode
+ doesn't change what the dock is looking at.
+- **Two independent consumers**, justifying building this generically rather than folding it
+ straight into the sample-transfer use case:
+ - **`SampleVisionMonitor`** (Layer 2, below) uses it for registration + the diagnostic overlay it
+ publishes during confirm gates.
+ - **`x_ray_eye_align.py`**'s alignment GUI (`update_frame()`,
+ `flomni/x_ray_eye_align.py:114-135` — the operator watches a raw live `cam_xeye` feed and
+ manually places alignment points, fit later done in Matlab; OMNY's own copy is still an
+ unadapted LamNI leftover per `OPEN_ISSUES.md`) currently shows only the unprocessed live image.
+ A `VisionInterface` in front of `cam_xeye` could offer an edge-enhanced or CLAHE-normalized live
+ view as an operator-toggleable alternative during manual point-picking — exactly the same
+ exposure-invariance motivation as the sample-transfer use case, applied to a second, already-
+ existing manual-confirmation-style workflow. **Not proposed as a replacement for the existing
+ Matlab-based fit** — just a second real consumer of the same generic layer, and a concrete
+ illustration of why Layer 0/1 shouldn't be scoped to OMNY sample-transfer specifically.
+
+### Layer 2 — `SampleVisionMonitor`: the sample-transfer confirm/scoring application
+
+Everything from the previous draft of this plan, now explicitly framed as *one application built on
+top of Layers 0/1*, not a separate camera-coupled implementation:
+
+- **Runs device-server-side** (a `PSIDeviceBase` subclass, like `IDSCamera`/`VisionInterface`
+ itself), consistent with where camera image processing already happens in this codebase.
+- **Opaque, caller-defined reference tags** — reference images and scores are grouped by a plain
+ string tag the caller picks (for OMNY, `f"{step_name}_{pin_position}"`); the device itself has no
+ notion of "pin position" or "shuttle slot".
- **Client-facing RPC surface** (sketch, names not final):
- - `save_reference(tag: str) -> None` — pulls the current frame(s) from the configured camera(s)
- and stores them as a labeled reference under `tag`.
- - `score(tag: str) -> dict` — pulls the current frame(s), registers + scores each against every
- stored reference under `tag` (see CV pipeline below), publishes a diagnostic overlay (see next
- bullet), and returns the best-match score(s) plus enough detail to build a confirm message.
+ - `save_reference(tag: str) -> None` — pulls the current frame(s) (via its own or a shared
+ `VisionInterface`) and stores them as a labeled reference under `tag`.
+ - `score(tag: str) -> dict` — registers + scores the current frame(s) against every stored
+ reference under `tag` (see CV pipeline below, using Layer 0's registration/contour/SSIM
+ functions), publishes a diagnostic diff/overlay through the `VisionInterface` it uses, and
+ returns the best-match score(s) plus enough detail to build a confirm message.
- `omny_sample_transfer_mixin.py`'s gates (1-4 above) become *callers* of this device
- (`dev.omny_vision_parking.score(...)`, `dev.omny_vision_samplestage.score(...)`, one device
- instance per camera pair) instead of doing any `cv2`/`skimage` work themselves — the mixin only
- turns the returned score into a confirm-dialog decision (per the staged rollout below).
- - **Own diagnostic signal vs. borrowing the target camera's channel** — open decision, not
- resolved here: (a) give `SampleVisionMonitor` its own `diagnostic_image` `PreviewSignal`
- component, fully decoupled from whichever camera it's pointed at, requiring one new `Image` dock
- in `gui_tools.py` per vision device; vs. (b) when the configured camera happens to expose
- `push_smear_preview()` (i.e. it's an `IDSCamera`), feature-detect that and reuse the existing
- dock + toggle pattern instead of adding a new one. (a) is more genuinely camera-agnostic (matches
- the rest of this design); (b) is less new GUI surface for OMNY specifically, at the cost of only
- working when the backing camera happens to support it. Worth prototyping both before deciding.
+ (`dev.omny_vision_parking.score(...)`, `dev.omny_vision_samplestage.score(...)`, one instance
+ per camera pair) instead of doing any `cv2`/`skimage` work themselves — the mixin only turns the
+ returned score into a confirm-dialog decision (per the staged rollout below).
+- **Open composition question, not resolved here**: whether `SampleVisionMonitor` *contains* a
+ `VisionInterface` instance internally (composition), or is itself built directly on Layer 0 with
+ its own separate `diagnostic_image` signal and `VisionInterface` stays a purely GUI/alignment-side
+ concept. Composition (reusing one `VisionInterface` per camera pair, shared by both the confirm
+ scoring and any live processed view of that same pair) avoids two devices both owning a signal for
+ the same physical camera pair — likely the cleaner shape, but worth prototyping both.
## Proposed CV pipeline (classical, no deep learning)
@@ -201,13 +244,23 @@ this should not be an all-or-nothing flag flip. Proposed phases for the later im
- **Where scores/results live.** Likely alongside the existing HDF5 reference-image files (same
per-step file, additional dataset for score + registration transform), rather than a new storage
mechanism.
-- **Diagnostic visualization: own `PreviewSignal` vs. borrowing the camera's `smear_preview`** —
- see the architecture section above; needs a short prototype of both before committing.
-- **One `SampleVisionMonitor` config per camera pair vs. one instance whose `camera_names` gets
- reconfigured at call time** — static per-pair config (e.g. `omny_vision_parking`,
- `omny_vision_samplestage` as two fixed device-config entries) is simpler and matches how OMNY's
- own two camera pairs are already fixed, not dynamic; leaning toward this unless a concrete need
- for a dynamic pairing shows up.
+- **`SampleVisionMonitor` composing a `VisionInterface` vs. owning its own signal directly** — see
+ the "open composition question" at the end of the architecture section; needs a short prototype of
+ both before committing.
+- **One `SampleVisionMonitor`/`VisionInterface` config per camera pair vs. one instance whose
+ `camera_names` gets reconfigured at call time** — static per-pair config (e.g.
+ `omny_vision_parking`, `omny_vision_samplestage` as two fixed device-config entries) is simpler
+ and matches how OMNY's own two camera pairs are already fixed, not dynamic; leaning toward this
+ unless a concrete need for a dynamic pairing shows up.
+- **How an operator switches `VisionInterface`'s mode from the GUI.** The existing
+ `smear_preview_toggle` (`x_ray_eye.py:386-873`) is a single on/off switch between two fixed
+ channels; a `VisionInterface` with several selectable modes (raw/grayscale/edges/CLAHE/overlay)
+ needs a small mode picker, not just a toggle — new GUI widget surface, not sketched yet.
+- **Overlay compositing vs. the GUI's own annotations.** `x_ray_eye_align.py`'s alignment GUI
+ already lets the operator click to place points/markers on the live image; a `VisionInterface`
+ "overlay" mode drawing its own graphics (contours, diff highlights) onto the frame it republishes
+ must not visually conflict with or obscure those widget-drawn markers — needs checking against the
+ actual widget code before this mode is built, not assumed compatible.
## Non-goals for this session
From e8baa759e91e75a6e8fa9c967bfbd1f7252cd495 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Sun, 6 Sep 2026 13:21:05 +0200
Subject: [PATCH 063/102] docs(omny): spell out CLI-first operator interface
for the vision layer
Per feedback: the vision device(s) should be operable from the command
line as their own separately-addressable interface, not gated behind
GUI widget work. Notes this falls out of BEC's normal device-RPC model
for free (same mechanism get_last_image() already uses client-side),
and proposes thin omny.ovision_* wrapper methods matching the existing
otransfer_* wrapper convention around dev.omny_samples.*. Downgrades the
GUI mode-picker open question to strictly additive, not a prerequisite.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
---
.../MACHINE_VISION_AUTOCONFIRM_PLAN.md | 44 +++++++++++++++++--
1 file changed, 40 insertions(+), 4 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
index 1e624ba8..b73cb7a8 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md
@@ -131,6 +131,12 @@ publishing a transformed view instead of (or alongside) the raw feed.
in-between interface rather than a camera-specific patch: the GUI dock watches the
`VisionInterface`'s output, and swapping/reconfiguring the source camera or the active mode
doesn't change what the dock is looking at.
+- **Command-line addressable from day one, independent of any GUI work.** `set_mode()` and any other
+ controls are plain device RPC methods — like `get_last_image()`, they're callable directly from a
+ BEC session (`dev.omny_vision_parking.set_mode("edges")`) the moment the device exists, with no
+ GUI widget required. The operator-facing question "how does someone switch modes" therefore has an
+ answer on day one regardless of whether/when a GUI mode-picker gets built — see the dedicated CLI
+ section below.
- **Two independent consumers**, justifying building this generically rather than folding it
straight into the sample-transfer use case:
- **`SampleVisionMonitor`** (Layer 2, below) uses it for registration + the diagnostic overlay it
@@ -174,6 +180,34 @@ top of Layers 0/1*, not a separate camera-coupled implementation:
scoring and any live processed view of that same pair) avoids two devices both owning a signal for
the same physical camera pair — likely the cleaner shape, but worth prototyping both.
+### Operator interface: command line first, a GUI mode-picker second
+
+Per feedback: the tool an operator uses to drive this should not be gated behind new GUI widget
+work — it should be addressable on the command line as its own, separately-callable interface,
+independent of whether/when a GUI control for it exists.
+
+- **This falls out of BEC's normal device-RPC model, not something to build specially.** Every
+ method on a device (`VisionInterface.set_mode()`, `SampleVisionMonitor.save_reference()`/`score()`)
+ is already callable directly from a BEC ipython session the moment the device exists —
+ `dev.omny_vision_parking.set_mode("edges")`, `dev.omny_vision_parking.score("put_mount_start_3")`
+ — the exact same mechanism `get_last_image()` already relies on for its own direct client-side
+ calls in `x_ray_eye_align.py`. No dedicated CLI layer needs to be designed from scratch; it's a
+ free consequence of this being a device.
+- **Thin per-endstation wrapper methods, matching the existing convention.** Raw device names
+ (`omny_vision_parking`, `omny_vision_samplestage`) aren't the ergonomic surface an operator should
+ have to remember. `omny_sample_transfer_mixin.py` already establishes the pattern for exactly this
+ problem — its `otransfer_is_sample_slot_used()`/`otransfer_set_sample_slot()`/etc. block (lines
+ 1175-1255) is a set of thin one-line wrappers around `dev.omny_samples.*`, added, per its own
+ comment, "so every sample-storage command tab-completes under `omny.otransfer_*`". The same shape
+ applies here: short `omny.ovision_*` wrappers (e.g. `ovision_set_mode(pair, mode)`,
+ `ovision_score(tag)`, `ovision_save_reference(tag)`) that dispatch to the right
+ `SampleVisionMonitor`/`VisionInterface` instance for the currently-active camera pair (using
+ `_omnycam_active_pair`, already tracked — see "Existing infrastructure" above), so an operator
+ never needs to know the underlying device names at all.
+- **GUI mode-picker (the open question above) becomes strictly additive**, not a prerequisite —
+ the command-line interface is complete and usable the day `VisionInterface`/`SampleVisionMonitor`
+ land, regardless of GUI work landing later or not at all.
+
## Proposed CV pipeline (classical, no deep learning)
Per the original discussion this grew out of: classical machine vision is enough for a
@@ -252,10 +286,12 @@ this should not be an all-or-nothing flag flip. Proposed phases for the later im
`omny_vision_parking`, `omny_vision_samplestage` as two fixed device-config entries) is simpler
and matches how OMNY's own two camera pairs are already fixed, not dynamic; leaning toward this
unless a concrete need for a dynamic pairing shows up.
-- **How an operator switches `VisionInterface`'s mode from the GUI.** The existing
- `smear_preview_toggle` (`x_ray_eye.py:386-873`) is a single on/off switch between two fixed
- channels; a `VisionInterface` with several selectable modes (raw/grayscale/edges/CLAHE/overlay)
- needs a small mode picker, not just a toggle — new GUI widget surface, not sketched yet.
+- **A GUI mode-picker for `VisionInterface`** (as opposed to the command-line control, which is
+ covered — see "Operator interface" above). The existing `smear_preview_toggle`
+ (`x_ray_eye.py:386-873`) is a single on/off switch between two fixed channels; a `VisionInterface`
+ with several selectable modes (raw/grayscale/edges/CLAHE/overlay) would need a small mode picker
+ instead, not just a toggle — new GUI widget surface, not sketched yet, and not a blocker for
+ anything else in this plan.
- **Overlay compositing vs. the GUI's own annotations.** `x_ray_eye_align.py`'s alignment GUI
already lets the operator click to place points/markers on the live image; a `VisionInterface`
"overlay" mode drawing its own graphics (contours, diff highlights) onto the frame it republishes
From 144c1eabf02e45af3a14fd95e8b46339553c6b4f Mon Sep 17 00:00:00 2001
From: x01dc
Date: Sun, 6 Sep 2026 13:41:15 +0200
Subject: [PATCH 064/102] fix(flomni): save a gripper reference image at the
empty/occupied checks
ftransfer_get_sample()/ftransfer_put_sample() only ever saved a gripper
camera image at the final "All OK?" confirm, never at the earlier
automated "is there a sample in the gripper" check -- so there was no
visual record of the gripper state when that check failed and aborted
the transfer. Extract the existing image-capture logic (used by
ftransfer_confirm) into _ftransfer_capture_gripper_image() and call it
at both gripper-state decision points, before the FlomniError is raised.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
---
.../plugins/flomni/flomni.py | 19 ++++-
.../test_flomni_gripper_check_images.py | 84 +++++++++++++++++++
2 files changed, 99 insertions(+), 4 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_flomni_gripper_check_images.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index 91a603ba..547d5033 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -751,6 +751,7 @@ class FlomniSampleTransferMixin:
self.check_sensor_connected()
sample_in_gripper = dev.flomni_samples.is_sample_in_gripper()
+ self._ftransfer_capture_gripper_image(file_suffix="get_gripper_empty_check")
if sample_in_gripper:
raise FlomniError(
"The gripper does carry a sample. Cannot proceed getting another sample."
@@ -812,6 +813,7 @@ class FlomniSampleTransferMixin:
sample_in_gripper = dev.flomni_samples.is_sample_in_gripper()
# dev.flomni_samples.sample_in_gripper.get()
+ self._ftransfer_capture_gripper_image(file_suffix="put_gripper_occupied_check")
if not sample_in_gripper:
raise FlomniError("The gripper does not carry a sample.")
@@ -1106,16 +1108,25 @@ class FlomniSampleTransferMixin:
self.transfer_step += 1
if self.ftransfer_confirm_dialog("All OK? Continue?", "y"):
print("OK. continue.")
- data = self.client.connector.get_last(
- MessageEndpoints.device_preview("cam_flomni_gripper", "preview")
- )["data"].data
- self.save_reference_image(data, file_suffix=f"{step_name}_{self.transfer_step}")
+ self._ftransfer_capture_gripper_image(file_suffix=f"{step_name}_{self.transfer_step}")
dev.ftransy.controller.socket_put_confirmed("confirm=1")
else:
print("Stopping.")
self.ftransfer_abort()
raise FlomniError("User abort sample transfer.")
+ def _ftransfer_capture_gripper_image(self, file_suffix: str = ""):
+ """Grab the latest gripper-camera preview and write it to disk via
+ save_reference_image(). Used at every gripper state decision point
+ (empty/occupied checks and the "All OK?" motion-loop confirms) so
+ the sample transfer log has a visual record of the gripper at each
+ step, not just the final confirm.
+ """
+ data = self.client.connector.get_last(
+ MessageEndpoints.device_preview("cam_flomni_gripper", "preview")
+ )["data"].data
+ self.save_reference_image(data, file_suffix=file_suffix)
+
def save_reference_image(self, image: np.ndarray, file_suffix: str = ""):
suffix = f"_{file_suffix}_" or "_"
# Save the reference image to a file
diff --git a/tests/tests_bec_ipython_client/test_flomni_gripper_check_images.py b/tests/tests_bec_ipython_client/test_flomni_gripper_check_images.py
new file mode 100644
index 00000000..0cc06730
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_flomni_gripper_check_images.py
@@ -0,0 +1,84 @@
+"""Tests that flomni's gripper-empty/gripper-occupied sample-transfer
+checks capture a gripper camera reference image, not just the final
+"All OK?" confirm (see csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/
+OPEN_ISSUES.md, "flomni has no equivalent gate to save at").
+
+Uses a bare FlomniSampleTransferMixin instance (bypassing Flomni's heavy
+__init__), same pattern as test_lamni_account_change_reset.py.
+"""
+
+import pytest
+
+import csaxs_bec.bec_ipython_client.plugins.flomni.flomni as flomni_module
+from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import (
+ FlomniError,
+ FlomniSampleTransferMixin,
+)
+
+
+class FakeConnector:
+ def __init__(self, image):
+ self.image = image
+ self.requested_endpoints = []
+
+ def get_last(self, endpoint):
+ self.requested_endpoints.append(endpoint)
+ return {"data": type("Msg", (), {"data": self.image})()}
+
+
+class FakeClient:
+ def __init__(self, image="gripper-cam-frame"):
+ self.connector = FakeConnector(image)
+
+
+class FakeFlomniSamples:
+ def __init__(self, sample_in_gripper):
+ self._sample_in_gripper = sample_in_gripper
+
+ def is_sample_in_gripper(self):
+ return self._sample_in_gripper
+
+
+class _FakeDev:
+ def __init__(self, sample_in_gripper):
+ self.flomni_samples = FakeFlomniSamples(sample_in_gripper)
+
+
+def make_flomni(sample_in_gripper):
+ flomni_module.dev = _FakeDev(sample_in_gripper)
+ obj = object.__new__(FlomniSampleTransferMixin)
+ obj.client = FakeClient()
+ obj.check_tray_in = lambda: None
+ obj.check_sensor_connected = lambda: None
+ obj.saved_images = []
+ obj.save_reference_image = lambda image, file_suffix="": obj.saved_images.append(
+ (image, file_suffix)
+ )
+ return obj
+
+
+def test_get_sample_captures_image_before_raising_when_gripper_occupied():
+ flomni = make_flomni(sample_in_gripper=True)
+
+ with pytest.raises(FlomniError, match="does carry a sample"):
+ flomni.ftransfer_get_sample(5)
+
+ assert flomni.saved_images == [("gripper-cam-frame", "get_gripper_empty_check")]
+
+
+def test_put_sample_captures_image_before_raising_when_gripper_empty():
+ flomni = make_flomni(sample_in_gripper=False)
+
+ with pytest.raises(FlomniError, match="does not carry a sample"):
+ flomni.ftransfer_put_sample(5)
+
+ assert flomni.saved_images == [("gripper-cam-frame", "put_gripper_occupied_check")]
+
+
+def test_capture_gripper_image_fetches_preview_and_saves_it():
+ flomni = make_flomni(sample_in_gripper=False)
+
+ flomni._ftransfer_capture_gripper_image(file_suffix="my_suffix")
+
+ assert flomni.saved_images == [("gripper-cam-frame", "my_suffix")]
+ assert flomni.client.connector.requested_endpoints
From cba7c378dc0ff005d8b9106015bcabdb62e8d889 Mon Sep 17 00:00:00 2001
From: Mirko Holler
Date: Sat, 17 Oct 2026 15:19:40 +0200
Subject: [PATCH 065/102] feat(flomni): sample storage on the new flOMNI soft
IOC (FLOMNI-SAMPLE::)
The flOMNI sample storage moves from the XOMNY-SAMPLE_DB_flomni:GET/.DESC
records (name and owner packed into one 40-char DESC via sample_desc_codec,
measurement status in the BEC global var "flomni_measured_samples" keyed by
that packed string) to one record per slot on the flOMNI soft IOC
(flOMNI repo, softioc/ioc_flomni.py):
FLOMNI-SAMPLE::USED / NAME (up to 256 chars) / OWNER /
STATUS (none|started|completed) / MEAS_DATE
= STAGE (0), T01..T20 (1-20), GRIPPER (100)
No transition (decided by Mirko 2026-10-17).
- FlomniSampleStorage: same public methods (slot 100 = gripper), attribute
names sample_placed/sample_names/sample_in_gripper[_name] kept; new
sample_owners/sample_status/sample_meas_date (+ gripper equivalents),
get_sample_status/meas_date/record, set_sample_measured(slot, status,
date, owner_if_empty) and transfer_sample(from, to), which moves the whole
record so the measurement status travels with the sample
- flomni plugin: transfers use transfer_sample(); tomo_scan marks the stage
sample started/completed on the record; the label offer reads status,
date and owner from the record; _MeasuredSampleLog and
sample_get_measured_log_key removed
Behaviour change: the label's account is the sample owner (was the active
account at measurement time); set_sample_measured fills an empty owner
with the active account
- sample storage widget: bulk read incl. owner/status, writes via
set_sample_slot/unset_sample_slot; no global-var measured log
- webpage generator: name and owner from separate fields
- e2e harness and tomo test updated; nexus writer unchanged (sample name is
now the plain name)
Tested against the live IOC on mpc2844 (ophyd): long names, transfers slot ->
gripper -> stage with status/date, owner_if_empty, show_all, clear, slot
range check, bulk read() keys.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01TNtKEk4jAyjebMDQFwRHJQ
---
.../plugins/flomni/flomni.py | 152 ++--------
.../flomni/flomni_webpage_generator.py | 20 +-
.../widgets/sample_storage/sample_storage.py | 145 +++-------
.../devices/omny/flomni_sample_storage.py | 261 ++++++++++++------
csaxs_bec/devices/sim/sim_sample_storage.py | 2 +-
omny_e2e_tests/sim_flomni_harness.py | 45 +--
.../test_flomni_tomo_angles.py | 7 +-
7 files changed, 293 insertions(+), 339 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index 093a1c3e..329a2b41 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -42,7 +42,6 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.ptouch_printer import PTouchLabelPrinter
from csaxs_bec.devices.omny.galil.galil_ophyd import GalilError
-from csaxs_bec.devices.omny.sample_desc_codec import pack_desc, unpack_desc
logger = bec_logger.logger
@@ -818,13 +817,11 @@ class FlomniSampleTransferMixin:
self.ftransfer_controller_disable_mount_mode()
self.ensure_gripper_up()
- signal_name = getattr(dev.flomni_samples.sample_names, f"sample{position}")
- name, owner = unpack_desc(signal_name.get())
- self.flomni_modify_storage_non_interactive(100, 1, name, owner=owner)
- self.flomni_modify_storage_non_interactive(position, 0, "-")
+ # the whole record (name, owner, measurement status/date) moves to the gripper
+ dev.flomni_samples.transfer_sample(position, 100)
def ftransfer_show_all(self):
- dev.flomni_samples.show_all(measured=self.measured_log.snapshot())
+ dev.flomni_samples.show_all()
def ftransfer_put_sample(self, position: int):
self.check_position_is_valid(position)
@@ -877,9 +874,8 @@ class FlomniSampleTransferMixin:
self.ftransfer_controller_disable_mount_mode()
self.ensure_gripper_up()
- sample_name, sample_owner = unpack_desc(dev.flomni_samples.sample_in_gripper_name.get())
- self.flomni_modify_storage_non_interactive(100, 0, "-")
- self.flomni_modify_storage_non_interactive(position, 1, sample_name, owner=sample_owner)
+ # the whole record (name, owner, measurement status/date) moves from the gripper
+ dev.flomni_samples.transfer_sample(100, position)
if position == 0:
self.ftransfer_flomni_stage_in()
@@ -889,18 +885,7 @@ class FlomniSampleTransferMixin:
"""
Get the name of the sample currently in the given position.
"""
- signal_name = getattr(dev.flomni_samples.sample_names, f"sample{position}")
- return unpack_desc(signal_name.get())[0]
-
- def sample_get_measured_log_key(self, position: int = 0) -> str:
- """Packed `name | owner` identity key for the sample at `position`,
- for use with `self.measured_log` -- NOT the same as `sample_name`
- (a `sample_get_name()` alias), which is name-only and would silently
- under-key the measured log against a different sample of the same
- name but a different owner.
- """
- name, owner = dev.flomni_samples.get_sample_name_and_owner(position)
- return pack_desc(name, owner)
+ return dev.flomni_samples.get_sample_name(position)
def ftransfer_sample_change(self, new_sample_position: int):
self.check_tray_in()
@@ -1039,23 +1024,22 @@ class FlomniSampleTransferMixin:
print offer would incorrectly fire on every ordinary transfer step
instead of only on a genuine manual clear/replace.
"""
- if not dev.flomni_samples.is_sample_slot_used(position):
- return
- prev_name, prev_owner = dev.flomni_samples.get_sample_name_and_owner(position)
- prev_packed = pack_desc(prev_name, prev_owner)
- record = self.measured_log.pop(prev_packed)
- if record is None:
+ # measurement status/date/owner live on the sample record in the IOC
+ # (FLOMNI-SAMPLE::STATUS/MEAS_DATE/OWNER) since 2026-10-17
+ record = dev.flomni_samples.get_sample_record(position)
+ if not record["used"] or record["status"] == "none":
return
+ prev_name = record["name"]
if self.OMNYTools.yesno(
f"Sample '{prev_name}' was measured ({record['status']}) on"
- f" {record['measured_date']}. Print a label?",
+ f" {record['meas_date']}. Print a label?",
"y",
):
self.label_printer.ensure_ready()
printed = self.label_printer.print_label(
{
- self._PTOUCH_FIELD_ACCOUNT: record["account"],
- self._PTOUCH_FIELD_DATE: record["measured_date"],
+ self._PTOUCH_FIELD_ACCOUNT: record["owner"],
+ self._PTOUCH_FIELD_DATE: record["meas_date"],
self._PTOUCH_FIELD_SAMPLENAME: prev_name,
}
)
@@ -1068,15 +1052,11 @@ class FlomniSampleTransferMixin:
def flomni_modify_storage_non_interactive(
self, position: int, used: int, name: str, owner: str = ""
):
- packed_name = pack_desc(name, owner)
- if position == 100:
- dev.flomni_samples.sample_in_gripper.set(used).wait(timeout=5)
- dev.flomni_samples.sample_in_gripper_name.set(packed_name).wait(timeout=5)
+ # position 100 = gripper; a new/renamed sample starts with measurement status "none"
+ if used:
+ dev.flomni_samples.set_sample_slot(position, name, owner=owner)
else:
- signal = getattr(dev.flomni_samples.sample_placed, f"sample{position}")
- signal.set(used).wait(timeout=5)
- signal_name = getattr(dev.flomni_samples.sample_names, f"sample{position}")
- signal_name.set(packed_name).wait(timeout=5)
+ dev.flomni_samples.unset_sample_slot(position)
def check_position_is_valid(self, position: int):
if 0 <= position < 21:
@@ -1710,83 +1690,6 @@ class _ProgressProxy:
return self._load()
-class _MeasuredSampleLog:
- """Tracks which flomni samples have been measured, for the P-touch
- label-printing feature (print an account/date/name label when a
- measured sample is cleared or replaced in tray storage).
-
- Keyed by the packed ``name | owner`` string (see pack_desc/unpack_desc
- in sample_desc_codec.py) rather than by slot number, since that packed
- string is the identity that already travels with a sample across
- ftransfer_get_sample/ftransfer_put_sample transfers between tray
- slots, the gripper, and the stage -- tracking by slot number would
- require threading a flag through every one of those transfer calls.
- Persisted as a BEC global var (same pattern as _ProgressProxy above)
- so it's visible to any client session -- CLI or GUI widget -- and
- survives a kernel restart.
-
- Two statuses: "started" (a fresh tomogram began for this sample) and
- "completed" (that tomogram's scan loop ran to completion without
- error). Both count as "measured" for is_measured()/the print offer --
- "started" without "completed" just means the tomogram may be
- incomplete, which callers surface in the offer wording rather than
- suppressing the offer entirely.
- """
-
- _GLOBAL_VAR_KEY = "flomni_measured_samples"
-
- def __init__(self, client):
- self._client = client
-
- def _load(self) -> dict:
- val = self._client.get_global_var(self._GLOBAL_VAR_KEY)
- return val if val is not None else {}
-
- def _save(self, data: dict) -> None:
- self._client.set_global_var(self._GLOBAL_VAR_KEY, data)
-
- def mark_measured(self, sample_key: str, account: str, date: str, status: str) -> None:
- """Record `sample_key` as measured, overwriting any existing record.
-
- Overwriting (rather than merging) means re-measuring a sample from
- scratch correctly resets a stale "completed" back to "started".
- """
- data = self._load()
- data[sample_key] = {"account": account, "measured_date": date, "status": status}
- self._save(data)
-
- def is_measured(self, sample_key: str) -> bool:
- return sample_key in self._load()
-
- def get(self, sample_key: str) -> dict | None:
- return self._load().get(sample_key)
-
- def snapshot(self) -> dict:
- """One bulk fetch of the whole log, as a plain dict.
-
- Used instead of `get` when the caller needs to hand the data to
- `dev.flomni_samples.show_all()`, which runs via BEC's
- device-server RPC mechanism -- every argument gets msgpack-
- serialized, so a bound method (`self.measured_log.get`) can't be
- passed directly; a plain dict can.
- """
- return self._load()
-
- def pop(self, sample_key: str) -> dict | None:
- """Remove and return the record for `sample_key`, if any.
-
- Called once the print offer for that sample has been resolved
- (printed or declined), so the log stays bounded to
- currently-measured-but-not-yet-cleared samples instead of growing
- unbounded over a beamtime.
- """
- data = self._load()
- record = data.pop(sample_key, None)
- if record is not None:
- self._save(data)
- return record
-
-
class Flomni(
TomoQueueMixin,
TomoAngleFileMixin,
@@ -1843,7 +1746,6 @@ class Flomni(
self.reconstructor = PtychoReconstructor(self.ptycho_reconstruct_foldername)
self.tomo_id_manager = TomoIDManager()
self.align = XrayEyeAlign(self.client, self)
- self.measured_log = _MeasuredSampleLog(self.client)
self.label_printer = PTouchLabelPrinter(host="BRN94DDF8AAB8EC.psi.ch", template_number=1)
self.set_client(client)
@@ -2842,11 +2744,12 @@ class Flomni(
"test additional info",
"BEC",
)
- self.measured_log.mark_measured(
- self.sample_get_measured_log_key(0),
- bec.active_account or "",
+ # measurement status on the sample record (stage slot 0) in the IOC
+ dev.flomni_samples.set_sample_measured(
+ 0,
+ "started",
datetime.date.today().strftime("%Y-%m"),
- status="started",
+ owner_if_empty=bec.active_account or "",
)
self.write_pdf_report()
# estimated_remaining_time/estimated_finish_time/accumulated_idle_time/
@@ -3035,11 +2938,12 @@ class Flomni(
self._print_progress()
self._log_tomogram_timing()
self.OMNYTools.printgreenbold("Tomoscan finished")
- self.measured_log.mark_measured(
- self.sample_get_measured_log_key(0),
- bec.active_account or "",
+ # measurement status on the sample record (stage slot 0) in the IOC
+ dev.flomni_samples.set_sample_measured(
+ 0,
+ "completed",
datetime.date.today().strftime("%Y-%m"),
- status="completed",
+ owner_if_empty=bec.active_account or "",
)
timing_lines = self._build_timing_lines()
for line in timing_lines[3:]:
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py
index 7538e26c..105fc6ef 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py
@@ -43,7 +43,6 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base imp
_safe_get,
_safe_float,
)
-from csaxs_bec.devices.omny.sample_desc_codec import unpack_desc
class FlomniWebpageGenerator(WebpageGeneratorBase):
@@ -106,19 +105,16 @@ class FlomniWebpageGenerator(WebpageGeneratorBase):
# ── Sample name ───────────────────────────────────────────────
# slot 0 of flomni_samples is the flOMNI measurement stage itself
# (see FlomniSampleStorage.show_all()'s own "flOMNI stage:" label
- # for slot 0 -- not an arbitrary tray position). The raw DESC value
- # is packed as "name | owner" (sample_desc_codec.pack_desc, added
- # 7e8e807) -- unpack it here too, the same way
- # FlomniSampleStorage.get_sample_name_and_owner() and show_all()
- # already do, so the page doesn't show the raw delimiter-joined
- # string as the "sample name". Owner is appended in parentheses,
- # mirroring show_all()'s own "(owner: ...)" formatting.
- raw_sample = _safe_get(dm, "flomni_samples.sample_names.sample0")
- if raw_sample is None:
+ # for slot 0 -- not an arbitrary tray position). Name and owner are
+ # separate IOC fields since 2026-10-17 (FLOMNI-SAMPLE:STAGE:NAME /
+ # :OWNER); owner is appended in parentheses, mirroring show_all()'s
+ # own "(owner: ...)" formatting.
+ name = _safe_get(dm, "flomni_samples.sample_names.sample0")
+ if name is None:
sample_name = "N/A"
else:
- name, owner = unpack_desc(raw_sample)
- sample_name = f"{name} (owner: {owner})" if owner else name
+ owner = _safe_get(dm, "flomni_samples.sample_owners.sample0")
+ sample_name = f"{name} (owner: {owner})" if owner else str(name)
# ── Temperatures ──────────────────────────────────────────────
temperatures = {
diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
index 9348f07a..64963266 100644
--- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
+++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
@@ -29,10 +29,14 @@ single source of truth also consulted by the transfer state machine. The
widget reads/writes it directly, mirroring
``Flomni.flomni_modify_storage_non_interactive()``:
- slots 0–20 : sample_placed.sample{N}.set(used)
- sample_names.sample{N}.set(name)
- gripper : sample_in_gripper.set(used)
- sample_in_gripper_name.set(name)
+ writes : set_sample_slot(N, name, owner) / unset_sample_slot(N)
+ (N = 0–20, 100 = gripper; device methods, all record fields)
+ reads : one bulk read() of sample_placed / sample_names /
+ sample_owners / sample_status (gripper: sample_in_gripper*)
+
+Since 2026-10-17 every slot is one record on the flOMNI soft IOC
+(FLOMNI-SAMPLE::USED/NAME/OWNER/STATUS/MEAS_DATE); the measurement
+status travels with the sample and is no longer kept in a BEC global var.
Polling
-------
@@ -69,7 +73,6 @@ from qtpy.QtWidgets import (
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.ptouch_printer import PTouchLabelPrinter
-from csaxs_bec.devices.omny.sample_desc_codec import pack_desc, unpack_desc
logger = bec_logger.logger
@@ -115,54 +118,6 @@ PTOUCH_FIELD_SAMPLENAME = "SAMPLENAME"
PRINTER_NAME = "WSLA_X12SA"
-# ── measured-sample log ──────────────────────────────────────────────────────
-
-
-class _MeasuredSampleLog:
- """Tracks which flomni samples have been measured, for the P-touch
- label-printing feature.
-
- Deliberately duplicated here rather than imported from
- ``flomni.py``'s ``_MeasuredSampleLog`` -- pulling in that whole plugin
- module (CLI-only dependencies, module-level ``bec``/``dev`` builtins
- access) into this widget's gui-server process is a heavier and more
- fragile coupling than re-declaring this ~15-line global-var wrapper,
- and this widget already duplicates ``flomni_modify_storage_non_interactive()``
- the same way (see module docstring). Both copies read/write the same
- ``flomni_measured_samples`` global-var key, so they stay consistent in
- effect despite being independent code, exactly like the existing
- ``_write_slot``/``flomni_modify_storage_non_interactive`` duplication.
- """
-
- _GLOBAL_VAR_KEY = "flomni_measured_samples"
-
- def __init__(self, client):
- self._client = client
-
- def _load(self) -> dict:
- val = self._client.get_global_var(self._GLOBAL_VAR_KEY)
- return val if val is not None else {}
-
- def _save(self, data: dict) -> None:
- self._client.set_global_var(self._GLOBAL_VAR_KEY, data)
-
- def get(self, sample_key: str) -> Optional[dict]:
- return self._load().get(sample_key)
-
- def snapshot(self) -> dict:
- """One bulk fetch of the whole log, for callers (like the poll
- loop below) that need to look up many sample keys at once without
- one redis round-trip per key."""
- return self._load()
-
- def pop(self, sample_key: str) -> Optional[dict]:
- data = self._load()
- record = data.pop(sample_key, None)
- if record is not None:
- self._save(data)
- return record
-
-
# ── slot cell widget ─────────────────────────────────────────────────────────
@@ -232,7 +187,7 @@ class _SlotCell(QFrame):
) -> None:
"""Update the cell's displayed state (called by the poll/refresh).
- `measured_status` is "started"/"completed" (from `_MeasuredSampleLog`)
+ `measured_status` is "started"/"completed" (FLOMNI-SAMPLE::STATUS)
or None if this slot's current occupant has no measured record.
"""
self._occupied = occupied
@@ -309,7 +264,6 @@ class OMNY_SampleStorage(BECWidget, QWidget):
# last-seen (occupied, name, owner, measured_status) per slot, for
# change-detection in refresh()
self._last_state: dict[int, tuple[bool, str, str, Optional[str]]] = {}
- self.measured_log = _MeasuredSampleLog(self.client)
self.label_printer = PTouchLabelPrinter(
host=PTOUCH_PRINTER_HOST, template_number=PTOUCH_TEMPLATE_NUMBER
)
@@ -345,9 +299,8 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def _read_all_slots(self) -> dict[int, tuple[bool, str, str, Optional[str]]]:
"""Return {slot: (occupied, name, owner, measured_status)} for every
slot 0–20 and the gripper from a SINGLE bulk
- ``dev.flomni_samples.read()`` round-trip plus a single bulk
- ``_MeasuredSampleLog.snapshot()`` (one redis round-trip, not one
- per occupied slot).
+ ``dev.flomni_samples.read()`` round-trip (name, owner and
+ measurement status are separate IOC fields since 2026-10-17).
The per-slot accessors (``is_sample_slot_used`` +
``sample_names.sample{N}.get()``) would be ~44 blocking device
@@ -358,8 +311,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
``flomni_samples_sample_names_sample{N}`` /
``flomni_samples_sample_in_gripper`` /
``flomni_samples_sample_in_gripper_name``), the same keys the CLI
- iterated over in ftransfer_sample_change. The raw name signal packs
- name+owner (see ``sample_desc_codec``), unpacked here.
+ iterated over in ftransfer_sample_change.
Every underlying signal is an ``auto_monitor=True`` ``EpicsSignal``
(see ``FlomniSampleStorage``), so BEC already keeps a fresh cached
@@ -375,40 +327,31 @@ class OMNY_SampleStorage(BECWidget, QWidget):
# keep whatever is currently displayed rather than blanking out
return {}
- try:
- measured = self.measured_log.snapshot()
- except Exception as exc:
- logger.warning(f"OMNY_SampleStorage: measured-log read failed: {exc}")
- measured = {}
-
def _val(key, default=None):
entry = data.get(key)
if isinstance(entry, dict):
return entry.get("value", default)
return default
- def _measured_status(used: bool, name: str, owner: str) -> Optional[str]:
- if not used:
- return None
- record = measured.get(pack_desc(name, owner))
- return record["status"] if record else None
+ def _status(used: bool, raw) -> Optional[str]:
+ status = str(raw) if raw is not None else ""
+ return status if used and status in ("started", "completed") else None
for slot in (STAGE_SLOT, *STORAGE_SLOTS):
- used = _val(f"flomni_samples_sample_placed_sample{slot}", 0)
- raw_name = _val(f"flomni_samples_sample_names_sample{slot}", EMPTY_NAME)
- name, owner = unpack_desc(raw_name if raw_name else EMPTY_NAME)
- name = name if name else EMPTY_NAME
- result[slot] = (bool(used), name, owner, _measured_status(bool(used), name, owner))
+ used = bool(_val(f"flomni_samples_sample_placed_sample{slot}", 0))
+ name = _val(f"flomni_samples_sample_names_sample{slot}", EMPTY_NAME) or EMPTY_NAME
+ owner = _val(f"flomni_samples_sample_owners_sample{slot}", "") or ""
+ status = _status(used, _val(f"flomni_samples_sample_status_sample{slot}"))
+ result[slot] = (used, str(name), str(owner), status)
- g_used = _val("flomni_samples_sample_in_gripper", 0)
- g_raw_name = _val("flomni_samples_sample_in_gripper_name", EMPTY_NAME)
- g_name, g_owner = unpack_desc(g_raw_name if g_raw_name else EMPTY_NAME)
- g_name = g_name if g_name else EMPTY_NAME
+ g_used = bool(_val("flomni_samples_sample_in_gripper", 0))
+ g_name = _val("flomni_samples_sample_in_gripper_name", EMPTY_NAME) or EMPTY_NAME
+ g_owner = _val("flomni_samples_sample_in_gripper_owner", "") or ""
result[GRIPPER_SLOT] = (
- bool(g_used),
- g_name,
- g_owner,
- _measured_status(bool(g_used), g_name, g_owner),
+ g_used,
+ str(g_name),
+ str(g_owner),
+ _status(g_used, _val("flomni_samples_sample_in_gripper_status")),
)
return result
@@ -425,39 +368,32 @@ class OMNY_SampleStorage(BECWidget, QWidget):
``Flomni.flomni_modify_storage_non_interactive()``.
"""
try:
- if slot == GRIPPER_SLOT:
- was_used = bool(self._samples.sample_in_gripper.get())
- prev_name, prev_owner = unpack_desc(str(self._samples.sample_in_gripper_name.get()))
- else:
- was_used = self._samples.is_sample_slot_used(slot)
- prev_name, prev_owner = self._samples.get_sample_name_and_owner(slot)
+ record = self._samples.get_sample_record(slot)
except Exception as exc:
logger.warning(
f"OMNY_SampleStorage: could not read previous state of slot {slot} for the"
f" measured-label check: {exc}"
)
return
- if not was_used:
- return
- record = self.measured_log.pop(pack_desc(prev_name, prev_owner))
- if record is None:
+ if not record["used"] or record["status"] not in ("started", "completed"):
return
+ prev_name = record["name"]
reply = QMessageBox.question(
self,
"Print label?",
f"Sample '{prev_name}' was measured ({record['status']}) on"
- f" {record['measured_date']}. Print a label?",
+ f" {record['meas_date']}. Print a label?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
fields = {
- PTOUCH_FIELD_ACCOUNT: record["account"],
- PTOUCH_FIELD_DATE: record["measured_date"],
+ PTOUCH_FIELD_ACCOUNT: record["owner"],
+ PTOUCH_FIELD_DATE: record["meas_date"],
PTOUCH_FIELD_SAMPLENAME: prev_name,
}
- # Retry loop instead of a single attempt: the record was already
- # popped from measured_log above, so if we gave up after one
+ # Retry loop instead of a single attempt: the slot record is
+ # overwritten right after this offer, so if we gave up after one
# failure the label's account/date/name would be gone for good
# even though the operator could just switch the printer on and
# try again a moment later. Mirrors the CLI's ensure_ready() retry
@@ -487,13 +423,12 @@ class OMNY_SampleStorage(BECWidget, QWidget):
"""
self._offer_print_label_for_previous_occupant(slot)
try:
- packed_name = pack_desc(name, owner)
- if slot == GRIPPER_SLOT:
- self._samples.sample_in_gripper.set(used)
- self._samples.sample_in_gripper_name.set(packed_name)
+ # device methods write the whole record (slot 100 = gripper); a new or
+ # renamed sample starts with measurement status "none"
+ if used:
+ self._samples.set_sample_slot(slot, name, owner=owner)
else:
- getattr(self._samples.sample_placed, f"sample{slot}").set(used)
- getattr(self._samples.sample_names, f"sample{slot}").set(packed_name)
+ self._samples.unset_sample_slot(slot)
# drop the cached state for this slot so the refresh() right after
# a write always repaints it, without waiting for the value to
# differ from a possibly-stale cache entry
diff --git a/csaxs_bec/devices/omny/flomni_sample_storage.py b/csaxs_bec/devices/omny/flomni_sample_storage.py
index 3588b261..2ccce0c8 100644
--- a/csaxs_bec/devices/omny/flomni_sample_storage.py
+++ b/csaxs_bec/devices/omny/flomni_sample_storage.py
@@ -1,5 +1,27 @@
+"""flOMNI sample storage on the flOMNI soft IOC (``ioc_flomni.py`` in the flOMNI repo).
+
+One record per slot, all fields autosaved by the IOC::
+
+ FLOMNI-SAMPLE::USED 0 = free, 1 = sample present
+ FLOMNI-SAMPLE::NAME sample name (long string, up to 256 chars)
+ FLOMNI-SAMPLE::OWNER e-account of the owner
+ FLOMNI-SAMPLE::STATUS none / started / completed (measurement status)
+ FLOMNI-SAMPLE::MEAS_DATE date of the measurement
+
+ = STAGE (slot 0, the flOMNI sample stage), T01 ... T20 (tray slots 1-20),
+ GRIPPER (slot 100)
+
+Replaces (2026-10-17) the former ``XOMNY-SAMPLE_DB_flomni:GET`` / ``.DESC`` records,
+where name and owner were packed into one 40-character DESC (``sample_desc_codec``)
+and the measurement status lived in a separate BEC global variable keyed by that
+packed string. The status now travels with the sample: ``transfer_sample()`` moves
+all fields between slots, the gripper and the stage.
+
+An empty string cannot be written to the char-array NAME via Channel Access, so a
+free slot carries the name ``EMPTY_NAME`` ("-").
+"""
+
import time
-from typing import Optional
from ophyd import Component as Cpt
from ophyd import Device
@@ -7,13 +29,36 @@ from ophyd import DynamicDeviceComponent as Dcpt
from ophyd import EpicsSignal
from prettytable import PrettyTable
-from csaxs_bec.devices.omny.sample_desc_codec import pack_desc, unpack_desc
+PV_PREFIX = "FLOMNI-SAMPLE:"
+GRIPPER_SLOT = 100
+STAGE_SLOT = 0
+TRAY_SLOTS = range(1, 21)
+EMPTY_NAME = "-"
+STATUS_NONE = "none"
+STATUS_STARTED = "started"
+STATUS_COMPLETED = "completed"
+STATUS_VALUES = (STATUS_NONE, STATUS_STARTED, STATUS_COMPLETED)
class FlomniSampleStorageError(Exception):
pass
+def _slot_pv(slot_nr: int) -> str:
+ if slot_nr == STAGE_SLOT:
+ return "STAGE"
+ if slot_nr == GRIPPER_SLOT:
+ return "GRIPPER"
+ return f"T{slot_nr:02d}"
+
+
+def _slot_signals(field: str, **kwargs) -> dict:
+ return {
+ f"sample{i}": (EpicsSignal, f"{PV_PREFIX}{_slot_pv(i)}:{field}", dict(auto_monitor=True, **kwargs))
+ for i in range(21)
+ }
+
+
class FlomniSampleStorage(Device):
USER_ACCESS = [
"is_sample_slot_used",
@@ -25,30 +70,36 @@ class FlomniSampleStorage(Device):
"get_sample_name",
"get_sample_owner",
"get_sample_name_and_owner",
+ "get_sample_status",
+ "get_sample_meas_date",
+ "get_sample_record",
+ "set_sample_measured",
+ "transfer_sample",
"show_all",
]
SUB_VALUE = "value"
_default_sub = SUB_VALUE
- sample_placed = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_flomni{i}:GET", {"auto_monitor": True}) for i in range(21)
- }
- sample_placed = Dcpt(sample_placed)
- sample_names = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_flomni{i}:GET.DESC", {"string": True, "auto_monitor": True})
- for i in range(21)
- }
- sample_names = Dcpt(sample_names)
+ # slots 0-20 (0 = flOMNI stage); attribute names kept from the former device
+ sample_placed = Dcpt(_slot_signals("USED"))
+ sample_names = Dcpt(_slot_signals("NAME", string=True))
+ sample_owners = Dcpt(_slot_signals("OWNER", string=True))
+ sample_status = Dcpt(_slot_signals("STATUS", string=True))
+ sample_meas_date = Dcpt(_slot_signals("MEAS_DATE", string=True))
- sample_in_gripper = Cpt(
- EpicsSignal, name="sample_in_gripper", read_pv="XOMNY-SAMPLE_DB_flomni100:GET", auto_monitor=True
- )
+ # gripper (slot 100)
+ sample_in_gripper = Cpt(EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:USED", auto_monitor=True)
sample_in_gripper_name = Cpt(
- EpicsSignal,
- name="sample_in_gripper_name",
- read_pv="XOMNY-SAMPLE_DB_flomni100:GET.DESC",
- string=True,
- auto_monitor=True
+ EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:NAME", string=True, auto_monitor=True
+ )
+ sample_in_gripper_owner = Cpt(
+ EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:OWNER", string=True, auto_monitor=True
+ )
+ sample_in_gripper_status = Cpt(
+ EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:STATUS", string=True, auto_monitor=True
+ )
+ sample_in_gripper_meas_date = Cpt(
+ EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:MEAS_DATE", string=True, auto_monitor=True
)
def __init__(self, prefix="", *, name, **kwargs):
@@ -60,72 +111,132 @@ class FlomniSampleStorage(Device):
self.wait_for_connection()
self._run_subs(sub_type=self.SUB_VALUE, timestamp=timestamp, obj=self)
- def set_sample_slot(self, slot_nr: int, name: str, owner: str = "") -> bool:
- if slot_nr > 20:
- raise FlomniSampleStorageError(f"Invalid slot number {slot_nr}.")
+ # ── slot addressing ──────────────────────────────────────────────────────
+ @staticmethod
+ def _check_slot(slot_nr: int) -> int:
+ slot_nr = int(slot_nr)
+ if slot_nr == GRIPPER_SLOT or 0 <= slot_nr <= 20:
+ return slot_nr
+ raise FlomniSampleStorageError(
+ f"Invalid slot number {slot_nr} (0 = stage, 1-20 = tray, {GRIPPER_SLOT} = gripper)."
+ )
- getattr(self.sample_placed, f"sample{slot_nr}").set(1).wait(timeout=5)
- getattr(self.sample_names, f"sample{slot_nr}").set(pack_desc(name, owner)).wait(timeout=5)
+ def _sig(self, field: str, slot_nr: int) -> EpicsSignal:
+ slot_nr = self._check_slot(slot_nr)
+ if slot_nr == GRIPPER_SLOT:
+ return {
+ "used": self.sample_in_gripper,
+ "name": self.sample_in_gripper_name,
+ "owner": self.sample_in_gripper_owner,
+ "status": self.sample_in_gripper_status,
+ "meas_date": self.sample_in_gripper_meas_date,
+ }[field]
+ group = {
+ "used": self.sample_placed,
+ "name": self.sample_names,
+ "owner": self.sample_owners,
+ "status": self.sample_status,
+ "meas_date": self.sample_meas_date,
+ }[field]
+ return getattr(group, f"sample{slot_nr}")
- def unset_sample_slot(self, slot_nr: int) -> bool:
- if slot_nr > 20:
- raise FlomniSampleStorageError(f"Invalid slot number {slot_nr}.")
+ @staticmethod
+ def _str(val) -> str:
+ return "" if val is None else str(val)
- getattr(self.sample_placed, f"sample{slot_nr}").set(0).wait(timeout=5)
- getattr(self.sample_names, f"sample{slot_nr}").set("-").wait(timeout=5)
+ # ── write ────────────────────────────────────────────────────────────────
+ def _write(self, slot_nr: int, used: int, name: str, owner: str, status: str, meas_date: str):
+ if status not in STATUS_VALUES:
+ raise FlomniSampleStorageError(f"Invalid status {status!r}, expected one of {STATUS_VALUES}.")
+ name = name if name else EMPTY_NAME
+ # name/owner/status/date first, USED last: a client watching USED sees a complete record
+ self._sig("name", slot_nr).set(name).wait(timeout=5)
+ self._sig("owner", slot_nr).set(owner or "").wait(timeout=5)
+ self._sig("status", slot_nr).set(status).wait(timeout=5)
+ self._sig("meas_date", slot_nr).set(meas_date or "").wait(timeout=5)
+ self._sig("used", slot_nr).set(int(used)).wait(timeout=5)
- def set_sample_in_gripper(self, name: str, owner: str = "") -> bool:
- self.sample_in_gripper.set(1).wait(timeout=5)
- self.sample_in_gripper_name.set(pack_desc(name, owner)).wait(timeout=5)
+ def set_sample_slot(
+ self, slot_nr: int, name: str, owner: str = "", status: str = STATUS_NONE, meas_date: str = ""
+ ):
+ """Place a sample record in slot `slot_nr` (0-20, or 100 = gripper)."""
+ self._write(slot_nr, 1, name, owner, status, meas_date)
- def unset_sample_in_gripper(self) -> bool:
- self.sample_in_gripper.set(0).wait(timeout=5)
- self.sample_in_gripper_name.set("-").wait(timeout=5)
+ def unset_sample_slot(self, slot_nr: int):
+ """Clear slot `slot_nr` (0-20, or 100 = gripper)."""
+ self._write(slot_nr, 0, EMPTY_NAME, "", STATUS_NONE, "")
+ def set_sample_in_gripper(self, name: str, owner: str = "", status: str = STATUS_NONE, meas_date: str = ""):
+ self.set_sample_slot(GRIPPER_SLOT, name, owner, status, meas_date)
+
+ def unset_sample_in_gripper(self):
+ self.unset_sample_slot(GRIPPER_SLOT)
+
+ def set_sample_measured(self, slot_nr: int, status: str, meas_date: str, owner_if_empty: str = ""):
+ """Record the measurement status/date of the sample in `slot_nr`.
+
+ If the sample has no owner yet, `owner_if_empty` (e.g. the active
+ e-account) is stored as owner -- the label printed when a measured
+ sample is cleared uses the owner as account.
+ """
+ if status not in STATUS_VALUES:
+ raise FlomniSampleStorageError(f"Invalid status {status!r}, expected one of {STATUS_VALUES}.")
+ if owner_if_empty and not self.get_sample_owner(slot_nr):
+ self._sig("owner", slot_nr).set(owner_if_empty).wait(timeout=5)
+ self._sig("status", slot_nr).set(status).wait(timeout=5)
+ self._sig("meas_date", slot_nr).set(meas_date or "").wait(timeout=5)
+
+ def transfer_sample(self, from_slot: int, to_slot: int):
+ """Move the complete record (name, owner, status, date) from one slot to
+ another (0-20, 100 = gripper) and clear the source slot."""
+ rec = self.get_sample_record(from_slot)
+ self.set_sample_slot(to_slot, rec["name"], rec["owner"], rec["status"], rec["meas_date"])
+ self.unset_sample_slot(from_slot)
+
+ # ── read ─────────────────────────────────────────────────────────────────
def is_sample_slot_used(self, slot_nr: int) -> bool:
- val = getattr(self.sample_placed, f"sample{slot_nr}").get(use_monitor=False)
- return bool(val)
+ return bool(self._sig("used", slot_nr).get(use_monitor=False))
def is_sample_in_gripper(self) -> bool:
- val = self.sample_in_gripper.get(use_monitor=False)
- return bool(val)
+ return self.is_sample_slot_used(GRIPPER_SLOT)
def get_sample_name(self, slot_nr) -> str:
- val = getattr(self.sample_names, f"sample{slot_nr}").get()
- return unpack_desc(str(val))[0]
+ name = self._str(self._sig("name", slot_nr).get())
+ return name if name else EMPTY_NAME
def get_sample_owner(self, slot_nr) -> str:
- val = getattr(self.sample_names, f"sample{slot_nr}").get()
- return unpack_desc(str(val))[1]
+ return self._str(self._sig("owner", slot_nr).get())
def get_sample_name_and_owner(self, slot_nr) -> tuple:
- val = getattr(self.sample_names, f"sample{slot_nr}").get()
- return unpack_desc(str(val))
+ return (self.get_sample_name(slot_nr), self.get_sample_owner(slot_nr))
- def show_all(self, measured: Optional[dict] = None):
- """Print the tray grid plus loaded tray/gripper/stage samples.
+ def get_sample_status(self, slot_nr) -> str:
+ status = self._str(self._sig("status", slot_nr).get())
+ return status if status in STATUS_VALUES else STATUS_NONE
- `measured`, if given, is a plain dict (as returned by
- `_MeasuredSampleLog.snapshot()`) mapping packed
- `pack_desc(name, owner)` strings to {"status": ..., ...} records --
- this device class stays decoupled from the BEC-client-backed
- measured-sample log that produces it; callers (e.g.
- `Flomni.ftransfer_show_all()`) inject the snapshot instead.
+ def get_sample_meas_date(self, slot_nr) -> str:
+ return self._str(self._sig("meas_date", slot_nr).get())
- Must be a plain, msgpack-serializable dict, not a callable: this
- method runs via BEC's device-server RPC mechanism, which
- serializes every argument before sending it, so a bound method
- (an earlier version of this passed `self.measured_log.get`) fails
- with "can not serialize 'method' object".
- """
+ def get_sample_record(self, slot_nr) -> dict:
+ return {
+ "used": self.is_sample_slot_used(slot_nr),
+ "name": self.get_sample_name(slot_nr),
+ "owner": self.get_sample_owner(slot_nr),
+ "status": self.get_sample_status(slot_nr),
+ "meas_date": self.get_sample_meas_date(slot_nr),
+ }
- def _measured_suffix(name: str, owner: str) -> str:
- if not measured:
- return ""
- record = measured.get(pack_desc(name, owner))
- if record is None:
- return ""
- return f" [measured: {record['status']}]"
+ def show_all(self):
+ """Print the tray grid plus loaded tray/gripper/stage samples."""
+
+ def _suffix(slot_nr: int) -> str:
+ owner = self.get_sample_owner(slot_nr)
+ status = self.get_sample_status(slot_nr)
+ text = f" (owner: {owner})" if owner else ""
+ if status != STATUS_NONE:
+ date = self.get_sample_meas_date(slot_nr)
+ text += f" [measured: {status}{', ' + date if date else ''}]"
+ return text
t = PrettyTable()
t.title = "flOMNI sample storage"
@@ -141,21 +252,15 @@ class FlomniSampleStorage(Device):
t.add_row(row)
print(t)
print("\n\nFollowing samples are currently loaded:\n")
- for ct in range(1, 21):
+ for ct in TRAY_SLOTS:
if self.is_sample_slot_used(ct):
- name, owner = self.get_sample_name_and_owner(ct)
- owner_suffix = f" (owner: {owner})" if owner else ""
- print(f" Position {ct:2.0f}: {name}{owner_suffix}{_measured_suffix(name, owner)}")
- if self.sample_in_gripper.get():
- name, owner = unpack_desc(str(self.sample_in_gripper_name.get()))
- owner_suffix = f" (owner: {owner})" if owner else ""
- print(f"\n Gripper: {name}{owner_suffix}{_measured_suffix(name, owner)}\n")
+ print(f" Position {ct:2.0f}: {self.get_sample_name(ct)}{_suffix(ct)}")
+ if self.is_sample_in_gripper():
+ print(f"\n Gripper: {self.get_sample_name(GRIPPER_SLOT)}{_suffix(GRIPPER_SLOT)}\n")
else:
print(f"\n Gripper: no sample\n")
- if self.is_sample_slot_used(0):
- name, owner = self.get_sample_name_and_owner(0)
- owner_suffix = f" (owner: {owner})" if owner else ""
- print(f" flOMNI stage: {name}{owner_suffix}{_measured_suffix(name, owner)}\n")
+ if self.is_sample_slot_used(STAGE_SLOT):
+ print(f" flOMNI stage: {self.get_sample_name(STAGE_SLOT)}{_suffix(STAGE_SLOT)}\n")
else:
print(f" flOMNI stage: no sample\n")
diff --git a/csaxs_bec/devices/sim/sim_sample_storage.py b/csaxs_bec/devices/sim/sim_sample_storage.py
index 602740e7..f70c11d9 100644
--- a/csaxs_bec/devices/sim/sim_sample_storage.py
+++ b/csaxs_bec/devices/sim/sim_sample_storage.py
@@ -1,7 +1,7 @@
"""
Simulated flOMNI sample storage.
-`FlomniSampleStorage` is purely EPICS-backed (XOMNY-SAMPLE_DB_flomni* PVs). The simulated
+`FlomniSampleStorage` is purely EPICS-backed (FLOMNI-SAMPLE:: PVs). The simulated
version follows the patched-device pattern of `simulated_beamline_devices`: the real class
is instantiated with all PVs replaced by in-memory mock PVs, so `set_sample_slot`,
`show_all`, the gripper bookkeeping and the sample-transfer scripts run unchanged; the
diff --git a/omny_e2e_tests/sim_flomni_harness.py b/omny_e2e_tests/sim_flomni_harness.py
index 1b8778e4..4e5c543d 100644
--- a/omny_e2e_tests/sim_flomni_harness.py
+++ b/omny_e2e_tests/sim_flomni_harness.py
@@ -417,36 +417,47 @@ def main(): # noqa: C901
"slot 1 -> gripper bookkeeping",
)
- # --- 9b. sample owner (packed into the same DESC field as name) ------------------
+ # --- 9b. sample record: name, owner, measurement status (separate IOC fields) ----
samples.set_sample_slot(2, "owner_test_sample", owner="mholler")
check(
- samples.get_sample_name(2) == "owner_test_sample", "get_sample_name returns plain name only"
+ samples.get_sample_name(2) == "owner_test_sample", "get_sample_name returns the name"
)
- check(samples.get_sample_owner(2) == "mholler", "get_sample_owner returns the packed owner")
+ check(samples.get_sample_owner(2) == "mholler", "get_sample_owner returns the owner")
check(
samples.get_sample_name_and_owner(2) == ("owner_test_sample", "mholler"),
"get_sample_name_and_owner returns (name, owner)",
)
+ check(samples.get_sample_status(2) == "none", "a new sample starts with status 'none'")
samples.unset_sample_slot(2)
check(
- str(getattr(samples.sample_names, "sample2").get()) == "-",
- "unset_sample_slot writes bare '-', not '- | '",
+ str(getattr(samples.sample_names, "sample2").get()) == "-"
+ and samples.get_sample_owner(2) == "",
+ "unset_sample_slot clears name ('-') and owner",
)
- # gripper move (slot -> gripper) must preserve owner, not just name. The
- # gripper is a separate Cpt (sample_in_gripper_name), not part of the
- # sample_names slot dict, so unpack it directly rather than via
- # get_sample_name_and_owner (which only covers slots 0-20).
- from csaxs_bec.devices.omny.sample_desc_codec import unpack_desc
-
+ # transfer slot -> gripper -> slot must carry the whole record (name, owner,
+ # measurement status and date), slot 100 = gripper
samples.set_sample_slot(4, "transfer_test_sample", owner="e12345")
- name, owner = samples.get_sample_name_and_owner(4)
- samples.set_sample_in_gripper(name, owner=owner)
- samples.unset_sample_slot(4)
+ samples.set_sample_measured(4, "completed", "2026-10")
+ samples.transfer_sample(4, 100)
check(
- unpack_desc(str(samples.sample_in_gripper_name.get())) == ("transfer_test_sample", "e12345"),
- "owner survives a slot -> gripper move",
+ samples.is_sample_in_gripper()
+ and not samples.is_sample_slot_used(4)
+ and samples.get_sample_name_and_owner(100) == ("transfer_test_sample", "e12345")
+ and samples.get_sample_status(100) == "completed"
+ and samples.get_sample_meas_date(100) == "2026-10",
+ "slot -> gripper move carries name, owner, status and date",
)
- samples.unset_sample_in_gripper()
+ samples.transfer_sample(100, 5)
+ check(
+ not samples.is_sample_in_gripper() and samples.get_sample_status(5) == "completed",
+ "gripper -> slot move carries the status",
+ )
+ samples.unset_sample_slot(5)
+ # set_sample_measured fills an empty owner (label printing uses the owner as account)
+ samples.set_sample_slot(6, "no_owner_sample")
+ samples.set_sample_measured(6, "started", "2026-10", owner_if_empty="e99999")
+ check(samples.get_sample_owner(6) == "e99999", "owner_if_empty fills an empty owner")
+ samples.unset_sample_slot(6)
frame_a = ids_cam.cam.get_image_data()
frame_b = ids_cam.cam.get_image_data()
diff --git a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py
index 5385c15d..71e6932c 100644
--- a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py
@@ -243,8 +243,6 @@ def make_flomni_for_golden_resume(monkeypatch, tomo_type: int) -> Flomni:
obj.golden_max_number_of_projections = 21
obj.tomo_id = -1
obj.sample_get_name = lambda position: "test" # backs the read-only sample_name property
- obj.sample_get_measured_log_key = lambda position=0: "test|"
- obj.measured_log = types.SimpleNamespace(mark_measured=lambda *a, **k: None)
obj.special_angles = []
obj._golden = _fake_golden
obj._golden_equally_spaced = _fake_golden
@@ -270,6 +268,11 @@ def make_flomni_for_golden_resume(monkeypatch, tomo_type: int) -> Flomni:
)
monkeypatch.setitem(builtins.__dict__, "bec", fake_bec)
monkeypatch.setitem(builtins.__dict__, "scans", _FakeScans())
+ # tomo_scan() records the measurement status on the sample record (stage slot 0)
+ fake_dev = types.SimpleNamespace(
+ flomni_samples=types.SimpleNamespace(set_sample_measured=lambda *a, **k: None)
+ )
+ monkeypatch.setitem(builtins.__dict__, "dev", fake_dev)
return obj
From 89ba28550c3680f0f0389948d02c060c86c8e86e Mon Sep 17 00:00:00 2001
From: Mirko Holler
Date: Sun, 18 Oct 2026 19:28:39 +0200
Subject: [PATCH 066/102] OMNY sample storage on the OMNY soft IOC records
(OMNY-SAMPLE::...)
OMNYSampleStorage follows FlomniSampleStorage: one record per slot
(USED/NAME/OWNER/STATUS/MEAS_DATE; slots A1..C6, P10..P101, STAGE, GRIPPER,
PARK1..6), served by OMNY's caproto IOC (OMNY repo preemt_rt,
OMNYtools/softioc/ioc_omny.py). Replaces the XOMNY-SAMPLE_DB_* records and the
status x account VAL packing.
- device: slot_name(), transfer_sample() (whole record moves),
set_sample_measured(), get_sample_record()/get_sample_meas_date(); the
(container, slot_nr) API is kept; the 0-20 slot check that rejected 32-34/
100/101 is gone.
- transfer mixin: picks/puts use transfer_sample(); otransfer_* wrappers for
the new methods, the account wrappers removed.
- omny.py tomo_scan(): started/completed + month on the STAGE record (as flOMNI).
- sample_storage widget: bulk-read parser and writes on the new records; label
prints owner as account and the stored date (as flOMNI).
- tomo_params: sample name from sample_names.STAGE.
- SimOMNYSampleStorage, tests, user docs.
Checked against a local ioc_omny.py (real EPICS, localhost): long names,
status enum, transfers, parking.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01TNtKEk4jAyjebMDQFwRHJQ
---
.../plugins/omny/AI_docs/OPEN_ISSUES.md | 6 +
.../bec_ipython_client/plugins/omny/omny.py | 16 +
.../omny/omny_sample_transfer_mixin.py | 92 +--
.../widgets/sample_storage/sample_storage.py | 186 ++---
.../widgets/tomo_params/tomo_params.py | 6 +-
csaxs_bec/devices/omny/omny_sample_storage.py | 738 +++++++-----------
csaxs_bec/devices/sim/sim_omny.py | 58 +-
docs/user/ptychography/omny.md | 9 +-
.../test_omny_tomo_angles.py | 25 +-
.../test_omny_tomo_params_widget_math.py | 6 +-
.../test_omny_tomo_queue.py | 5 +-
.../test_omny_tomo_scan.py | 10 +-
.../tests_devices/test_omny_sample_storage.py | 111 ++-
.../test_sim_omny_sample_storage_defaults.py | 7 +
14 files changed, 508 insertions(+), 767 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
index ad400426..651527d7 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md
@@ -816,6 +816,12 @@ work above). **Mirko's intent: port the same real-EPICS status+account channel t
point** -- a separate, dedicated task (its own `.template` record + `FlomniSampleStorage` changes
+ retiring/reconciling `_MeasuredSampleLog`), not bundled into this branch.
+**Superseded 2026-10-18 (branch `feat/omny-sample-ioc`):** both instruments now keep one
+soft-IOC record per slot (`FLOMNI-SAMPLE::...` since 2026-10-17, `OMNY-SAMPLE::USED/
+NAME/OWNER/STATUS/MEAS_DATE` on OMNY's caproto IOC `ioc_omny.py`); the status is an enum plus a
+date, the status x account packing above and `_MeasuredSampleLog` are gone, `transfer_sample()`
+moves the whole record, and `tomo_scan()` sets started/completed on the STAGE record (as flOMNI).
+
## Reference images now saved at every manual confirm gate in otransfer_get_sample/put_sample -- flomni has no equivalent gate to save at
`_otransfer_save_reference_images()` used to be called only from inside `_otransfer_confirm()`'s
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index a2664d3b..16b67727 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -1365,6 +1365,14 @@ class OMNY(
self.write_pdf_report()
else:
self.tomo_id = 0
+ # measurement status on the sample record (sample stage) in the IOC
+ dev.omny_samples.set_sample_measured(
+ "STAGE",
+ 0,
+ "started",
+ datetime.date.today().strftime("%Y-%m"),
+ owner_if_empty=bec.active_account or "",
+ )
self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
# reset stale estimates from any previous scan, otherwise the GUI
# would keep showing a leftover ETA from before this scan has
@@ -1551,6 +1559,14 @@ class OMNY(
self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"]
self._print_progress()
self.OMNYTools.printgreenbold("Tomoscan finished")
+ # measurement status on the sample record (sample stage) in the IOC
+ dev.omny_samples.set_sample_measured(
+ "STAGE",
+ 0,
+ "completed",
+ datetime.date.today().strftime("%Y-%m"),
+ owner_if_empty=bec.active_account or "",
+ )
idle_s = self.progress.get("accumulated_idle_time", 0.0)
start_str = self.progress.get("tomo_start_time")
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
index 4e6bbdd0..369397e6 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py
@@ -1233,14 +1233,22 @@ class OMNYSampleTransferMixin:
def otransfer_get_sample_name_and_owner(self, container: str, slot_nr: int) -> tuple:
return dev.omny_samples.get_sample_name_and_owner(container, slot_nr)
- def otransfer_set_sample_status(self, container: str, slot_nr: int, status: str, account=None) -> bool:
- return dev.omny_samples.set_sample_status(container, slot_nr, status, account)
+ def otransfer_set_sample_measured(
+ self, container: str, slot_nr: int, status: str, meas_date: str, owner_if_empty: str = ""
+ ):
+ return dev.omny_samples.set_sample_measured(container, slot_nr, status, meas_date, owner_if_empty)
def otransfer_get_sample_status(self, container: str, slot_nr: int) -> str:
return dev.omny_samples.get_sample_status(container, slot_nr)
- def otransfer_get_sample_account(self, container: str, slot_nr: int):
- return dev.omny_samples.get_sample_account(container, slot_nr)
+ def otransfer_get_sample_meas_date(self, container: str, slot_nr: int) -> str:
+ return dev.omny_samples.get_sample_meas_date(container, slot_nr)
+
+ def otransfer_get_sample_record(self, container: str, slot_nr: int) -> dict:
+ return dev.omny_samples.get_sample_record(container, slot_nr)
+
+ def otransfer_transfer_sample(self, from_slot, to_slot):
+ return dev.omny_samples.transfer_sample(from_slot, to_slot)
def otransfer_get_sample_owner_in_gripper(self) -> str:
return dev.omny_samples.get_sample_owner_in_gripper()
@@ -1248,24 +1256,12 @@ class OMNYSampleTransferMixin:
def otransfer_get_sample_owner_in_samplestage(self) -> str:
return dev.omny_samples.get_sample_owner_in_samplestage()
- def otransfer_set_sample_status_in_gripper(self, status: str, account=None) -> bool:
- return dev.omny_samples.set_sample_status_in_gripper(status, account)
-
def otransfer_get_sample_status_in_gripper(self) -> str:
return dev.omny_samples.get_sample_status_in_gripper()
- def otransfer_get_sample_account_in_gripper(self):
- return dev.omny_samples.get_sample_account_in_gripper()
-
- def otransfer_set_sample_status_in_samplestage(self, status: str, account=None) -> bool:
- return dev.omny_samples.set_sample_status_in_samplestage(status, account)
-
def otransfer_get_sample_status_in_samplestage(self) -> str:
return dev.omny_samples.get_sample_status_in_samplestage()
- def otransfer_get_sample_account_in_samplestage(self):
- return dev.omny_samples.get_sample_account_in_samplestage()
-
# def _otransfer_modify_sample(_ocontainer, _oposition, _ostatus, _oname):
# #_ocontainer is A, B, C for the pin containers
# #it can also be FZP, OSA and omny
@@ -1331,23 +1327,11 @@ class OMNYSampleTransferMixin:
# is there a sample available?
if dev.omny_samples.is_sample_slot_used(installedshuttle, _osampleposition):
if _picked_it == 1:
- samplename = dev.omny_samples.get_sample_name(
- installedshuttle, _osampleposition
- )
- owner = dev.omny_samples.get_sample_owner(
- installedshuttle, _osampleposition
- )
- status = dev.omny_samples.get_sample_status(
- installedshuttle, _osampleposition
- )
- account = dev.omny_samples.get_sample_account(
- installedshuttle, _osampleposition
- )
- # remove sample from slot and put to gripper -- owner/status/account
+ # remove sample from slot and put to gripper -- name/owner/status/date
# carry over with it, the same physical sample
- dev.omny_samples.unset_sample_slot(installedshuttle, _osampleposition)
- dev.omny_samples.set_sample_in_gripper(samplename, owner)
- dev.omny_samples.set_sample_status_in_gripper(status, account)
+ dev.omny_samples.transfer_sample(
+ (installedshuttle, _osampleposition), "GRIPPER"
+ )
return True
else:
print("There is no sample availabe at that position.")
@@ -1435,13 +1419,7 @@ class OMNYSampleTransferMixin:
):
if dev.omny_samples.is_sample_slot_used("O", _osampleposition):
if _picked_it:
- samplename = dev.omny_samples.get_sample_name("O", _osampleposition)
- owner = dev.omny_samples.get_sample_owner("O", _osampleposition)
- status = dev.omny_samples.get_sample_status("O", _osampleposition)
- account = dev.omny_samples.get_sample_account("O", _osampleposition)
- dev.omny_samples.unset_sample_slot("O", _osampleposition)
- dev.omny_samples.set_sample_in_gripper(samplename, owner)
- dev.omny_samples.set_sample_status_in_gripper(status, account)
+ dev.omny_samples.transfer_sample(("O", _osampleposition), "GRIPPER")
# FZP was picked
# if (_osampleposition == 10 || _osampleposition == 11 || _osampleposition == 100)
# _otransfer_modify_sample("omny",110,2,samplename)
@@ -1460,13 +1438,7 @@ class OMNYSampleTransferMixin:
elif _osampleposition == 0:
if dev.omny_samples.is_sample_in_samplestage():
if _picked_it:
- samplename = dev.omny_samples.get_sample_name_in_samplestage()
- owner = dev.omny_samples.get_sample_owner_in_samplestage()
- status = dev.omny_samples.get_sample_status_in_samplestage()
- account = dev.omny_samples.get_sample_account_in_samplestage()
- dev.omny_samples.unset_sample_in_samplestage()
- dev.omny_samples.set_sample_in_gripper(samplename, owner)
- dev.omny_samples.set_sample_status_in_gripper(status, account)
+ dev.omny_samples.transfer_sample("STAGE", "GRIPPER")
# FZP was picked
# if (_osampleposition == 10 || _osampleposition == 11 || _osampleposition == 100)
# _otransfer_modify_sample("omny",110,2,samplename)
@@ -1538,16 +1510,8 @@ class OMNYSampleTransferMixin:
# is there free position available?
if not dev.omny_samples.is_sample_slot_used(installedshuttle, _osampleposition):
if _put_it:
- samplename = dev.omny_samples.get_sample_name_in_gripper()
- owner = dev.omny_samples.get_sample_owner_in_gripper()
- status = dev.omny_samples.get_sample_status_in_gripper()
- account = dev.omny_samples.get_sample_account_in_gripper()
- dev.omny_samples.unset_sample_in_gripper()
- dev.omny_samples.set_sample_slot(
- installedshuttle, _osampleposition, samplename, owner
- )
- dev.omny_samples.set_sample_status(
- installedshuttle, _osampleposition, status, account
+ dev.omny_samples.transfer_sample(
+ "GRIPPER", (installedshuttle, _osampleposition)
)
return True
else:
@@ -1636,13 +1600,7 @@ class OMNYSampleTransferMixin:
# is there a free slot available?
if not dev.omny_samples.is_sample_slot_used("O", _osampleposition):
if _put_it:
- samplename = dev.omny_samples.get_sample_name_in_gripper()
- owner = dev.omny_samples.get_sample_owner_in_gripper()
- status = dev.omny_samples.get_sample_status_in_gripper()
- account = dev.omny_samples.get_sample_account_in_gripper()
- dev.omny_samples.set_sample_slot("O", _osampleposition, samplename, owner)
- dev.omny_samples.set_sample_status("O", _osampleposition, status, account)
- dev.omny_samples.unset_sample_in_gripper()
+ dev.omny_samples.transfer_sample("GRIPPER", ("O", _osampleposition))
return True
else:
print("There is no free space at this position.")
@@ -1652,13 +1610,7 @@ class OMNYSampleTransferMixin:
elif _osampleposition == 0:
if not dev.omny_samples.is_sample_in_samplestage():
if _put_it:
- samplename = dev.omny_samples.get_sample_name_in_gripper()
- owner = dev.omny_samples.get_sample_owner_in_gripper()
- status = dev.omny_samples.get_sample_status_in_gripper()
- account = dev.omny_samples.get_sample_account_in_gripper()
- dev.omny_samples.set_sample_in_samplestage(samplename, owner)
- dev.omny_samples.set_sample_status_in_samplestage(status, account)
- dev.omny_samples.unset_sample_in_gripper()
+ dev.omny_samples.transfer_sample("GRIPPER", "STAGE")
return True
else:
print("There is no sample in the sample stage.")
diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
index 90e8517f..99f86b7a 100644
--- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
+++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py
@@ -9,13 +9,13 @@ different setups, detected at construction time (see ``SETUP_PROFILES``,
``_detect_setup()``) — a BEC session is always exactly one setup, never
both at once:
-Both setups require every sample slot to carry a name *and* an owner, but
-via different backend mechanisms:
+Both setups require every sample slot to carry a name *and* an owner; both
+keep one soft-IOC record per slot (USED/NAME/OWNER/STATUS/MEAS_DATE):
FlOMNI (``dev.flomni_samples``)
A flat magazine: slots 1-20, the sample stage (slot 0) and the gripper
- (slot 100). Name and owner are packed into one EPICS DESC field (see
- ``sample_desc_codec``) since this backend has no separate owner PV.
+ (slot 100), FLOMNI-SAMPLE::... since 2026-10-17 (before: name and
+ owner packed into one EPICS DESC field, ``sample_desc_codec``).
OMNY (``dev.omny_samples``)
A richer structure: three shuttles (A/B/C) of 6 slots each, a sparse
@@ -23,11 +23,10 @@ OMNY (``dev.omny_samples``)
separate sample stage and gripper, and a 6-slot "parking" carousel
recording which shuttle letter is docked at each physical parking
position (parking slots are a shuttle-letter assignment, not a sample,
- so they carry no name/owner/status). Unlike FlOMNI, OMNY has a
- dedicated owner channel and a measured-status channel (packing status
- *and* account into one VAL -- see ``OMNY_SampleStorage.STATUS_NONE``)
- next to each sample slot's existing placed/name channels, so no
- packing is needed there. Whichever shuttle's parking slot matches the
+ so they carry no name/owner/status). Since 2026-10-18 every OMNY slot is
+ one record on the OMNY soft IOC, like FlOMNI's
+ (OMNY-SAMPLE::USED/NAME/OWNER/STATUS/MEAS_DATE, see
+ ``OMNYSampleStorage``). Whichever shuttle's parking slot matches the
``oparkz`` motor's current readback is "active", i.e.
at the transfer-ready position (see ``oparkz_active_parking_slot()``).
@@ -66,9 +65,11 @@ FlOMNI
Since 2026-10-17 every slot is one record on the flOMNI soft IOC
(FLOMNI-SAMPLE::USED/NAME/OWNER/STATUS/MEAS_DATE); the measurement
status travels with the sample and is no longer kept in a BEC global var.
-OMNY, stage/gripper/fixed-O/shuttle slots -- see ``_write_slot_omny()``.
-OMNY, parking : parking_placed.parking{N}.set(used)
- parking_names.parking{N}.set(letter or "none")
+OMNY, stage/gripper/fixed-O/shuttle slots: set_sample_slot(container, N,
+ name, owner) / unset_sample_slot(container, N) -- see
+ ``_write_slot_omny()``; the same one-record-per-slot layout
+ (OMNY-SAMPLE::..., 2026-10-18).
+OMNY, parking : set_shuttle_slot(letter, N) / unset_shuttle_slot(N)
Polling
-------
@@ -135,14 +136,6 @@ OMNY_PARKING_SLOTS = tuple(range(1, 7))
# slots (which use EMPTY_NAME = "-"), parking slots are cleared to "none".
PARKING_EMPTY_SENTINEL = "none"
-# Mirrors OMNYSampleStorage.STATUS_NONE, duplicated locally rather than read
-# off the device -- self._samples is the BEC *client-side* device proxy,
-# which only exposes USER_ACCESS methods/signals, not plain class
-# attributes like STATUS_NONE (reaching for self._samples.STATUS_NONE
-# raised "'Device' object has no attribute 'STATUS_NONE'" here, even though
-# it works when testing directly against the real device class).
-OMNY_STATUS_NONE = 0
-
# colours
# An occupied slot's name label intentionally has no fixed foreground colour
# here (see _SlotCell.set_state()) -- it was previously hardcoded to
@@ -337,10 +330,9 @@ class _SlotCell(QFrame):
"""Update the cell's displayed state (called by the poll/refresh).
`measured_status` is "started"/"completed" (FLOMNI-SAMPLE::STATUS)
- or None if this slot's current occupant has no measured record.
- OMNY cells always pass measured_status=None (no measured-sample log
- for OMNY -- see module docstring), which the measured label below
- already renders as blank.
+ (OMNY: OMNY-SAMPLE::STATUS) or None if this slot's current
+ occupant has no measured record, which the measured label below
+ renders as blank.
"""
self._occupied = occupied
self._name = name if name else EMPTY_NAME
@@ -599,11 +591,11 @@ class OMNY_SampleStorage(BECWidget, QWidget):
"""Parse a dev.omny_samples.read(cached=True) dict into
{key: (occupied, name, owner, measured_status)} for every
sample-kind slot -- stage, gripper, the 10 fixed O positions, and
- the 18 shuttle slots. Owner and measured status each have their own
- dedicated OMNYSampleStorage signal (added alongside the existing
- placed/name signals -- see that class's status+account VAL
- encoding, ``STATUS_NONE``), unlike FlOMNI which packs name+owner
- into one field via ``sample_desc_codec``.
+ the 18 shuttle slots. One record per slot on the OMNY soft IOC
+ (OMNY-SAMPLE::USED/NAME/OWNER/STATUS/MEAS_DATE, see
+ ``OMNYSampleStorage``); the flat keys are
+ ``omny_samples_sample__`` with the IOC slot name
+ (A1..C6, P10..P101, STAGE, GRIPPER).
"""
def _val(key, default=None):
@@ -612,50 +604,22 @@ class OMNY_SampleStorage(BECWidget, QWidget):
return entry.get("value", default)
return default
- def _status_str(raw) -> Optional[str]:
- # mirrors OMNYSampleStorage._val_to_status(), without a live
- # device round-trip: this widget only has the bulk-read dict.
- # VAL packs status+account (see that class's status+account VAL
- # encoding comment): 0 -> no badge, negative -> "started",
- # positive -> "completed" (the magnitude is the account, not
- # needed here -- see _status_account() for that).
- try:
- val = int(round(float(raw)))
- except (TypeError, ValueError):
- return None
- if val == 0:
- return None
- return "started" if val < 0 else "completed"
+ def _record(slot: str) -> tuple[bool, str, str, Optional[str]]:
+ used = bool(_val(f"omny_samples_sample_used_{slot}", 0))
+ name = _val(f"omny_samples_sample_names_{slot}", EMPTY_NAME) or EMPTY_NAME
+ owner = _val(f"omny_samples_sample_owners_{slot}", "") or ""
+ raw = _val(f"omny_samples_sample_status_{slot}")
+ status = str(raw) if raw is not None else ""
+ return (used, str(name), str(owner), status if used and status in ("started", "completed") else None)
state: dict[tuple, tuple[bool, str, str, Optional[str]]] = {}
-
- used = bool(_val("omny_samples_sample_in_samplestage", 0))
- name = _val("omny_samples_sample_in_samplestage_name", EMPTY_NAME) or EMPTY_NAME
- owner = _val("omny_samples_sample_in_samplestage_owner", "") or ""
- status = _status_str(_val("omny_samples_sample_in_samplestage_status", 0))
- state[("stage",)] = (used, name, owner, status)
-
- used = bool(_val("omny_samples_sample_in_gripper", 0))
- name = _val("omny_samples_sample_in_gripper_name", EMPTY_NAME) or EMPTY_NAME
- owner = _val("omny_samples_sample_in_gripper_owner", "") or ""
- status = _status_str(_val("omny_samples_sample_in_gripper_status", 0))
- state[("gripper",)] = (used, name, owner, status)
-
+ state[("stage",)] = _record("STAGE")
+ state[("gripper",)] = _record("GRIPPER")
for n in OMNY_FIXED_POSITIONS:
- used = bool(_val(f"omny_samples_sample_placed_sample{n}", 0))
- name = _val(f"omny_samples_sample_names_sample{n}", EMPTY_NAME) or EMPTY_NAME
- owner = _val(f"omny_samples_sample_owners_sample{n}", "") or ""
- status = _status_str(_val(f"omny_samples_sample_status_sample{n}", 0))
- state[("O", n)] = (used, name, owner, status)
-
+ state[("O", n)] = _record(f"P{n}")
for letter in OMNY_SHUTTLE_LETTERS:
for n in OMNY_SHUTTLE_SLOTS:
- used = bool(_val(f"omny_samples_sample_shuttle_{letter}_placed_sample{n}", 0))
- name = _val(f"omny_samples_sample_shuttle_{letter}_names_sample{n}", EMPTY_NAME) or EMPTY_NAME
- owner = _val(f"omny_samples_sample_shuttle_{letter}_owners_sample{n}", "") or ""
- status = _status_str(_val(f"omny_samples_sample_shuttle_{letter}_status_sample{n}", 0))
- state[("shuttle", letter, n)] = (used, name, owner, status)
-
+ state[("shuttle", letter, n)] = _record(f"{letter}{n}")
return state
def _parse_parking_assignment_omny(self, data: dict) -> dict[int, str]:
@@ -769,39 +733,35 @@ class OMNY_SampleStorage(BECWidget, QWidget):
(ACCOUNT/DATE/SAMPLENAME) by assumption, not yet bench-tested
against a real printer the way FlOMNI's template 1 is.
- OMNY has no FlOMNI-style measured-sample log to pop a record from
- -- status and account already live directly on the slot itself
- (see ``OMNYSampleStorage``'s status+account VAL encoding), so this
- reads them fresh from the device right before the clear, instead.
- There's also no stored measurement date (OMNY doesn't track one),
- so the label's DATE field is simply today's date, at print time.
+ Status, owner and measurement date live on the slot record itself
+ (OMNY-SAMPLE::..., as FlOMNI), read fresh from the device right
+ before the clear; the owner is printed as account, as on FlOMNI.
"""
assert key[0] == "shuttle"
_, letter, n = key
try:
- was_used = self._samples.is_sample_slot_used(letter, n)
- prev_name = self._samples.get_sample_name(letter, n)
- status = self._samples.get_sample_status(letter, n)
- account = self._samples.get_sample_account(letter, n)
+ record = self._samples.get_sample_record(letter, n)
except Exception as exc:
logger.warning(
f"OMNY_SampleStorage: could not read previous state of slot {key} for the"
f" measured-label check: {exc}"
)
return
- if not was_used or status == "none":
+ if not record["used"] or record["status"] not in ("started", "completed"):
return
+ prev_name = record["name"]
reply = QMessageBox.question(
self,
"Print label?",
- f"Sample '{prev_name}' was measured ({status}), account {account}. Print a label?",
+ f"Sample '{prev_name}' was measured ({record['status']}) on"
+ f" {record['meas_date']}. Print a label?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
fields = {
- PTOUCH_FIELD_ACCOUNT: str(account),
- PTOUCH_FIELD_DATE: datetime.date.today().strftime("%Y-%m-%d"),
+ PTOUCH_FIELD_ACCOUNT: record["owner"],
+ PTOUCH_FIELD_DATE: record["meas_date"],
PTOUCH_FIELD_SAMPLENAME: prev_name,
}
# Retry loop, same reasoning as _offer_print_label_for_previous_occupant():
@@ -852,14 +812,11 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def _write_slot_omny(self, key: tuple, used: int, name: str, owner: str = "") -> bool:
"""Write (used, name, owner) to an OMNY sample-kind slot -- stage,
- gripper, a fixed O position, or a shuttle slot -- mirroring the
- direct-signal style of ``_write_slot()``. Unlike FlOMNI, OMNY has a
- dedicated owner signal (added alongside placed/name -- see
- ``OMNYSampleStorage``), so name and owner are written separately,
- no packing needed. Measured status is reset to "none" on every
- write here, mirroring ``OMNYSampleStorage.set_sample_slot()``'s/
- ``unset_sample_slot()``'s own behavior -- a fresh name (even a
- rename) or a cleared slot isn't mid-measurement.
+ gripper, a fixed O position, or a shuttle slot -- through the device
+ methods, like ``_write_slot()``. Measured status is reset to "none"
+ on every write here (``OMNYSampleStorage.set_sample_slot()``/
+ ``unset_sample_slot()`` write the whole record) -- a fresh name
+ (even a rename) or a cleared slot isn't mid-measurement.
Offers a P-touch label print for the previous occupant only when
*clearing* a *shuttle* slot (see
@@ -873,31 +830,20 @@ class OMNY_SampleStorage(BECWidget, QWidget):
if used == 0 and kind == "shuttle":
self._offer_print_label_for_previous_occupant_omny(key)
try:
- status_none = OMNY_STATUS_NONE
if kind == "stage":
- self._samples.sample_in_samplestage.set(used)
- self._samples.sample_in_samplestage_name.set(name)
- self._samples.sample_in_samplestage_owner.set(owner)
- self._samples.sample_in_samplestage_status.set(status_none)
+ container, n = "STAGE", 0
elif kind == "gripper":
- self._samples.sample_in_gripper.set(used)
- self._samples.sample_in_gripper_name.set(name)
- self._samples.sample_in_gripper_owner.set(owner)
- self._samples.sample_in_gripper_status.set(status_none)
+ container, n = "GRIPPER", 0
elif kind == "O":
- _, n = key
- getattr(self._samples.sample_placed, f"sample{n}").set(used)
- getattr(self._samples.sample_names, f"sample{n}").set(name)
- getattr(self._samples.sample_owners, f"sample{n}").set(owner)
- getattr(self._samples.sample_status, f"sample{n}").set(status_none)
+ container, n = "O", key[1]
elif kind == "shuttle":
- _, letter, n = key
- getattr(getattr(self._samples, f"sample_shuttle_{letter}_placed"), f"sample{n}").set(used)
- getattr(getattr(self._samples, f"sample_shuttle_{letter}_names"), f"sample{n}").set(name)
- getattr(getattr(self._samples, f"sample_shuttle_{letter}_owners"), f"sample{n}").set(owner)
- getattr(getattr(self._samples, f"sample_shuttle_{letter}_status"), f"sample{n}").set(status_none)
+ container, n = key[1], key[2]
else:
raise ValueError(f"unknown OMNY slot key {key!r}")
+ if used:
+ self._samples.set_sample_slot(container, n, name, owner)
+ else:
+ self._samples.unset_sample_slot(container, n)
self._last_state.pop(key, None)
return True
except Exception as exc:
@@ -921,10 +867,10 @@ class OMNY_SampleStorage(BECWidget, QWidget):
in-progress transfer.
"""
try:
- used = 1 if letter else 0
- value = letter if letter else PARKING_EMPTY_SENTINEL
- getattr(self._samples.parking_placed, f"parking{slot_nr}").set(used)
- getattr(self._samples.parking_names, f"parking{slot_nr}").set(value)
+ if letter:
+ self._samples.set_shuttle_slot(letter, slot_nr)
+ else:
+ self._samples.unset_shuttle_slot(slot_nr)
return True
except Exception as exc:
logger.warning(f"OMNY_SampleStorage: write of parking slot {slot_nr} failed: {exc}")
@@ -1146,9 +1092,10 @@ class OMNY_SampleStorage(BECWidget, QWidget):
"""Return the slot key that already holds `name`, if any OTHER
occupied slot of the active setup currently has this exact name, or
None. Used by action_change_sample()/action_new_sample() to enforce
- that sample names are unique -- otherwise the measured-sample log
- (FlOMNI, keyed by name+owner) and operators reading the storage
- overview could no longer tell two same-named samples apart.
+ that sample names are unique -- otherwise operators reading the
+ storage overview (and the measurement records, scilog entries and
+ labels keyed by sample name) could no longer tell two same-named
+ samples apart.
Reads from ``self._last_state``, the same poll-refreshed snapshot
the cells themselves are painted from, rather than issuing a fresh
@@ -1168,7 +1115,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def action_change_sample(self, slot, current_name: str, current_owner: str = "") -> None:
"""Replace an occupied slot's sample identity (name + owner), stays
occupied. Named "change sample" rather than "rename" because
- _write_sample() resets the slot's measured status/account back to
+ _write_sample() resets the slot's measured status/date back to
"none" -- appropriate since a new name+owner means a different
physical sample, not a correction to the existing one's record.
Shared by both setups (see ``_SlotCell``) -- both require a name and
@@ -1484,9 +1431,8 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def _draw_report_cell(self, pdf, x, y, w, h, slot, entry) -> None:
"""Draw one slot's box, mirroring what ``_SlotCell.set_state()``
shows on screen: slot caption, name (or "empty"), owner and measured
- marker. Shared by both setups' report builders -- OMNY entries
- always carry owner="" and measured_status=None, which render as
- blank without any OMNY-specific branch here.
+ marker. Shared by both setups' report builders (same entry tuple
+ for both, no setup-specific branch here).
"""
pdf.rect(x, y, w, h)
occupied, name, owner, measured_status = entry if entry else (False, EMPTY_NAME, "", None)
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 7bc355c2..05796e04 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -3050,7 +3050,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
# get_sample_name_in_samplestage device_rpc entries stuck PENDING
# after an abort left the queue paused, plus a GUI freeze while a
# scan was running -- the wrapper method itself is just
- # `return str(self.sample_in_samplestage_name.get())`
+ # the NAME of the STAGE record, OMNY-SAMPLE:STAGE:NAME
# (omny_sample_storage.py), so reading the signal directly is
# equivalent and free of both problems. cached=True goes one step
# further: without it, DeviceBase.get() still issues a live
@@ -3058,9 +3058,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
# cached=True reads BEC's already-locally-updated status cache
# instead, no round-trip at all -- standard advice for GUI/widget
# code (see e.g. sample_storage.py's oparkz.readback.get(cached=True)).
- "sample_name_getter": lambda w: w.dev.omny_samples.sample_in_samplestage_name.get(
- cached=True
- ),
+ "sample_name_getter": lambda w: w.dev.omny_samples.sample_names.STAGE.get(cached=True),
"cli_hint_name": "omny",
# OMNY has three "equally spaced sub-tomograms" flavors (2/4/8
# sub-tomograms), unlike flomni's/lamni's single always-8 flavor --
diff --git a/csaxs_bec/devices/omny/omny_sample_storage.py b/csaxs_bec/devices/omny/omny_sample_storage.py
index 362fc537..8cf31d0a 100644
--- a/csaxs_bec/devices/omny/omny_sample_storage.py
+++ b/csaxs_bec/devices/omny/omny_sample_storage.py
@@ -1,5 +1,33 @@
+"""OMNY sample storage on the OMNY soft IOC (``OMNYtools/softioc/ioc_omny.py`` in the OMNY repo).
+
+One record per slot, all fields autosaved by the IOC (same layout as flOMNI's
+``FLOMNI-SAMPLE:``, see ``flomni_sample_storage.py``)::
+
+ OMNY-SAMPLE::USED 0 = free, 1 = sample present
+ OMNY-SAMPLE::NAME sample name (long string, up to 256 chars)
+ OMNY-SAMPLE::OWNER e-account of the owner
+ OMNY-SAMPLE::STATUS none / started / completed (measurement status)
+ OMNY-SAMPLE::MEAS_DATE date of the measurement
+
+ (container, slot_nr of this device in brackets):
+ A1..A6, B1..B6, C1..C6 shuttles ("A".."C", 1-6)
+ P10..P14, P32..P34, P100, P101 fixed positions ("O", 10-14, 32-34, 100, 101)
+ STAGE sample stage (set/unset_sample_in_samplestage)
+ GRIPPER gripper (set/unset_sample_in_gripper)
+ PARK1..PARK6 parking slots: USED + NAME = shuttle letter (set_shuttle_slot)
+ FZP0, OSA0, OSA1 exist in the IOC, not used by the transfer yet
+
+Replaces (2026-10-18) the classic softIoc records ``XOMNY-SAMPLE_DB_:``
+(VAL = placed, DESC = name) and ``...::STATUS`` (VAL = status sign x account,
+DESC = owner). The status is now a plain enum plus a date, as on flOMNI, and travels
+with the sample: ``transfer_sample()`` moves all fields between slots, the gripper and
+the stage.
+
+An empty string cannot be written to the char-array NAME via Channel Access, so a
+free slot carries the name ``EMPTY_NAME`` ("-").
+"""
+
import time
-from typing import Optional
from ophyd import Component as Cpt
from ophyd import Device
@@ -7,11 +35,68 @@ from ophyd import DynamicDeviceComponent as Dcpt
from ophyd import EpicsSignal
from prettytable import FRAME, PrettyTable
+PV_PREFIX = "OMNY-SAMPLE:"
+SHUTTLES = ("A", "B", "C")
+SHUTTLE_SLOTS = range(1, 7)
+FIXED_SLOTS = (10, 11, 12, 13, 14, 32, 33, 34, 100, 101)
+PARKING_SLOTS = range(1, 7)
+STAGE = "STAGE"
+GRIPPER = "GRIPPER"
+EMPTY_NAME = "-"
+NO_SHUTTLE = "none"
+STATUS_NONE = "none"
+STATUS_STARTED = "started"
+STATUS_COMPLETED = "completed"
+STATUS_VALUES = (STATUS_NONE, STATUS_STARTED, STATUS_COMPLETED)
+FIELDS = ("used", "name", "owner", "status", "meas_date")
+
+# every sample slot of the IOC that this device handles, by PV slot name
+SAMPLE_SLOTS = (
+ [f"{s}{i}" for s in SHUTTLES for i in SHUTTLE_SLOTS] + [f"P{n}" for n in FIXED_SLOTS] + [STAGE, GRIPPER]
+)
+
class OMNYSampleStorageError(Exception):
pass
+def slot_name(container: str, slot_nr: int = 0) -> str:
+ """("A", 3) -> "A3", ("O", 10) -> "P10"; "STAGE"/"GRIPPER" (and a slot name
+ such as "A3" or "P10") are passed through."""
+ container = str(container)
+ if container.upper() in (STAGE, GRIPPER):
+ return container.upper()
+ if container in SAMPLE_SLOTS:
+ return container
+ slot_nr = int(slot_nr)
+ if container in SHUTTLES and slot_nr in SHUTTLE_SLOTS:
+ return f"{container}{slot_nr}"
+ if container == "O" and slot_nr in FIXED_SLOTS:
+ return f"P{slot_nr}"
+ raise OMNYSampleStorageError(
+ f"Invalid sample slot ({container!r}, {slot_nr}): container A/B/C with slot 1-6, "
+ f"O with {FIXED_SLOTS}, or STAGE/GRIPPER."
+ )
+
+
+def _field_pv(slot: str, field: str) -> str:
+ return f"{PV_PREFIX}{slot}:{field.upper()}"
+
+
+def _sample_signals(field: str) -> dict:
+ kwargs = {"auto_monitor": True}
+ if field != "used":
+ kwargs["string"] = True
+ return {slot: (EpicsSignal, _field_pv(slot, field), dict(kwargs)) for slot in SAMPLE_SLOTS}
+
+
+def _parking_signals(field: str) -> dict:
+ kwargs = {"auto_monitor": True}
+ if field != "used":
+ kwargs["string"] = True
+ return {f"parking{i}": (EpicsSignal, _field_pv(f"PARK{i}", field), dict(kwargs)) for i in PARKING_SLOTS}
+
+
class OMNYSampleStorage(Device):
USER_ACCESS = [
"is_sample_slot_used",
@@ -26,18 +111,16 @@ class OMNYSampleStorage(Device):
"get_sample_name_in_gripper",
"get_sample_owner_in_samplestage",
"get_sample_owner_in_gripper",
- "set_sample_status_in_samplestage",
"get_sample_status_in_samplestage",
- "get_sample_account_in_samplestage",
- "set_sample_status_in_gripper",
"get_sample_status_in_gripper",
- "get_sample_account_in_gripper",
"get_sample_name",
"get_sample_owner",
"get_sample_name_and_owner",
- "set_sample_status",
"get_sample_status",
- "get_sample_account",
+ "get_sample_meas_date",
+ "get_sample_record",
+ "set_sample_measured",
+ "transfer_sample",
"is_sample_in_samplestage",
"set_shuttle_slot",
"unset_shuttle_slot",
@@ -50,482 +133,197 @@ class OMNYSampleStorage(Device):
SUB_VALUE = "value"
_default_sub = SUB_VALUE
- # Measured status *and* account, packed into one number -- an "ai"
- # record's VAL (see omny_samples_status.template in the OMNY/OMNY.git
- # IOC repo), not a proper EPICS enum: this IOC's sample DB uses plain
- # "ai" records uniformly even for boolean-ish fields (see the
- # placed-flag records), so the STATUS record follows the same
- # convention rather than introducing a new record type used nowhere
- # else here.
- #
- # Encoding: VAL == 0 means "not measured"; otherwise VAL's magnitude is
- # the 5-digit beamtime account and its sign is the status -- negative
- # while a measurement is in progress ("started"), positive once it's
- # done ("completed"). This packs status+account into the one VAL this
- # IOC's "ai"-record convention already gives us, with no new EPICS
- # record needed. An account that isn't exactly 5 digits (e.g. a
- # non-numeric test account) falls back to FALLBACK_ACCOUNT (11111) --
- # safe because real account numbers are already above 20000, so this
- # sentinel can never collide with a real one, and only ever shows up
- # for test accounts.
- STATUS_NONE = 0
- FALLBACK_ACCOUNT = 11111
+ # sample slots, attribute = PV slot name (A1 ... C6, P10 ... P101, STAGE, GRIPPER)
+ sample_used = Dcpt(_sample_signals("used"))
+ sample_names = Dcpt(_sample_signals("name"))
+ sample_owners = Dcpt(_sample_signals("owner"))
+ sample_status = Dcpt(_sample_signals("status"))
+ sample_meas_date = Dcpt(_sample_signals("meas_date"))
- sample_shuttle_A_placed = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}", {"auto_monitor": True}) for i in range(1, 7)
- }
- sample_shuttle_A_placed = Dcpt(sample_shuttle_A_placed)
-
- sample_shuttle_B_placed = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}", {"auto_monitor": True}) for i in range(1, 7)
- }
- sample_shuttle_B_placed = Dcpt(sample_shuttle_B_placed)
-
- sample_shuttle_C_placed = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}", {"auto_monitor": True}) for i in range(1, 7)
- }
- sample_shuttle_C_placed = Dcpt(sample_shuttle_C_placed)
-
- sample_shuttle_C_placed = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}", {"auto_monitor": True}) for i in range(1, 7)
- }
- sample_shuttle_C_placed = Dcpt(sample_shuttle_C_placed)
-
- parking_placed = {
- f"parking{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_parking:{i}", {"auto_monitor": True}) for i in range(1, 7)
- }
- parking_placed = Dcpt(parking_placed)
-
- sample_placed = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}", {"auto_monitor": True})
- for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
- }
- sample_placed = Dcpt(sample_placed)
-
- sample_shuttle_A_names = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}.DESC", {"string": True, "auto_monitor": True})
- for i in range(1, 7)
- }
- sample_shuttle_A_names = Dcpt(sample_shuttle_A_names)
-
- sample_shuttle_B_names = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}.DESC", {"string": True, "auto_monitor": True})
- for i in range(1, 7)
- }
- sample_shuttle_B_names = Dcpt(sample_shuttle_B_names)
-
- sample_shuttle_C_names = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}.DESC", {"string": True, "auto_monitor": True})
- for i in range(1, 7)
- }
- sample_shuttle_C_names = Dcpt(sample_shuttle_C_names)
-
- parking_names = {
- f"parking{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_parking:{i}.DESC", {"string": True, "auto_monitor": True})
- for i in range(1, 7)
- }
- parking_names = Dcpt(parking_names)
-
- sample_names = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}.DESC", {"string": True, "auto_monitor": True})
- for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
- }
- sample_names = Dcpt(sample_names)
-
- # Measured status+account (VAL) + owner (DESC) -- one new record per
- # sample position, added alongside the existing placed/name records
- # (see the status+account VAL encoding described above and
- # omny_samples_status.template in OMNY/OMNY.git). Parking slots have no
- # equivalent -- they hold a shuttle letter, not a sample.
- sample_shuttle_A_status = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}:STATUS", {"auto_monitor": True})
- for i in range(1, 7)
- }
- sample_shuttle_A_status = Dcpt(sample_shuttle_A_status)
-
- sample_shuttle_B_status = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}:STATUS", {"auto_monitor": True})
- for i in range(1, 7)
- }
- sample_shuttle_B_status = Dcpt(sample_shuttle_B_status)
-
- sample_shuttle_C_status = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}:STATUS", {"auto_monitor": True})
- for i in range(1, 7)
- }
- sample_shuttle_C_status = Dcpt(sample_shuttle_C_status)
-
- sample_status = {
- f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}:STATUS", {"auto_monitor": True})
- for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
- }
- sample_status = Dcpt(sample_status)
-
- sample_shuttle_A_owners = {
- f"sample{i}": (
- EpicsSignal,
- f"XOMNY-SAMPLE_DB_shuttle_A:{i}:STATUS.DESC",
- {"string": True, "auto_monitor": True},
- )
- for i in range(1, 7)
- }
- sample_shuttle_A_owners = Dcpt(sample_shuttle_A_owners)
-
- sample_shuttle_B_owners = {
- f"sample{i}": (
- EpicsSignal,
- f"XOMNY-SAMPLE_DB_shuttle_B:{i}:STATUS.DESC",
- {"string": True, "auto_monitor": True},
- )
- for i in range(1, 7)
- }
- sample_shuttle_B_owners = Dcpt(sample_shuttle_B_owners)
-
- sample_shuttle_C_owners = {
- f"sample{i}": (
- EpicsSignal,
- f"XOMNY-SAMPLE_DB_shuttle_C:{i}:STATUS.DESC",
- {"string": True, "auto_monitor": True},
- )
- for i in range(1, 7)
- }
- sample_shuttle_C_owners = Dcpt(sample_shuttle_C_owners)
-
- sample_owners = {
- f"sample{i}": (
- EpicsSignal,
- f"XOMNY-SAMPLE_DB_omny:{i}:STATUS.DESC",
- {"string": True, "auto_monitor": True},
- )
- for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
- }
- sample_owners = Dcpt(sample_owners)
-
- sample_in_gripper = Cpt(
- EpicsSignal, name="sample_in_gripper", read_pv="XOMNY-SAMPLE_DB_omny:110.VAL", auto_monitor=True
- )
- sample_in_gripper_name = Cpt(
- EpicsSignal,
- name="sample_in_gripper_name",
- read_pv="XOMNY-SAMPLE_DB_omny:110.DESC",
- string=True,
- auto_monitor=True
- )
-
- sample_in_samplestage = Cpt(
- EpicsSignal, name="sample_in_samplestage", read_pv="XOMNY-SAMPLE_DB_omny:0.VAL", auto_monitor=True
- )
- sample_in_samplestage_name = Cpt(
- EpicsSignal,
- name="sample_in_samplestage_name",
- read_pv="XOMNY-SAMPLE_DB_omny:0.DESC",
- string=True,
- auto_monitor=True
- )
-
- sample_in_gripper_status = Cpt(
- EpicsSignal, name="sample_in_gripper_status", read_pv="XOMNY-SAMPLE_DB_omny:110:STATUS", auto_monitor=True
- )
- sample_in_gripper_owner = Cpt(
- EpicsSignal,
- name="sample_in_gripper_owner",
- read_pv="XOMNY-SAMPLE_DB_omny:110:STATUS.DESC",
- string=True,
- auto_monitor=True,
- )
-
- sample_in_samplestage_status = Cpt(
- EpicsSignal, name="sample_in_samplestage_status", read_pv="XOMNY-SAMPLE_DB_omny:0:STATUS", auto_monitor=True
- )
- sample_in_samplestage_owner = Cpt(
- EpicsSignal,
- name="sample_in_samplestage_owner",
- read_pv="XOMNY-SAMPLE_DB_omny:0:STATUS.DESC",
- string=True,
- auto_monitor=True,
- )
+ # parking slots 1-6: which shuttle (letter in NAME) is docked
+ parking_placed = Dcpt(_parking_signals("used"))
+ parking_names = Dcpt(_parking_signals("name"))
def __init__(self, prefix="", *, name, **kwargs):
super().__init__(prefix, name=name, **kwargs)
- self.sample_shuttle_A_placed.sample1.subscribe(self._emit_value)
+ self.sample_used.A1.subscribe(self._emit_value)
def _emit_value(self, **kwargs):
timestamp = kwargs.pop("timestamp", time.time())
self.wait_for_connection()
self._run_subs(sub_type=self.SUB_VALUE, timestamp=timestamp, obj=self)
- @classmethod
- def _normalize_account(cls, account) -> int:
- """A positive 5-digit account number for the status+account VAL
- encoding (see the class docstring comment above), or
- FALLBACK_ACCOUNT if `account` doesn't parse as exactly 5 digits.
+ # ── slot addressing ──────────────────────────────────────────────────────
+ def _sig(self, field: str, slot: str) -> EpicsSignal:
+ group = {
+ "used": self.sample_used,
+ "name": self.sample_names,
+ "owner": self.sample_owners,
+ "status": self.sample_status,
+ "meas_date": self.sample_meas_date,
+ }[field]
+ return getattr(group, slot)
+
+ @staticmethod
+ def _check_parking(slot_nr: int) -> int:
+ slot_nr = int(slot_nr)
+ if slot_nr not in PARKING_SLOTS:
+ raise OMNYSampleStorageError(f"Invalid parking slot {slot_nr} (1-6).")
+ return slot_nr
+
+ @staticmethod
+ def _str(val) -> str:
+ return "" if val is None else str(val)
+
+ # ── write ────────────────────────────────────────────────────────────────
+ def _write(self, slot: str, used: int, name: str, owner: str, status: str, meas_date: str):
+ if status not in STATUS_VALUES:
+ raise OMNYSampleStorageError(f"Invalid status {status!r}, expected one of {STATUS_VALUES}.")
+ name = name if name else EMPTY_NAME
+ # name/owner/status/date first, USED last: a client watching USED sees a complete record
+ self._sig("name", slot).set(name).wait(timeout=5)
+ self._sig("owner", slot).set(owner or "").wait(timeout=5)
+ self._sig("status", slot).set(status).wait(timeout=5)
+ self._sig("meas_date", slot).set(meas_date or "").wait(timeout=5)
+ self._sig("used", slot).set(int(used)).wait(timeout=5)
+
+ def set_sample_slot(
+ self,
+ container: str,
+ slot_nr: int,
+ name: str,
+ owner: str = "",
+ status: str = STATUS_NONE,
+ meas_date: str = "",
+ ):
+ """Place a sample record in (container, slot_nr): A/B/C 1-6, O 10-14/32-34/100/101."""
+ self._write(slot_name(container, slot_nr), 1, name, owner, status, meas_date)
+
+ def unset_sample_slot(self, container: str, slot_nr: int):
+ """Clear (container, slot_nr)."""
+ self._write(slot_name(container, slot_nr), 0, EMPTY_NAME, "", STATUS_NONE, "")
+
+ def set_sample_in_gripper(self, name: str, owner: str = "", status: str = STATUS_NONE, meas_date: str = ""):
+ self.set_sample_slot(GRIPPER, 0, name, owner, status, meas_date)
+
+ def unset_sample_in_gripper(self):
+ self.unset_sample_slot(GRIPPER, 0)
+
+ def set_sample_in_samplestage(
+ self, name: str, owner: str = "", status: str = STATUS_NONE, meas_date: str = ""
+ ):
+ self.set_sample_slot(STAGE, 0, name, owner, status, meas_date)
+
+ def unset_sample_in_samplestage(self):
+ self.unset_sample_slot(STAGE, 0)
+
+ def set_sample_measured(self, container: str, slot_nr: int, status: str, meas_date: str, owner_if_empty: str = ""):
+ """Record the measurement status/date of the sample in (container, slot_nr)
+ (container "STAGE" for the sample stage).
+
+ If the sample has no owner yet, `owner_if_empty` (e.g. the active
+ e-account) is stored as owner -- the label printed when a measured
+ sample is cleared uses the owner as account.
"""
- digits = "".join(ch for ch in str(account) if ch.isdigit()) if account is not None else ""
- if len(digits) == 5:
- val = int(digits)
- if 10000 <= val <= 99999:
- return val
- return cls.FALLBACK_ACCOUNT
+ if status not in STATUS_VALUES:
+ raise OMNYSampleStorageError(f"Invalid status {status!r}, expected one of {STATUS_VALUES}.")
+ slot = slot_name(container, slot_nr)
+ if owner_if_empty and not self._str(self._sig("owner", slot).get()):
+ self._sig("owner", slot).set(owner_if_empty).wait(timeout=5)
+ self._sig("status", slot).set(status).wait(timeout=5)
+ self._sig("meas_date", slot).set(meas_date or "").wait(timeout=5)
- @classmethod
- def _status_to_val(cls, status: str, account=None) -> int:
- status = (status or "none").strip().lower()
- if status == "none":
- return 0
- if status not in ("started", "completed"):
- raise OMNYSampleStorageError(
- f"Invalid status {status!r}; expected one of ['none', 'started', 'completed']."
- )
- acc = cls._normalize_account(account)
- return -acc if status == "started" else acc
+ def transfer_sample(self, from_slot, to_slot):
+ """Move the complete record (name, owner, status, date) from one slot to
+ another and clear the source. Slots as (container, slot_nr) tuples or slot
+ names: e.g. transfer_sample(("A", 3), "GRIPPER"), transfer_sample("GRIPPER", "STAGE")."""
+ src = slot_name(*from_slot) if isinstance(from_slot, tuple) else slot_name(from_slot)
+ dst = slot_name(*to_slot) if isinstance(to_slot, tuple) else slot_name(to_slot)
+ rec = self.get_sample_record(src)
+ self._write(dst, 1, rec["name"], rec["owner"], rec["status"], rec["meas_date"])
+ self._write(src, 0, EMPTY_NAME, "", STATUS_NONE, "")
- @classmethod
- def _val_to_status(cls, val) -> str:
- try:
- v = int(round(float(val)))
- except (TypeError, ValueError):
- return "none"
- if v == 0:
- return "none"
- return "started" if v < 0 else "completed"
+ def set_shuttle_slot(self, container: str, slot_nr: int):
+ """Shuttle `container` (A/B/C) is docked at parking slot `slot_nr` (1-6)."""
+ slot_nr = self._check_parking(slot_nr)
+ getattr(self.parking_names, f"parking{slot_nr}").set(container).wait(timeout=5)
+ getattr(self.parking_placed, f"parking{slot_nr}").set(1).wait(timeout=5)
- @classmethod
- def _val_to_account(cls, val) -> Optional[int]:
- try:
- v = int(round(float(val)))
- except (TypeError, ValueError):
- return None
- return abs(v) if v != 0 else None
+ def unset_shuttle_slot(self, slot_nr: int):
+ slot_nr = self._check_parking(slot_nr)
+ getattr(self.parking_names, f"parking{slot_nr}").set(NO_SHUTTLE).wait(timeout=5)
+ getattr(self.parking_placed, f"parking{slot_nr}").set(0).wait(timeout=5)
- def set_sample_slot(self, container: str, slot_nr: int, name: str, owner: str = "") -> bool:
- if slot_nr > 20:
- raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
-
- status = self._status_to_val("none")
- if container == "A":
- getattr(self.sample_shuttle_A_placed, f"sample{slot_nr}").set(1)
- getattr(self.sample_shuttle_A_names, f"sample{slot_nr}").set(name)
- getattr(self.sample_shuttle_A_owners, f"sample{slot_nr}").set(owner)
- getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").set(status)
- elif container == "B":
- getattr(self.sample_shuttle_B_placed, f"sample{slot_nr}").set(1)
- getattr(self.sample_shuttle_B_names, f"sample{slot_nr}").set(name)
- getattr(self.sample_shuttle_B_owners, f"sample{slot_nr}").set(owner)
- getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").set(status)
- elif container == "C":
- getattr(self.sample_shuttle_C_placed, f"sample{slot_nr}").set(1)
- getattr(self.sample_shuttle_C_names, f"sample{slot_nr}").set(name)
- getattr(self.sample_shuttle_C_owners, f"sample{slot_nr}").set(owner)
- getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").set(status)
- elif container == "O":
- getattr(self.sample_placed, f"sample{slot_nr}").set(1)
- getattr(self.sample_names, f"sample{slot_nr}").set(name)
- getattr(self.sample_owners, f"sample{slot_nr}").set(owner)
- getattr(self.sample_status, f"sample{slot_nr}").set(status)
-
- def unset_sample_slot(self, container: str, slot_nr: int) -> bool:
- if slot_nr > 20:
- raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
-
- status = self._status_to_val("none")
- if container == "A":
- getattr(self.sample_shuttle_A_placed, f"sample{slot_nr}").set(0)
- getattr(self.sample_shuttle_A_names, f"sample{slot_nr}").set("-")
- getattr(self.sample_shuttle_A_owners, f"sample{slot_nr}").set("")
- getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").set(status)
- elif container == "B":
- getattr(self.sample_shuttle_B_placed, f"sample{slot_nr}").set(0)
- getattr(self.sample_shuttle_B_names, f"sample{slot_nr}").set("-")
- getattr(self.sample_shuttle_B_owners, f"sample{slot_nr}").set("")
- getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").set(status)
- elif container == "C":
- getattr(self.sample_shuttle_C_placed, f"sample{slot_nr}").set(0)
- getattr(self.sample_shuttle_C_names, f"sample{slot_nr}").set("-")
- getattr(self.sample_shuttle_C_owners, f"sample{slot_nr}").set("")
- getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").set(status)
- elif container == "O":
- getattr(self.sample_placed, f"sample{slot_nr}").set(0)
- getattr(self.sample_names, f"sample{slot_nr}").set("-")
- getattr(self.sample_owners, f"sample{slot_nr}").set("")
- getattr(self.sample_status, f"sample{slot_nr}").set(status)
-
- def set_sample_status(self, container: str, slot_nr: int, status: str, account=None) -> bool:
- val = self._status_to_val(status, account)
- if container == "A":
- getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").set(val)
- elif container == "B":
- getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").set(val)
- elif container == "C":
- getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").set(val)
- elif container == "O":
- getattr(self.sample_status, f"sample{slot_nr}").set(val)
-
- def get_sample_status(self, container: str, slot_nr: int) -> str:
- if container == "A":
- val = getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").get()
- elif container == "B":
- val = getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").get()
- elif container == "C":
- val = getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").get()
- elif container == "O":
- val = getattr(self.sample_status, f"sample{slot_nr}").get()
- else:
- return "none"
- return self._val_to_status(val)
-
- def get_sample_account(self, container: str, slot_nr: int) -> Optional[int]:
- """The account packed into this slot's status VAL (see the
- status+account encoding described above ``STATUS_NONE``), or None
- if the slot isn't measured ("none" status).
- """
- if container == "A":
- val = getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").get()
- elif container == "B":
- val = getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").get()
- elif container == "C":
- val = getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").get()
- elif container == "O":
- val = getattr(self.sample_status, f"sample{slot_nr}").get()
- else:
- return None
- return self._val_to_account(val)
-
- def get_sample_owner(self, container: str, slot_nr: int) -> str:
- if container == "A":
- val = getattr(self.sample_shuttle_A_owners, f"sample{slot_nr}").get()
- elif container == "B":
- val = getattr(self.sample_shuttle_B_owners, f"sample{slot_nr}").get()
- elif container == "C":
- val = getattr(self.sample_shuttle_C_owners, f"sample{slot_nr}").get()
- elif container == "O":
- val = getattr(self.sample_owners, f"sample{slot_nr}").get()
- else:
- return ""
- return str(val) if val else ""
-
- def get_sample_name_and_owner(self, container: str, slot_nr: int) -> tuple:
- return self.get_sample_name(container, slot_nr), self.get_sample_owner(container, slot_nr)
-
- def set_shuttle_slot(self, container: str, slot_nr: int) -> bool:
- if slot_nr > 6:
- raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
- getattr(self.parking_placed, f"parking{slot_nr}").set(1)
- getattr(self.parking_names, f"parking{slot_nr}").set(container)
-
- def unset_shuttle_slot(self, slot_nr: int) -> bool:
- if slot_nr > 6:
- raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
- getattr(self.parking_placed, f"parking{slot_nr}").set(0)
- getattr(self.parking_names, f"parking{slot_nr}").set("none")
-
- def set_sample_in_gripper(self, name: str, owner: str = "") -> bool:
- self.sample_in_gripper.set(1)
- self.sample_in_gripper_name.set(name)
- self.sample_in_gripper_owner.set(owner)
- self.sample_in_gripper_status.set(self._status_to_val("none"))
-
- def unset_sample_in_gripper(self) -> bool:
- self.sample_in_gripper.set(0)
- self.sample_in_gripper_name.set("-")
- self.sample_in_gripper_owner.set("")
- self.sample_in_gripper_status.set(self._status_to_val("none"))
-
- def set_sample_in_samplestage(self, name: str, owner: str = "") -> bool:
- self.sample_in_samplestage.set(1)
- self.sample_in_samplestage_name.set(name)
- self.sample_in_samplestage_owner.set(owner)
- self.sample_in_samplestage_status.set(self._status_to_val("none"))
-
- def unset_sample_in_samplestage(self) -> bool:
- self.sample_in_samplestage.set(0)
- self.sample_in_samplestage_name.set("-")
- self.sample_in_samplestage_owner.set("")
- self.sample_in_samplestage_status.set(self._status_to_val("none"))
-
- def get_sample_owner_in_gripper(self) -> str:
- val = self.sample_in_gripper_owner.get()
- return str(val) if val else ""
-
- def get_sample_owner_in_samplestage(self) -> str:
- val = self.sample_in_samplestage_owner.get()
- return str(val) if val else ""
-
- def set_sample_status_in_gripper(self, status: str, account=None) -> bool:
- self.sample_in_gripper_status.set(self._status_to_val(status, account))
-
- def get_sample_status_in_gripper(self) -> str:
- return self._val_to_status(self.sample_in_gripper_status.get())
-
- def get_sample_account_in_gripper(self) -> Optional[int]:
- return self._val_to_account(self.sample_in_gripper_status.get())
-
- def set_sample_status_in_samplestage(self, status: str, account=None) -> bool:
- self.sample_in_samplestage_status.set(self._status_to_val(status, account))
-
- def get_sample_status_in_samplestage(self) -> str:
- return self._val_to_status(self.sample_in_samplestage_status.get())
-
- def get_sample_account_in_samplestage(self) -> Optional[int]:
- return self._val_to_account(self.sample_in_samplestage_status.get())
-
- def is_sample_slot_used(self, container, slot_nr: int) -> bool:
- if container == "A":
- val = getattr(self.sample_shuttle_A_placed, f"sample{slot_nr}").get()
- if container == "B":
- val = getattr(self.sample_shuttle_B_placed, f"sample{slot_nr}").get()
- if container == "C":
- val = getattr(self.sample_shuttle_C_placed, f"sample{slot_nr}").get()
- elif container == "O":
- val = getattr(self.sample_placed, f"sample{slot_nr}").get()
- return bool(val)
-
- def is_shuttle_slot_used(self, slot_nr: int) -> bool:
- val = getattr(self.parking_placed, f"parking{slot_nr}").get()
- return bool(val)
+ # ── read ─────────────────────────────────────────────────────────────────
+ def is_sample_slot_used(self, container: str, slot_nr: int = 0) -> bool:
+ return bool(self._sig("used", slot_name(container, slot_nr)).get())
def is_sample_in_gripper(self) -> bool:
- val = self.sample_in_gripper.get()
- return bool(val)
+ return self.is_sample_slot_used(GRIPPER)
def is_sample_in_samplestage(self) -> bool:
- val = self.sample_in_samplestage.get()
- return bool(val)
+ return self.is_sample_slot_used(STAGE)
- def get_sample_name(self, container, slot_nr) -> str:
- if container == "A":
- val = getattr(self.sample_shuttle_A_names, f"sample{slot_nr}").get()
- elif container == "B":
- val = getattr(self.sample_shuttle_B_names, f"sample{slot_nr}").get()
- elif container == "C":
- val = getattr(self.sample_shuttle_C_names, f"sample{slot_nr}").get()
- elif container == "O":
- val = getattr(self.sample_names, f"sample{slot_nr}").get()
- else:
- val = "unknown container"
- return str(val)
+ def get_sample_name(self, container: str, slot_nr: int = 0) -> str:
+ name = self._str(self._sig("name", slot_name(container, slot_nr)).get())
+ return name if name else EMPTY_NAME
- def get_shuttle_name_slot(self, slot_nr: int) -> str:
- val = getattr(self.parking_names, f"parking{slot_nr}").get()
- return str(val)
+ def get_sample_owner(self, container: str, slot_nr: int = 0) -> str:
+ return self._str(self._sig("owner", slot_name(container, slot_nr)).get())
+
+ def get_sample_name_and_owner(self, container: str, slot_nr: int = 0) -> tuple:
+ return self.get_sample_name(container, slot_nr), self.get_sample_owner(container, slot_nr)
+
+ def get_sample_status(self, container: str, slot_nr: int = 0) -> str:
+ status = self._str(self._sig("status", slot_name(container, slot_nr)).get())
+ return status if status in STATUS_VALUES else STATUS_NONE
+
+ def get_sample_meas_date(self, container: str, slot_nr: int = 0) -> str:
+ return self._str(self._sig("meas_date", slot_name(container, slot_nr)).get())
+
+ def get_sample_record(self, container: str, slot_nr: int = 0) -> dict:
+ return {
+ "used": self.is_sample_slot_used(container, slot_nr),
+ "name": self.get_sample_name(container, slot_nr),
+ "owner": self.get_sample_owner(container, slot_nr),
+ "status": self.get_sample_status(container, slot_nr),
+ "meas_date": self.get_sample_meas_date(container, slot_nr),
+ }
def get_sample_name_in_gripper(self) -> str:
- val = self.sample_in_gripper_name.get()
- return str(val)
+ return self.get_sample_name(GRIPPER)
def get_sample_name_in_samplestage(self) -> str:
- val = self.sample_in_samplestage_name.get()
- return str(val)
+ return self.get_sample_name(STAGE)
+
+ def get_sample_owner_in_gripper(self) -> str:
+ return self.get_sample_owner(GRIPPER)
+
+ def get_sample_owner_in_samplestage(self) -> str:
+ return self.get_sample_owner(STAGE)
+
+ def get_sample_status_in_gripper(self) -> str:
+ return self.get_sample_status(GRIPPER)
+
+ def get_sample_status_in_samplestage(self) -> str:
+ return self.get_sample_status(STAGE)
+
+ def is_shuttle_slot_used(self, slot_nr: int) -> bool:
+ return bool(getattr(self.parking_placed, f"parking{self._check_parking(slot_nr)}").get())
+
+ def get_shuttle_name_slot(self, slot_nr: int) -> str:
+ return self._str(getattr(self.parking_names, f"parking{self._check_parking(slot_nr)}").get())
def search_shuttle_in_slot(self, shuttle: str) -> int:
returnvalue = 0
- for i in range(1, 7):
+ for i in PARKING_SLOTS:
if self.get_shuttle_name_slot(i) == shuttle:
returnvalue = i
return returnvalue
- def _describe_slot(self, container: str, slot_nr: int) -> str:
+ def _describe_slot(self, container: str, slot_nr: int = 0) -> str:
"""Sample name, plus owner/measured-status suffix when occupied --
- e.g. "sample1 (bob) ◐ started". Used by show_all() so the
+ e.g. "sample1 (e12345) ◐ started 2026-10". Used by show_all() so the
overview surfaces owner+status without changing get_sample_name()'s
own plain-name contract (still used elsewhere, e.g. transfers)."""
name = self.get_sample_name(container, slot_nr)
@@ -533,13 +331,12 @@ class OMNYSampleStorage(Device):
return name
owner = self.get_sample_owner(container, slot_nr)
status = self.get_sample_status(container, slot_nr)
+ date = self.get_sample_meas_date(container, slot_nr)
suffix = f" ({owner})" if owner else ""
- if status == "started":
- account = self.get_sample_account(container, slot_nr)
- suffix += f" ◐ started (acc {account})"
- elif status == "completed":
- account = self.get_sample_account(container, slot_nr)
- suffix += f" ✓ completed (acc {account})"
+ if status == STATUS_STARTED:
+ suffix += " ◐ started" + (f" {date}" if date else "")
+ elif status == STATUS_COMPLETED:
+ suffix += " ✓ completed" + (f" {date}" if date else "")
return name + suffix
def show_all(self):
@@ -547,14 +344,13 @@ class OMNYSampleStorage(Device):
red = "\x1b[91m"
green = "\x1b[92m"
white = "\x1b[0m"
- for ch in ["A", "B", "C"]:
+ for ch in SHUTTLES:
t.clear()
shuttle_in_slot = self.search_shuttle_in_slot(ch)
if shuttle_in_slot > 0:
t.title = green + "Shuttle " + ch + " in OMNY slot " + str(shuttle_in_slot) + white
else:
t.title = red + "Shuttle " + ch + white
- field_names = [""]
for ax in [1, 3, 5]:
row = []
row.extend([self._describe_slot(ch, ax)])
@@ -567,28 +363,18 @@ class OMNYSampleStorage(Device):
print(t)
if self.is_sample_in_samplestage():
- name = self.get_sample_name_in_samplestage()
- owner = self.get_sample_owner_in_samplestage()
- status = self.get_sample_status_in_samplestage()
- suffix = f" ({owner})" if owner else ""
- if status == "started":
- suffix += f" ◐ started (acc {self.get_sample_account_in_samplestage()})"
- elif status == "completed":
- suffix += f" ✓ completed (acc {self.get_sample_account_in_samplestage()})"
- print(f"\n\n Sample stage: {name}{suffix}")
+ print(f"\n\n Sample stage: {self._describe_slot(STAGE)}")
else:
print(f"\n\n Sample stage: no sample")
if self.is_sample_in_gripper():
- name = self.get_sample_name_in_gripper()
- owner = self.get_sample_owner_in_gripper()
- print(f"\n Gripper: {name}" + (f" ({owner})" if owner else "") + "\n")
+ print(f"\n Gripper: {self._describe_slot(GRIPPER)}\n")
else:
print(f"\n Gripper: no sample\n")
t.clear()
t.title = "Fixed positions in OMNY"
- for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]:
+ for i in FIXED_SLOTS:
row = []
row.extend([f"Position {i:3d}"])
if self.is_sample_slot_used("O", i):
@@ -608,12 +394,10 @@ class OMNYSampleStorage(Device):
print(" dev.omny_samples.set_sample_slot('system',position,'name','owner')")
print(" system can be A, B, C, O")
print(" dev.omny_samples.get_sample_owner('system',position)")
- print(" dev.omny_samples.set_sample_status('system',position,'status',account)")
- print(" status is one of 'none', 'started', 'completed'")
- print(" account is required for 'started'/'completed' (a 5-digit account number,")
- print(" falls back to 11111 if not exactly 5 digits)")
- print(" dev.omny_samples.get_sample_status('system',position)")
- print(" dev.omny_samples.get_sample_account('system',position)")
+ print(" dev.omny_samples.get_sample_record('system',position)")
+ print(" dev.omny_samples.set_sample_measured('system',position,'status','YYYY-MM')")
+ print(" status is one of 'none', 'started', 'completed'; system 'STAGE' for the sample stage")
+ print(" dev.omny_samples.transfer_sample(('A',3), 'GRIPPER') (name, owner, status move along)")
print(" dev.omny_samples.set_sample_in_gripper('name','owner') / unset_sample_in_gripper()")
print(" dev.omny_samples.set_sample_in_samplestage('name','owner'), unset_sample_in_samplestage()")
print(" dev.omny_samples.set_shuttle_slot(container, slot_nr) / unset_shuttle_slot(slot_nr)")
diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py
index 4f91b56d..458d3f17 100644
--- a/csaxs_bec/devices/sim/sim_omny.py
+++ b/csaxs_bec/devices/sim/sim_omny.py
@@ -28,7 +28,11 @@ from ophyd_devices.tests.utils import patched_device
from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilMotor
from csaxs_bec.devices.omny.omny_dewar import OMNYDewar
-from csaxs_bec.devices.omny.omny_sample_storage import OMNYSampleStorage
+from csaxs_bec.devices.omny.omny_sample_storage import (
+ PARKING_SLOTS,
+ SAMPLE_SLOTS,
+ OMNYSampleStorage,
+)
from csaxs_bec.devices.omny.omny_temperatures import OMNYTemperatures
from csaxs_bec.devices.omny.rt.rt_omny_ophyd import RtOMNYFlyer, RtOMNYMotor
from csaxs_bec.devices.sim.sim_galil import SimGalilSocket, SimGalilState, _sim_state_kwargs
@@ -262,53 +266,11 @@ class SimOMNYSampleStorage(OMNYSampleStorage):
# real backend's own empty sentinels ("-" for sample slots, "none"
# for parking) -- explicitly unset every slot first, the same way
# SimFlomniSampleStorage does, so untouched slots read back as
- # genuinely empty instead of a stray "0" name/shuttle-letter.
- #
- # Writes go directly to the Dcpt signals rather than through
- # unset_sample_slot()/set_sample_slot() -- those reject any
- # slot_nr > 20 (a pre-existing bug: it's copied from Flomni's
- # 0-20-only range check, but OMNY's own "O" fixed positions
- # legitimately include 32-34/100/101), which would raise here
- # for exactly the positions this needs to clear.
- # Same clearing for the owner/status signals added alongside
- # placed/names -- untouched positions should read owner="" and
- # status="none", not the mock-PV "0" default (an invalid status
- # string, would fail _status_val_to_str's lookup silently back
- # to "none" anyway, but owner would show a stray "0").
- for shuttle_placed, shuttle_names, shuttle_owners, shuttle_status in (
- (
- storage.sample_shuttle_A_placed,
- storage.sample_shuttle_A_names,
- storage.sample_shuttle_A_owners,
- storage.sample_shuttle_A_status,
- ),
- (
- storage.sample_shuttle_B_placed,
- storage.sample_shuttle_B_names,
- storage.sample_shuttle_B_owners,
- storage.sample_shuttle_B_status,
- ),
- (
- storage.sample_shuttle_C_placed,
- storage.sample_shuttle_C_names,
- storage.sample_shuttle_C_owners,
- storage.sample_shuttle_C_status,
- ),
- ):
- for slot_nr in shuttle_placed.component_names:
- getattr(shuttle_placed, slot_nr).set(0)
- getattr(shuttle_names, slot_nr).set("-")
- getattr(shuttle_owners, slot_nr).set("")
- getattr(shuttle_status, slot_nr).set(OMNYSampleStorage.STATUS_NONE)
- for slot_nr in storage.sample_placed.component_names:
- getattr(storage.sample_placed, slot_nr).set(0)
- getattr(storage.sample_names, slot_nr).set("-")
- getattr(storage.sample_owners, slot_nr).set("")
- getattr(storage.sample_status, slot_nr).set(OMNYSampleStorage.STATUS_NONE)
- for slot_nr in storage.parking_placed.component_names:
- getattr(storage.parking_placed, slot_nr).set(0)
- getattr(storage.parking_names, slot_nr).set("none")
- storage.unset_sample_in_gripper()
+ # genuinely empty (owner "", status "none") instead of a stray "0".
+ for slot in SAMPLE_SLOTS:
+ storage.unset_sample_slot(slot, 0)
+ for slot_nr in PARKING_SLOTS:
+ storage.unset_shuttle_slot(slot_nr)
storage.set_sample_in_samplestage("sim_stage_sample")
for slot_nr, name in samples.items():
storage.set_sample_slot("A", int(slot_nr), str(name))
diff --git a/docs/user/ptychography/omny.md b/docs/user/ptychography/omny.md
index 95ac8743..dcb17b4a 100644
--- a/docs/user/ptychography/omny.md
+++ b/docs/user/ptychography/omny.md
@@ -89,7 +89,7 @@ Day to day, sample storage is viewed and adjusted through the __OMNY Sample Stor
The widget shows every position (shuttle pins A/B/C, fixed "O" positions, the gripper, and the sample stage) with its occupied state, sample name, owner, and measured status (unmeasured / ◐ started / ✓ completed). The parking-carousel view also shows which shuttle is in which parking slot and marks the currently __ACTIVE__ one (oparkz is physically sitting there, ready for transfer).
Right-click a slot for:
-- __Change sample…__ — set a new name and owner for an occupied slot (replaces the sample record, including resetting its measured status back to unmeasured — use this when a physically different sample is now there, not to correct a typo in the current one's account).
+- __Change sample…__ — set a new name and owner for an occupied slot (replaces the sample record, including resetting its measured status back to unmeasured — use this when a physically different sample is now there, not to correct a typo in the current one's record).
- __Clear (set empty)__ — mark a slot empty after physically removing the sample. Only offered for shuttle slots, since only samples in a shuttle can leave vacuum: this also offers to print a P-touch label for the sample that was just removed.
- __New sample…__ — occupy an empty slot with a name and owner.
- __Set parking slot…__ / __Clear parking assignment__ (shuttle labels) — record which parking slot a shuttle occupies; a slot already held by a different shuttle is rejected, clear it there first.
@@ -145,10 +145,11 @@ The shuttles are mounted in the parking station. Typically oparkz slot 3 is used
`dev.omny_samples.set_shuttle_slot(container, slot_nr)`
Here is an _example_: dev.omny_samples.set_shuttle_slot('A',2)
-Measured status/account (normally set automatically during a tomographic scan) can be read and, if really needed, corrected manually via the `omny.otransfer_*` wrappers, e.g.:
+Measured status and date (set automatically when a tomographic scan starts and finishes: "started"/"completed" with the month) travel with the sample on every transfer. They can be read and, if really needed, corrected manually:
+`dev.omny_samples.get_sample_record('system', position)` returns used/name/owner/status/meas_date (`'STAGE'`/`'GRIPPER'` as system for the sample stage and the gripper)
+`dev.omny_samples.set_sample_measured('system', position, status, 'YYYY-MM')` (`status` is one of `"none"`/`"started"`/`"completed"`; also as `omny.otransfer_set_sample_measured`)
`omny.otransfer_get_sample_status('system', position)` / `otransfer_get_sample_status_in_gripper()` / `otransfer_get_sample_status_in_samplestage()`
-`omny.otransfer_set_sample_status('system', position, status, account)` (`status` is one of `"none"`/`"started"`/`"completed"`) and the matching `_in_gripper`/`_in_samplestage` variants
-`omny.otransfer_get_sample_account(...)` and its `_in_gripper`/`_in_samplestage` variants read back the account tied to a "started"/"completed" status.
+Each position is one record on the OMNY soft IOC, `OMNY-SAMPLE::USED/NAME/OWNER/STATUS/MEAS_DATE` (slots `A1`…`C6`, `P10`…`P101`, `STAGE`, `GRIPPER`, `PARK1`…`PARK6`).
### Status of OMNY
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
index a3e1a411..d295853e 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py
@@ -301,7 +301,10 @@ def test_tomo_parameters_forces_type_1_for_non_superuser(monkeypatch, fake_bec):
omny.tomo_angle_stepsize = 10.0
omny.tomo_type = 1
fake_dev = types.SimpleNamespace(
- omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ omny_samples=types.SimpleNamespace(
+ get_sample_name_in_samplestage=lambda: "test_sample",
+ set_sample_measured=lambda *a, **k: None,
+ )
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
@@ -335,7 +338,10 @@ def test_tomo_parameters_allows_superuser_to_unlock_8_subtomo_mode(monkeypatch,
omny.tomo_angle_stepsize = 10.0
omny.tomo_type = 1
fake_dev = types.SimpleNamespace(
- omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ omny_samples=types.SimpleNamespace(
+ get_sample_name_in_samplestage=lambda: "test_sample",
+ set_sample_measured=lambda *a, **k: None,
+ )
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
@@ -371,7 +377,10 @@ def test_tomo_parameters_clamps_type_3_step_size_to_max_2_5_degrees(monkeypatch,
omny.tomo_angle_stepsize = 10.0
omny.tomo_type = 1
fake_dev = types.SimpleNamespace(
- omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ omny_samples=types.SimpleNamespace(
+ get_sample_name_in_samplestage=lambda: "test_sample",
+ set_sample_measured=lambda *a, **k: None,
+ )
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
@@ -413,7 +422,10 @@ def test_tomo_parameters_warns_when_fermat_positions_below_minimum(monkeypatch,
omny.tomo_angle_stepsize = 10.0
omny.tomo_type = 1
fake_dev = types.SimpleNamespace(
- omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ omny_samples=types.SimpleNamespace(
+ get_sample_name_in_samplestage=lambda: "test_sample",
+ set_sample_measured=lambda *a, **k: None,
+ )
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: True) # accept as-is, no edits
@@ -440,7 +452,10 @@ def test_tomo_parameters_no_warning_when_fermat_positions_sufficient(monkeypatch
omny.tomo_angle_stepsize = 10.0
omny.tomo_type = 1
fake_dev = types.SimpleNamespace(
- omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ omny_samples=types.SimpleNamespace(
+ get_sample_name_in_samplestage=lambda: "test_sample",
+ set_sample_measured=lambda *a, **k: None,
+ )
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: True)
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
index f5b2796e..e6cd8a25 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py
@@ -9,6 +9,8 @@ grid: OMNY has three "equally spaced sub-tomograms" flavors (tomo_type
N*n_subtomos total, where n_subtomos = {1: 2, 4: 4, 5: 8}[tomo_type].
"""
+import types
+
import pytest
from csaxs_bec.bec_ipython_client.plugins.omny.omny import OMNY
@@ -245,7 +247,7 @@ def test_omny_sample_name_getter_reads_signal_not_rpc_method():
class _FakeOmnySamples:
def __init__(self):
- self.sample_in_samplestage_name = _FakeSignal("my_sample")
+ self.sample_names = types.SimpleNamespace(STAGE=_FakeSignal("my_sample"))
def get_sample_name_in_samplestage(self):
raise AssertionError(
@@ -259,7 +261,7 @@ def test_omny_sample_name_getter_reads_signal_not_rpc_method():
class _FakeWidget:
dev = _FakeDev()
- signal = _FakeDev.omny_samples.sample_in_samplestage_name
+ signal = _FakeDev.omny_samples.sample_names.STAGE
result = SETUP_PROFILES["omny"]["sample_name_getter"](_FakeWidget())
assert result == "my_sample"
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
index 736a54b2..11284244 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py
@@ -45,7 +45,10 @@ def make_omny(fake_client=None):
# real BEC session only) -- stand in a fake so tomo_queue_execute()'s
# confirmation prompt (which reads self.sample_name) doesn't NameError.
omny_module.dev = types.SimpleNamespace(
- omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ omny_samples=types.SimpleNamespace(
+ get_sample_name_in_samplestage=lambda: "test_sample",
+ set_sample_measured=lambda *a, **k: None,
+ )
)
obj = object.__new__(OMNY)
diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
index 4be38cca..15213c57 100644
--- a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
+++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py
@@ -57,7 +57,10 @@ def make_omny_for_tomo_scan(monkeypatch, tomo_angle_stepsize: float, active_acco
# real BEC session only) -- stand in a fake so tomo_scan()'s sample-
# database registration (which reads self.sample_name) doesn't NameError.
omny_module.dev = types.SimpleNamespace(
- omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
+ omny_samples=types.SimpleNamespace(
+ get_sample_name_in_samplestage=lambda: "test_sample",
+ set_sample_measured=lambda *a, **k: None,
+ )
)
obj = object.__new__(OMNY)
@@ -525,7 +528,10 @@ def test_write_tomo_scan_number_creates_missing_directory(monkeypatch, tmp_path)
omny.tomo_id = 1
fake_dev = types.SimpleNamespace(
osamroy=types.SimpleNamespace(read=lambda: {"osamroy": {"value": 12.5}}),
- omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample"),
+ omny_samples=types.SimpleNamespace(
+ get_sample_name_in_samplestage=lambda: "test_sample",
+ set_sample_measured=lambda *a, **k: None,
+ ),
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
diff --git a/tests/tests_devices/test_omny_sample_storage.py b/tests/tests_devices/test_omny_sample_storage.py
index 71a730e2..1b3ec4c9 100644
--- a/tests/tests_devices/test_omny_sample_storage.py
+++ b/tests/tests_devices/test_omny_sample_storage.py
@@ -1,47 +1,90 @@
+"""OMNYSampleStorage on the OMNY soft IOC records (OMNY-SAMPLE::USED/NAME/OWNER/STATUS/MEAS_DATE)."""
+
import pytest
-from csaxs_bec.devices.omny.omny_sample_storage import OMNYSampleStorage, OMNYSampleStorageError
+from csaxs_bec.devices.omny.omny_sample_storage import (
+ SAMPLE_SLOTS,
+ OMNYSampleStorage,
+ OMNYSampleStorageError,
+ slot_name,
+)
+from csaxs_bec.devices.sim.sim_omny import SimOMNYSampleStorage
-def test_status_none_encodes_to_zero():
- assert OMNYSampleStorage._status_to_val("none") == 0
- assert OMNYSampleStorage._val_to_status(0) == "none"
- assert OMNYSampleStorage._val_to_account(0) is None
+@pytest.fixture
+def storage():
+ dev = SimOMNYSampleStorage(name="omny_samples", sim_samples={})
+ try:
+ yield dev
+ finally:
+ dev.destroy()
-@pytest.mark.parametrize("account", [20123, "20123", "e20123"])
-def test_started_encodes_negative_account(account):
- val = OMNYSampleStorage._status_to_val("started", account)
- assert val == -20123
- assert OMNYSampleStorage._val_to_status(val) == "started"
- assert OMNYSampleStorage._val_to_account(val) == 20123
+@pytest.mark.parametrize(
+ "container, slot_nr, expected",
+ [("A", 1, "A1"), ("C", 6, "C6"), ("O", 10, "P10"), ("O", 101, "P101"), ("STAGE", 0, "STAGE"),
+ ("gripper", 0, "GRIPPER"), ("B4", 0, "B4"), ("P32", 0, "P32")],
+)
+def test_slot_name(container, slot_nr, expected):
+ assert slot_name(container, slot_nr) == expected
-@pytest.mark.parametrize("account", [20123, "20123", "e20123"])
-def test_completed_encodes_positive_account(account):
- val = OMNYSampleStorage._status_to_val("completed", account)
- assert val == 20123
- assert OMNYSampleStorage._val_to_status(val) == "completed"
- assert OMNYSampleStorage._val_to_account(val) == 20123
-
-
-@pytest.mark.parametrize("bad_account", [None, "", "not-a-number", "123", "123456", "e-test"])
-def test_unparseable_account_falls_back_to_sentinel(bad_account):
- val = OMNYSampleStorage._status_to_val("started", bad_account)
- assert OMNYSampleStorage._val_to_account(val) == OMNYSampleStorage.FALLBACK_ACCOUNT
- assert OMNYSampleStorage.FALLBACK_ACCOUNT == 11111
-
-
-def test_invalid_status_raises():
+@pytest.mark.parametrize("container, slot_nr", [("A", 0), ("A", 7), ("O", 15), ("O", 1), ("D", 1)])
+def test_slot_name_rejects_unknown_slots(container, slot_nr):
with pytest.raises(OMNYSampleStorageError):
- OMNYSampleStorage._status_to_val("bogus")
+ slot_name(container, slot_nr)
-@pytest.mark.parametrize("raw", ["not-a-number", None, object()])
-def test_val_to_status_defaults_to_none_on_bad_input(raw):
- assert OMNYSampleStorage._val_to_status(raw) == "none"
+def test_pv_names_match_the_ioc():
+ assert OMNYSampleStorage.sample_used.defn["A1"][1] == "OMNY-SAMPLE:A1:USED"
+ assert OMNYSampleStorage.sample_names.defn["P100"][1] == "OMNY-SAMPLE:P100:NAME"
+ assert OMNYSampleStorage.sample_status.defn["STAGE"][1] == "OMNY-SAMPLE:STAGE:STATUS"
+ assert OMNYSampleStorage.sample_meas_date.defn["GRIPPER"][1] == "OMNY-SAMPLE:GRIPPER:MEAS_DATE"
+ assert OMNYSampleStorage.parking_names.defn["parking3"][1] == "OMNY-SAMPLE:PARK3:NAME"
+ assert len(SAMPLE_SLOTS) == 18 + 10 + 2
-@pytest.mark.parametrize("raw", ["not-a-number", None, object()])
-def test_val_to_account_defaults_to_none_on_bad_input(raw):
- assert OMNYSampleStorage._val_to_account(raw) is None
+def test_set_and_unset_slot(storage):
+ storage.set_sample_slot("O", 32, "pin1", "e12345")
+ assert storage.get_sample_record("O", 32) == {
+ "used": True, "name": "pin1", "owner": "e12345", "status": "none", "meas_date": ""}
+ storage.unset_sample_slot("O", 32)
+ rec = storage.get_sample_record("O", 32)
+ assert rec["used"] is False and rec["name"] == "-" and rec["owner"] == ""
+
+
+def test_status_travels_with_the_sample(storage):
+ storage.set_sample_slot("B", 2, "pin2", "e11111")
+ storage.set_sample_measured("B", 2, "completed", "2026-10")
+ storage.transfer_sample(("B", 2), "GRIPPER")
+ storage.transfer_sample("GRIPPER", "STAGE")
+ assert not storage.is_sample_slot_used("B", 2)
+ assert not storage.is_sample_in_gripper()
+ assert storage.get_sample_record("STAGE") == {
+ "used": True, "name": "pin2", "owner": "e11111", "status": "completed", "meas_date": "2026-10"}
+
+
+def test_set_sample_measured_owner_if_empty(storage):
+ storage.set_sample_in_samplestage("pin3")
+ storage.set_sample_measured("STAGE", 0, "started", "2026-10", owner_if_empty="e22222")
+ assert storage.get_sample_owner_in_samplestage() == "e22222"
+ assert storage.get_sample_status_in_samplestage() == "started"
+ # an existing owner is kept
+ storage.set_sample_measured("STAGE", 0, "completed", "2026-10", owner_if_empty="e33333")
+ assert storage.get_sample_owner_in_samplestage() == "e22222"
+
+
+def test_invalid_status_raises(storage):
+ storage.set_sample_slot("A", 1, "pin4")
+ with pytest.raises(OMNYSampleStorageError):
+ storage.set_sample_measured("A", 1, "bogus", "")
+
+
+def test_parking(storage):
+ storage.set_shuttle_slot("C", 2)
+ assert storage.is_shuttle_slot_used(2) and storage.get_shuttle_name_slot(2) == "C"
+ assert storage.search_shuttle_in_slot("C") == 2
+ storage.unset_shuttle_slot(2)
+ assert not storage.is_shuttle_slot_used(2) and storage.get_shuttle_name_slot(2) == "none"
+ with pytest.raises(OMNYSampleStorageError):
+ storage.set_shuttle_slot("A", 7)
diff --git a/tests/tests_devices/test_sim_omny_sample_storage_defaults.py b/tests/tests_devices/test_sim_omny_sample_storage_defaults.py
index 9aac632f..66c0f910 100644
--- a/tests/tests_devices/test_sim_omny_sample_storage_defaults.py
+++ b/tests/tests_devices/test_sim_omny_sample_storage_defaults.py
@@ -29,3 +29,10 @@ def test_default_samples_still_seeded_in_shuttle_a(storage):
assert storage.get_sample_name("A", 1) == "sim_sample_1"
assert storage.get_sample_name("A", 2) == "sim_sample_2"
assert storage.get_sample_name("A", 3) == "sim_sample_3"
+
+
+def test_untouched_slots_read_empty(storage):
+ rec = storage.get_sample_record("O", 100)
+ assert rec == {"used": False, "name": "-", "owner": "", "status": "none", "meas_date": ""}
+ assert storage.get_sample_name_in_samplestage() == "sim_stage_sample"
+ assert not storage.is_sample_in_gripper()
From 06c391571f29358a23b56dc58e3983fd84859a17 Mon Sep 17 00:00:00 2001
From: Mirko Holler
Date: Tue, 20 Oct 2026 08:32:47 +0200
Subject: [PATCH 067/102] feat(flomni): detector-trigger stability via the
ConstEmitter (emitter_get_ontarget, emitter_set_ontarget_tolerance /
_hold_ms) and emitter_help
Control loop (flOMNI system-identification branch): OnTargetTrigger triggers a step-scan point when the
relative position sample vs FZP is within ontarget_tolerance_um in x and y for ontarget_hold_ms; both are
ConstEmitter values, live over the CommunicationServer (eo, eh, eg). 'es' is unchanged (4 values).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01TNtKEk4jAyjebMDQFwRHJQ
---
csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py | 67 ++++++++++++++++++++
1 file changed, 67 insertions(+)
diff --git a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py
index 0cf66f39..7309dd5b 100644
--- a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py
+++ b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py
@@ -51,6 +51,10 @@ class RtFlomniController(Controller):
"emitter_set_tracking_target_z",
"emitter_set_intensity_threshold_laser",
"emitter_set_psd_intensity_threshold_tracking_low",
+ "emitter_get_ontarget",
+ "emitter_set_ontarget_tolerance",
+ "emitter_set_ontarget_hold_ms",
+ "emitter_help",
]
def __init__(
@@ -432,8 +436,71 @@ class RtFlomniController(Controller):
t.field_names = ["Name", "Value"]
for key, val in self.emitter_get().items():
t.add_row([key, val])
+ for key, val in self.emitter_get_ontarget().items():
+ t.add_row([key, val])
print(t)
+ def emitter_get_ontarget(self) -> dict:
+ """Read the detector-trigger stability criterion (OnTargetTrigger in the control loop).
+
+ A step-scan point is triggered when the relative position sample vs FZP is within
+ ``ontarget_tolerance_um`` in x and in y (each averaged over 0.5 ms) for ``ontarget_hold_ms``
+ without interruption.
+
+ Returns:
+ dict: ontarget_tolerance_um, ontarget_hold_ms.
+ """
+ ret = self.socket_put_and_receive("eg")
+ ret = ret.split("\n")[0]
+ values = [float(val) for val in ret.split(",")]
+ if len(values) != 2:
+ raise RtCommunicationError(
+ f"Expected to receive 2 on-target values. Instead received {values}"
+ )
+ return {"ontarget_tolerance_um": values[0], "ontarget_hold_ms": values[1]}
+
+ def emitter_set_ontarget_tolerance(self, val_um: float) -> None:
+ """Set the detector-trigger tolerance in um, per axis.
+
+ E.g. 0.030 for a low-resolution scan, 0.002 for the highest resolution. A value <= 0 selects
+ the former criterion (on-target flags of the FZP PID loops, set in their parameter files).
+ """
+ self.socket_put(f"eo{val_um:.6f}")
+
+ def emitter_set_ontarget_hold_ms(self, val_ms: float) -> None:
+ """Set the time in ms the position must stay within the tolerance before the trigger."""
+ self.socket_put(f"eh{val_ms:.3f}")
+
+ def emitter_help(self) -> None:
+ """Print the ConstEmitter commands (loop runtime constants) with a short explanation."""
+ t = PrettyTable()
+ t.title = "ConstEmitter commands (rt controller)"
+ t.field_names = ["Method", "Wire", "Explanation"]
+ t.align = "l"
+ rows = [
+ ("emitter_show_all()", "es, eg", "print all runtime constants"),
+ ("emitter_get()", "es", "dict: tracking targets y/z, laser and PSD thresholds"),
+ ("emitter_get_ontarget()", "eg", "dict: detector-trigger tolerance (um), hold (ms)"),
+ ("emitter_set_tracking_target_y(val)", "ey", "target of the tracking loop y (PSD)"),
+ ("emitter_set_tracking_target_z(val)", "ez", "target of the tracking loop z (PSD)"),
+ ("emitter_set_intensity_threshold_laser(val)", "et",
+ "interferometer signal below this: slew limiters and x/y/z, FZP PIDs stop"),
+ ("emitter_set_psd_intensity_threshold_tracking_low(val)", "el",
+ "PSD intensity below this: tracking PIDs y/z stop"),
+ ("emitter_set_ontarget_tolerance(val_um)", "eo",
+ "detector trigger: |relative error| x and y <= val um (e.g. 0.030 coarse, 0.002 fine);"
+ " <= 0: FZP PID flags"),
+ ("emitter_set_ontarget_hold_ms(val_ms)", "eh",
+ "detector trigger: ... held for val ms without interruption"),
+ ]
+ for row in rows:
+ t.add_row(row)
+ print(t)
+ print(
+ "Values are live in the control loop; the start values after a loop restart come from"
+ " ConstEmitter.xmp."
+ )
+
def emitter_set_tracking_target_y(self, val: float) -> None:
"""Set the y tracking target position (PID_tracking_y)."""
self.socket_put(f"ey{val:.4f}")
From d9b23b9dc28d3c8db1a1884aa344b004b1789303 Mon Sep 17 00:00:00 2001
From: Mirko Holler
Date: Tue, 20 Oct 2026 10:23:24 +0200
Subject: [PATCH 068/102] feat(flomni): user commands
interferometer_trigger_show / _set_tolerance_nm / _set_hold_ms / _help for
the detector-trigger stability; user docs
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01TNtKEk4jAyjebMDQFwRHJQ
---
.../plugins/flomni/flomni.py | 60 +++++++++++++++++++
docs/user/ptychography/flomni.md | 25 ++++++++
2 files changed, 85 insertions(+)
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index 329a2b41..5be8b558 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -710,6 +710,66 @@ class FlomniSampleTransferMixin:
else:
print("The rt feedback is \x1b[91mNOT\x1b[0m running.")
+ def interferometer_trigger_show(self):
+ """Show the detector-trigger stability criterion of the interferometer feedback.
+
+ A step-scan point is triggered when the relative position sample vs FZP (interferometer
+ minus FZP cap sensor) is within the tolerance in x and in y, each averaged over 0.5 ms,
+ for the hold time without interruption.
+ """
+ values = dev.rtx.controller.emitter_get_ontarget()
+ tol_nm = values["ontarget_tolerance_um"] * 1000
+ if tol_nm > 0:
+ print(f"Detector trigger: |position error| x and y <= {tol_nm:.1f} nm "
+ f"for {values['ontarget_hold_ms']:.1f} ms.")
+ else:
+ print("Detector trigger: former criterion (on-target flags of the FZP PID loops).")
+
+ def interferometer_trigger_set_tolerance_nm(self, tolerance_nm: float):
+ """Set the detector-trigger tolerance in nm (per axis, x and y).
+
+ Typical: 30 nm for a fast low-resolution scan, 10 nm standard, 2 nm for the highest
+ resolution (needs a few ms more per point). 0 selects the former criterion (on-target flags
+ of the FZP PID loops).
+
+ Args:
+ tolerance_nm (float): tolerance in nm, 0 or 1 ... 1000.
+ """
+ if tolerance_nm != 0 and not 1 <= tolerance_nm <= 1000:
+ raise FlomniError(
+ "The trigger tolerance must be 0 (former criterion) or 1 ... 1000 nm."
+ )
+ dev.rtx.controller.emitter_set_ontarget_tolerance(tolerance_nm / 1000)
+ self.interferometer_trigger_show()
+
+ def interferometer_trigger_set_hold_ms(self, hold_ms: float):
+ """Set how long (ms) the position must stay within the tolerance before the trigger.
+
+ Typical 3 ms. Longer = safer against a position that is only passing through the tolerance.
+
+ Args:
+ hold_ms (float): hold time in ms, 0 ... 100.
+ """
+ if not 0 <= hold_ms <= 100:
+ raise FlomniError("The trigger hold time must be 0 ... 100 ms.")
+ dev.rtx.controller.emitter_set_ontarget_hold_ms(hold_ms)
+ self.interferometer_trigger_show()
+
+ def interferometer_trigger_help(self):
+ """Print the detector-trigger commands with a short explanation."""
+ lines = [
+ "Detector trigger (step scans): a point is triggered when the relative position",
+ "sample vs FZP is within the tolerance in x and y (0.5 ms average) for the hold time.",
+ " flomni.interferometer_trigger_show() tolerance and hold time",
+ " flomni.interferometer_trigger_set_tolerance_nm(val) 30 coarse, 10 standard,",
+ " 2 finest; 0 = former",
+ " criterion (FZP PID flags)",
+ " flomni.interferometer_trigger_set_hold_ms(val) e.g. 3",
+ "The values are live; after a restart of the control loop the start values",
+ "(10 nm, 3 ms) from the loop parameter file apply again.",
+ ]
+ print("\n".join(lines))
+
def lights_off(self):
self.device_manager.devices.fsamx.controller.lights_off()
diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md
index b7fe0c6e..238ec211 100644
--- a/docs/user/ptychography/flomni.md
+++ b/docs/user/ptychography/flomni.md
@@ -218,6 +218,23 @@ _There is also an enable without reset, which is used during tomography scans, w
- `flomni.feedback_disable()`
- `flomni.feedback_status()`
+#### Detector trigger: position stability
+
+In step scans the detector is triggered when the sample is stable at the scan point: the relative position
+sample vs FZP (interferometer minus FZP cap sensor) must stay within a tolerance in x and in y (each averaged
+over 0.5 ms) for a hold time. Both values are live and can be changed between scans:
+
+- `flomni.interferometer_trigger_show()` — current tolerance and hold time
+- `flomni.interferometer_trigger_set_tolerance_nm(10)` — tolerance per axis in nm: about 30 for a fast
+ low-resolution scan, 10 standard, 2 for the highest resolution (a few ms more per point);
+ 0 selects the former criterion (on-target flags of the FZP loops)
+- `flomni.interferometer_trigger_set_hold_ms(3)` — hold time in ms
+- `flomni.interferometer_trigger_help()` — this list
+
+After a restart of the control loop the start values (10 nm, 3 ms) apply again. Measured with 3 µm steps
+(2026-10-20): the trigger comes about 36 ms (x) / 48 ms (y) after the step at 10 nm, 40 / 55 ms at 2 nm;
+after the trigger the position stays within about 4 nm even at a 30 nm tolerance.
+
### Scanning in 2D and sample alignment
#### flOMNI Fermat scan
@@ -760,6 +777,14 @@ removed later.
[See short version](flomni.md#sample-storage-and-transfer)
+### Detector trigger and interferometer tuning
+
+The trigger tolerance and hold time: [Detector trigger: position stability](#detector-trigger-position-stability)
+(`flomni.interferometer_trigger_*`). The control loops of the sample stage, the FZP scanner and the laser
+tracker were characterized and retuned in October 2026; results, the present parameters and how to retune them
+are documented in the flOMNI control repository (`FeedbackLoop/feedback_report.md` with PDF,
+`FeedbackLoop/tuning_guide.md`).
+
### Heater
The [heater](https://www.dora.lib4ri.ch/psi/islandora/object/psi:45302) can be moved up and down by
From 87969539139714fbd12e48e57a2ddde924b21fa5 Mon Sep 17 00:00:00 2001
From: Mirko Holler
Date: Tue, 20 Oct 2026 11:47:36 +0200
Subject: [PATCH 069/102] feat(flomni): trigger after a position change
(interferometer_trigger_set_after_move, emitter_set_trigger_on_move -> em),
three-axis move (controller.move_xyz -> p,,); eg with 3 values
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01TNtKEk4jAyjebMDQFwRHJQ
---
.../plugins/flomni/flomni.py | 16 ++++++++
csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py | 41 +++++++++++++++++--
docs/user/ptychography/flomni.md | 4 ++
3 files changed, 57 insertions(+), 4 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index 5be8b558..36c5ec29 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -724,6 +724,9 @@ class FlomniSampleTransferMixin:
f"for {values['ontarget_hold_ms']:.1f} ms.")
else:
print("Detector trigger: former criterion (on-target flags of the FZP PID loops).")
+ if values.get("trigger_on_move") is not None:
+ mode = "on" if values["trigger_on_move"] else "off"
+ print(f"Trigger after every position change (not only at scan points): {mode}.")
def interferometer_trigger_set_tolerance_nm(self, tolerance_nm: float):
"""Set the detector-trigger tolerance in nm (per axis, x and y).
@@ -755,6 +758,16 @@ class FlomniSampleTransferMixin:
dev.rtx.controller.emitter_set_ontarget_hold_ms(hold_ms)
self.interferometer_trigger_show()
+ def interferometer_trigger_set_after_move(self, enable: bool):
+ """Send one detector trigger after every position change, not only at scan points.
+
+ With True every direct move of the sample stage (e.g. umv of rtx / rty / rtz, or
+ dev.rtx.controller.move_xyz) gives exactly one trigger, as soon as the position is stable
+ (same tolerance and hold time as for scan points). False: triggers at scan points only.
+ """
+ dev.rtx.controller.emitter_set_trigger_on_move(1 if enable else 0)
+ self.interferometer_trigger_show()
+
def interferometer_trigger_help(self):
"""Print the detector-trigger commands with a short explanation."""
lines = [
@@ -765,6 +778,9 @@ class FlomniSampleTransferMixin:
" 2 finest; 0 = former",
" criterion (FZP PID flags)",
" flomni.interferometer_trigger_set_hold_ms(val) e.g. 3",
+ " flomni.interferometer_trigger_set_after_move(True) also one trigger after every",
+ " position change (False: off)",
+ " dev.rtx.controller.move_xyz(x, y, z) move all three axes at once",
"The values are live; after a restart of the control loop the start values",
"(10 nm, 3 ms) from the loop parameter file apply again.",
]
diff --git a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py
index 7309dd5b..afa49424 100644
--- a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py
+++ b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py
@@ -55,6 +55,8 @@ class RtFlomniController(Controller):
"emitter_set_ontarget_tolerance",
"emitter_set_ontarget_hold_ms",
"emitter_help",
+ "emitter_set_trigger_on_move",
+ "move_xyz",
]
def __init__(
@@ -448,16 +450,21 @@ class RtFlomniController(Controller):
without interruption.
Returns:
- dict: ontarget_tolerance_um, ontarget_hold_ms.
+ dict: ontarget_tolerance_um, ontarget_hold_ms, trigger_on_move (None from a server
+ that does not report it yet).
"""
ret = self.socket_put_and_receive("eg")
ret = ret.split("\n")[0]
values = [float(val) for val in ret.split(",")]
- if len(values) != 2:
+ if len(values) not in (2, 3):
raise RtCommunicationError(
- f"Expected to receive 2 on-target values. Instead received {values}"
+ f"Expected to receive 2 or 3 on-target values. Instead received {values}"
)
- return {"ontarget_tolerance_um": values[0], "ontarget_hold_ms": values[1]}
+ return {
+ "ontarget_tolerance_um": values[0],
+ "ontarget_hold_ms": values[1],
+ "trigger_on_move": values[2] if len(values) == 3 else None,
+ }
def emitter_set_ontarget_tolerance(self, val_um: float) -> None:
"""Set the detector-trigger tolerance in um, per axis.
@@ -471,6 +478,30 @@ class RtFlomniController(Controller):
"""Set the time in ms the position must stay within the tolerance before the trigger."""
self.socket_put(f"eh{val_ms:.3f}")
+ def emitter_set_trigger_on_move(self, val: int) -> None:
+ """1: send one detector trigger after every direct position change (pa / p), once on target
+ (same criterion as a step-scan point); 0: triggers at step-scan points only."""
+ self.socket_put(f"em{1 if val else 0}")
+
+ def move_xyz(self, x: float, y: float, z: float) -> None:
+ """Move rtx, rty and rtz with one command (CommunicationServer p,,).
+
+ The three targets change in the same loop step, so with trigger_on_move there is exactly one
+ detector trigger for the move. Values as for the rtx / rty / rtz axes (axis sign applied).
+ Does not wait for the move.
+ """
+ if self.laser_tracker_check_signalstrength() == "toolow":
+ raise RtError(
+ "The interferometer signal is too low for movements. Realignment required."
+ )
+ targets = []
+ for name, val in (("rtx", x), ("rty", y), ("rtz", z)):
+ axis = self.get_axis_by_name(name)
+ target_val = val * axis.sign
+ axis.user_setpoint.setpoint = target_val
+ targets.append(target_val)
+ self.socket_put(f"p{targets[0]:.4f},{targets[1]:.4f},{targets[2]:.4f}")
+
def emitter_help(self) -> None:
"""Print the ConstEmitter commands (loop runtime constants) with a short explanation."""
t = PrettyTable()
@@ -492,6 +523,8 @@ class RtFlomniController(Controller):
" <= 0: FZP PID flags"),
("emitter_set_ontarget_hold_ms(val_ms)", "eh",
"detector trigger: ... held for val ms without interruption"),
+ ("emitter_set_trigger_on_move(val)", "em",
+ "1: one detector trigger after every direct move (pa / p) once on target; 0: off"),
]
for row in rows:
t.add_row(row)
diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md
index 238ec211..8031e5d2 100644
--- a/docs/user/ptychography/flomni.md
+++ b/docs/user/ptychography/flomni.md
@@ -229,6 +229,10 @@ over 0.5 ms) for a hold time. Both values are live and can be changed between sc
low-resolution scan, 10 standard, 2 for the highest resolution (a few ms more per point);
0 selects the former criterion (on-target flags of the FZP loops)
- `flomni.interferometer_trigger_set_hold_ms(3)` — hold time in ms
+- `flomni.interferometer_trigger_set_after_move(True)` — additionally one trigger after every position change
+ of the sample stage (e.g. `umv(dev.rtx, ...)`), as soon as the position is stable with the same tolerance and
+ hold time; `False` (default): triggers at scan points only
+- `dev.rtx.controller.move_xyz(x, y, z)` — moves rtx, rty and rtz with one command (one trigger for the move)
- `flomni.interferometer_trigger_help()` — this list
After a restart of the control loop the start values (10 nm, 3 ms) apply again. Measured with 3 µm steps
From 7c6cda681c47cd1f52f0e3da66f9f79f0f3de13d Mon Sep 17 00:00:00 2001
From: Mirko Holler
Date: Tue, 20 Oct 2026 15:12:51 +0200
Subject: [PATCH 070/102] test(xrayeye): keep the ellipse-ROI test items in a
module-lifetime QGraphicsScene
Unparented pyqtgraph ROIs could be garbage-collected while the next ROI was being
built, which crashed PySide6 in CI with --random-order (segfault in
GraphicsObject.itemChange during garbage collection). Test logic unchanged.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01TNtKEk4jAyjebMDQFwRHJQ
---
.../test_x_ray_eye_ellipse_roi.py | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py b/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py
index e07ece3b..b16ad85b 100644
--- a/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py
+++ b/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py
@@ -27,6 +27,22 @@ def _qapp():
return pg.mkQApp()
+# All test ROIs go into one scene that lives for the whole module: Qt then owns the items, and the
+# Python garbage collector cannot delete an unparented ROI while the next one is being built (that
+# crashed PySide6 / pyqtgraph in CI with --random-order: "Fatal Python error: Segmentation fault" in
+# GraphicsObject.itemChange during garbage collection).
+_SCENE = None
+
+
+@pytest.fixture(autouse=True, scope="module")
+def _scene(_qapp):
+ global _SCENE
+ _SCENE = pg.QtWidgets.QGraphicsScene()
+ yield _SCENE
+ _SCENE.clear()
+ _SCENE = None
+
+
class _FakeEllipticalROI(pg.EllipseROI):
"""pg ellipse standing in for bec_widgets' EllipticalROI in isinstance checks."""
@@ -39,6 +55,7 @@ def _patch_roi_class():
def _roi(pos=(10.0, 20.0), size=(40.0, 20.0), angle=0.0):
roi = _FakeEllipticalROI(pos, size, angle=angle)
+ _SCENE.addItem(roi)
return roi
From 26b16347945642ee791eac68e85f8be4a05dc6c5 Mon Sep 17 00:00:00 2001
From: Mirko Holler
Date: Tue, 20 Oct 2026 23:14:43 +0200
Subject: [PATCH 071/102] docs(flomni): FZP calibration / range how-tos updated
for the PREEMPT_RT loop (original text kept)
Markdown sources next to the PDFs; new section 'Update 2026-10-20': 7-element FAST_FZP_PID_LOOP, Feedforward,
AbbeCorrection, TargetSelect use_final, OnTargetTrigger WaitForSlew / MinDelay, FZP limits +-1.5 V, current defaults,
exposure during the motion after the calibration.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01YZDvHhkb4fXGmia6jLzyCS
---
...ct_only-howto_calibrate_the_fzp_scanner.md | 128 ++++++++++++++++++
...t_only-howto_calibrate_the_fzp_scanner.pdf | Bin 31150 -> 60296 bytes
..._only-howto_limit_the_fzp_scanner_range.md | 36 +++++
...only-howto_limit_the_fzp_scanner_range.pdf | Bin 20066 -> 48163 bytes
4 files changed, 164 insertions(+)
create mode 100644 csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.md
create mode 100644 csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_limit_the_fzp_scanner_range.md
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.md b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.md
new file mode 100644
index 00000000..03fe6cd2
--- /dev/null
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.md
@@ -0,0 +1,128 @@
+# How to calibrate the FZP scanner of flOMNI
+
+Source of `local_contact_only-howto_calibrate_the_fzp_scanner.pdf`. The original text is kept unchanged; the
+section "Update 2026-10-20" at the end lists what the present flOMNI loop (PREEMPT_RT, 100 µs, branch `rt_preemt`)
+needs in addition.
+
+```text
+This is a description of how to calibrate the FZP scanner of flOMNI
+
+Rmemeber to do all modifications for both directions x and y!
+
+1. Align flOMNI without the FZP scanner. This implies disabling the
+FZP scanner completely. To do so, in OrchestraHMI edit the parameters
+of the FZP scanner as follows.
+(a) Module PID_i_FZP: Set Param FAST_FZP_PID_LOOP(0) to 2. Then the FZP
+will just remain
+statically centered to the capacitive sensor position in closed loop.
+(b) Module PID_i: Set Param FAST_FZP_PID_LOOP(0) to 0. If this is
+negative, the FZP scanner will move ahead in a point-by-point scan by
+that amount.
+(c) Module PID_i: Set Param FAST_FZP_PID_LOOP(5) to 0. If this is
+larger than zero the sample stage motion is stopped if the position is
+reached by the FZP scanner (which is the case when the scanner is not
+active). Zero means thus full compensation by the sample stage.
+
+In this state the setup can be aligned, and the affine matrix for
+ptychography can be found. This refers then purely to the alignment of
+the interferometer and X-ray detector.
+
+2. In the next step we perform a scan (with a small beam and small
+range) such that only the FZP scanner is used and the sample stage
+will not move.
+
+Before we start ensure that the Correction Matrix for the capacitive
+sensors is cleared.
+
+To do this change the parameters of the flOMNI control
+loop as follows:
+
+(a) Module AnalogInput_6259: Set parameter MatrixCorr to 1 0 0 1
+(b) Module PID_j_fzp: Set Param FAST_FZP_PID_LOOP(0) to 1 to re-activate
+the FZP scanner.
+(c) Module PID_i: Set Param FAST_FZP_PID_LOOP(5) to 10, so up to 10
+volts output of the fast PID is accepted before starting a movement of
+the sample stage.
+(d) Module PID_i: Param FAST_FZP_PID_LOOP(4) 0
+(e) Module PID_i: Param FAST_FZP_PID_LOOP(6) 1
+ with d and e it will now stabilize the sample stage position at zero
+(f) Module PID_i: Param FAST_FZP_PID_LOOP(0) remains 0 (no move ahead
+in a scan)
+
+With these settings perform a scan. Use a field of view matching the
+range of the FZP scanner. Watch the sample stage position in Orchestra
+to ensure it does not change!
+A good range should be 2.8 microns in x and 1.8 microns in y.
+Reconstruct this scan with Michal's position refinement code and ask
+for an update of the FZP matrix. This can then be put into the
+AnalogInput_6259 module.
+I guess one has to have the matrix found for the interferometer in the
+reconsturction code, such that the new matrix of the FZP scanner will
+adjust the coordinate system of the FZP scanner to the system of the
+interferometer. Later these positions are used together and should
+match in direction.
+
+3. Back to default operating values:
+(a) Module PID_i: Set Param FAST_FZP_PID_LOOP(0) to -0.5, for up to
+0.5 microns move ahead in a scan.
+All other parameters are already as supposed to be.
+Default values for the sample stage pid, fast FZP parameter are "-
+0.5,0,0,0,0.1,0.5,0"
+Default values for the fzp pid, fast FZP parameter are "1,0.0001,0.1,-
+4,0,0,0"
+```
+
+## Update 2026-10-20 (flOMNI loop on PREEMPT_RT, mpc2844)
+
+The loop has new modules since the text above was written (flOMNI `FeedbackLoop/tuning_guide.md`,
+`feedback_report.md` 5.6, 6n, 6o, 6q). Parameters are changed in the web HMI (`http://mpc2844:8080`, tab
+Parameters) instead of OrchestraHMI. `i` = x or y, as above. Module names now: `PID_x`, `PID_y` (sample stage),
+`PID_x_fzp`, `PID_y_fzp` (FZP scanner), `AnalogInput_6289` (was `AnalogInput_6259`).
+
+**FAST_FZP_PID_LOOP has 7 elements** (`PID.cpp`). Sample stage PIDs: (0) move-ahead (negative, µm, point-by-point
+scans only), (4) FZP error threshold (µm) and (5) FZP drive limit (V) for holding the sample stage while the FZP
+takes the step (5 = 0: never held), (6) 1 = hold the sample stage at 0. FZP PIDs: (0) mode (1 operation, 2 FZP
+centred on its cap sensor = disabled, 3 not commissioned, 4 debug), (1)–(3) min / max / gain of the old velocity
+correction (−4 before 2026-10-19, now 0: replaced by the feedforward).
+
+**In addition to step 1 (FZP disabled):**
+
+- `Feedforward`: `enable` 0,0 — the static FZP feedforward adds its drive also in FZP mode 2.
+- `AbbeCorrection`: `enable` 0 (default until the signs are known).
+- `TargetSelect_x`, `TargetSelect_y`: `use_final` 0; `OnTargetTrigger`: `WaitForSlew` 1 (defaults).
+- `FreqGen`: `enable` 0 (excitation for measurements; default).
+
+**In addition to step 2 (FZP-only calibration scan):**
+
+- FZP range: `PID_x_fzp` / `PID_y_fzp` `Ub` / `Lb` are ±1.5 V in operation (the cap sensors are calibrated over
+ about ±2 V only). For the field of view above (2.8 × 1.8 µm) widen them, e.g. to ±10 V (FZP ≈ 0.13 µm/V in x,
+ ≈ 0.05 µm/V in y). The feedforward stays off (`enable` 0,0) during this scan.
+- The detector trigger (`OnTargetTrigger`) checks the relative error sample vs FZP (tolerance / hold time from the
+ ConstEmitter, BEC `interferometer_trigger_*`, default 10 nm / 3 ms) and waits `MinDelay_ms` (15 ms).
+
+**Step 3, default operating values (replace the values in the text above):**
+
+| Module | Parameter | Value |
+|---|---|---|
+| `PID_x`, `PID_y` | `FAST_FZP_PID_LOOP` | `0,0,0,0,0.1,0,0` (no move-ahead: −0.5 µm does not fit the ±1.5 V FZP range) |
+| `PID_x_fzp` | `FAST_FZP_PID_LOOP`, `Ub` / `Lb` | `1,0.0001,0.1,0,0,0,0`, ±1.5 V |
+| `PID_y_fzp` | `FAST_FZP_PID_LOOP`, `Ub` / `Lb` | `1,0.0005,0.02,0,0,0,0`, ±1.5 V |
+| `Feedforward` | `enable`; `ff_limit`, `out_min` / `out_max` | 1,1; ±1.5 V (as the FZP `Ub` / `Lb`) |
+| `OnTargetTrigger` | `WaitForSlew`, `MinDelay_ms` | 1, 15 |
+| `TargetSelect_x`, `TargetSelect_y` | `use_final` | 0 |
+| `AbbeCorrection` | `enable` | 0 (until the signs are known) |
+| `AnalogInput_6289` | `MatrixCorr` | the new matrix from the calibration |
+
+Check afterwards: web HMI tab **Loop check**, level 1 (it expects these values and lists live values that differ
+from the `.xmp` files); save the new values as default (Parameters → Unsaved changes) or in the `.xmp`.
+
+**After the calibration (per experiment): faster scans, exposure during the motion.** With the FZP calibrated
+over a wider range the FZP can take the small steps alone (flOMNI `feedback_report.md` 6q, tested at ±7 V):
+
+- `PID_x_fzp` / `PID_y_fzp` `Ub` / `Lb` and the `Feedforward` limits to the calibrated range (e.g. ±7 V);
+- `TargetSelect_x` / `_y` `use_final` 1 (the FZP aims at the new point at once, the sample stage follows its
+ S-curve) and `OnTargetTrigger` `WaitForSlew` 0 (trigger on the relative error only); optionally `MinDelay_ms` 0;
+- optionally move-ahead, sized to the FZP range: `PID_x` (0) e.g. −0.5, `PID_y` (0) e.g. −0.2.
+
+Measured with 101 ms exposures: 0.2–1 µm steps trigger after 4.5–5.8 ms instead of 17–32 ms; the error during the
+exposure grows with the step (1 µm: ≈ 6 nm max instead of 2.5 nm), so the setting depends on the experiment.
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.pdf b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.pdf
index 81ce9c8a5479ba327965c1f1f11cb88a10381c23..013f519d23ece88fb94c9016565d452bc0b31443 100644
GIT binary patch
literal 60296
zcma&M1FR@PwF^6up9zy@{)_
z>Ay~8YfD4=f3XC%jI3<`JpUis(%w$Q(8ZL1LWGNnfr*Wg;oqK-k%ftqnt_4*-}VnD
zYj5&@hw$-1ncA8BR}Reoor|#{0V5Q>kSqbcoV}B+q0N7TIQ|S!gm_pIZnA(}USP-xIkL+vR@mQQu#O_!1qj|JFqZ?=egmlbA
zu5$u!fE-XM5`*wRlH&pVyJt5S#U-Eh2d3t#ukoY;3l52@?P@gidLnKsDbxH?)YU)p
zxZjN4o5xiXzh8Bmky=PZdzeY-;W>W_{}eR*1u}jzxmDZT48nH+aI^@J{}{x_&+vLD>uz6Gtbxe
zcQpnrzcs%%AG%p?mCf{*dHUs}{=?_Y=7+}wZN0AiO>1X(O
z&aLsBLSN;8_0rxNpODC42rORrgap=JBFqV5UnR`dU;!VJ6$KOQY0(bE2C?jF+<{7|
zfa&3a+`7l1(1U#3WI2>g+1$&=8@$KCT;>}{E`$At>#L}@IX#yKY_FV)
z%pJ&l!{Pn_Gn6Rs20RBm?uREvX?H|ewdbg65>M@$Kmc@*wVGa8yJs~PxYA>%Y3iTSJZ0L&kqXQ%(MZ;X7
zFfK5s^1=*mKA!t!V5j0F38oTLW|@TR+bl7iw*{ejcxvs=q#C|QMjV}+rur_?CuOG8
zq|xU-lzce2zUE%%Itw50VPKuywZrGh#O_bF_a|pVIF^|95AR^;a^I+d??x_X*XX{z
zn`cT&T
zUG0sc=sAyXl~39$gynMr3VHc_N`l;VSnH=5Ut~0Oc}Xz}}mJ*uk5@
z>9d~cpeQ3e8$w$+P?YQ;G{l}F&hv;chV=V|8$kv0i7to!RKgd2LAM&3(yNW
z74+WL@D@rTKLCLRi3vw0N&G8!WHH_pD-k`Y4oeme<2_|7Gy{AE>@EJq;{hwHJdqE{
zl-@2QEF5+Z$6_E#!{j_e!(9not;W|H2X11Tvp&DlE9%LVz^pE45-Nvq1-t>9!C9HlU{&}0&RAb20f%RsVAiyeCo4bDc9?@7RPH?sy?_2QzPXAg9JK1mn3Lnqb9
z0nc7?wO?iiaOr{5LIevjT}0<*C#)oxMakFdyAu+c2NW&3XaEAj0pkTk4~fEZosY+%L&NrF!|HXRaviYYT1JW^q8jvy>j%IhHf
zWQfRaaK&u!e4SZpMh2OD)W&RYY0zT`uuj|`hv?0`x@*gL&%n&;H
z2t|kwAH+`aH&jp%QH7FO$AS+Jj%Hp0FLs8I7`{eT6eK>@L_tOe2f9)`&HWS@}K0Z?xon>br7(tdyQ_nn5Z_mmmz?!lXf3KQ7VXrZ{%~e4nE-xc^JvFI#^eSBQEq_
zDuJuxTLwXMPCXQPN&!1?H;$|M!ZjNL>y<19RcmODL!P?;lwTfrVAbL7{Ri5Nj!p2S
zZ;G%au-~Oah0x!vw8D_02rz=-5t@>VVO?%$D1lRuqCJP6a#Xm!v8?OV)nB%GaO{dR
z8@vhVMbw4z+A{dT52|#j6BoGqxLXf)a0vGCsP;ngf%VY?RIF554du0f8Hmb>)srMg
z-ZQpOjJ`OKhmd774(F;p^|uG*yc>~A@@53ha@A>MjZ|j
ziaGj|u$|(NAmRxHo@*wdI5LEZMg46{(>INJn)9@Ob1HQ+DR*Tx^a>WVp#IB}9v^)jF4!MPdIhdK9w>^by)rDah@BFQ(@N<@Jy4N+?3K){VWWe4Uo3l!
z%IINxlfZ(jvJJ=nt^*vlQ*v
z?+48+LWG@2hYu(Zdx;N>a)fMOv473y=+ty{9sa$%qvme{98J6LSQI=oD{#OAiXMK5
z?_~hAyYJW*9%S79uX`#wunDGM?^}REdp`R}^VbYSSAXJE@7`eGB=6F3{O{p)PtxV3
zk^zIzM|7G@f{1R*>cS~iJB}fmixvVsUEe=q}
zlYWGycLQDaKRe7Hp+K4>XG{vBZXp}f0m9JJ0JKDv4~gogP)vej`)Ct2M&*Q3S_#jL
z!a31g_miT7FbHWA3>A+DHF77>gy*xC*BxLj*e)!th}7Lu_otcPg6#4L?`k*+Fec
zKABZ5ct~4LPa@~a@5EXL@CtAxbPyHi&5%^F^cgA9$veg<$aE4+#O%m=$f8M$TAkyZ
zZK?vx`S`Tz!sc-Tt)c3aM$~j>MIJ?3jR@oet=!soKlg540vejKR~4V5z~%m%
zl;s(qbJUY9+Q59n(p`Z2M^9C29H^ARLy<+iaI+w;7jEp4HR=)m6xy#_LR5`u%mU-#
z>egeXA|<2ZCqM63qNKM{ol#2kQ??xkVQ}bV_Gl>Tpsg6>f8%OkAVjWhE)=6&uW|QrT6^2rVC0
zyF0x}*5s3>I-GXgHIGLfJv0dsm^KgaHVIKj4Q=R_n*{y{tk?sT7d*DQIPl3RL`d@C
z5sE6o+MMyu`0N0M#6xEcYlvyKl|-wft{jbVsAQFh2`)`)B&
zS9DYH6mUcr?bOf_5AAe+(;W{>N%?$FRTNN`t8PeL@6ef=ManWTvpQ&uQTQ&BK666b
z?@d*8)e)^oHM7t`sJ^oh$4BtpT%rFpy2f06r!*omG^MUq(^2-c$Iv3N_EO1_z+(j7
z-;zf6xG-ozWDrZb+3sCTb&DA!0=iJ>Z`wI}&l+0Kwy29c+z|vTp#j4Oo>_J{8HWd|
z5ZWD&NN5JlHYgEvozXhX5n)fhkM4$Cgd>remE<{@RER&Z$}A~;c}PXTXjm`ZYsK+C
zed7e$+DGNb3wLO>Xr^qF8UpqJ1x#)DInvYHjev)?r0P(+No`ebkVE79^lC;q>N8TF
zdi8PWyg`o=$*pi}G}t3@*ECY%XYIx5@@(a7<@0?DU)BuRaZtUkhCh4nS3X}gU#*V0
z&6*EjSl*8B7EjUY?)*#GzlA8hpUcHy-ljZ7InSGuj+}0bXHJ3Li(x5n&e0e1a4z3s
z?GvJqZQhqqXKl~2wX+$?y@%irytfPl$$u3z{!@AQPicdZ;s32{!NI`5`ajDS(zVAe
z2|K&`1*S*@R%p8*SYRJ5+nS~clE9kSxogb9YvH)yW3%`QvxR?ahZpuaU)>f2ueKz_
zeN<2{b%*t`SiVY?b1wXL8-KB@Z;Y>cKJU*@$scN7H(ZmK?s_wAF1n*SnExv0!Mxw3
z_40imyKl?O^YdrqHerL|{ATw4bj!JY{dD5zdF{she%JE5Oz!r6-94SamwfKy%hwYl
z+nxsBP{4nGE9Yg);r_b+IKAGb;XkxWt#Do$gDx$m
zw~3d#!NG#M9!zk-XNy!no>!PP(kK|e4l@&ILI^*|LB@(iSc41%5mqla?h&U);3If9
z@X7ZT$TqGkI!1H;8y_3?&|js`6O2BL?o)Q)bB8{B4bp}nL$54srOiQ!52VGAwk|c}?%T5uBn!VMO#Eqc{p{B4!UFJ8g2%SO0uc+z+
z*VqW4C*T)DUW=48d;~|!UWi_f*&FI?fQVM?%uydrC(#6TssJA_S*eQ^ASZE}w=w-2$vz)l2Z_*yO@lk(^_Ih;Atz$b#M
z#4@E#JC?@Er5r9?P%6W@=AZx#c9k4#sfg3E4FZwtLER7dMNH*0o31G2V3lkT>Jy6E
z#Z`DymaQ-kxGCvj>>(L77?G>!Kp@DD^5J_BPZH6ag6{NN+3^4qX3Wgx%wENZ+faO*2KJ!q*{-_(1Fi@pK`a@N@z#KcBT*ubaG%H
zfu_&>#4nPE*T|G4IA;U^F9iZ|K{!L2O+6MJ+E)H!s2~o5paSX_HeQl+KtUoou=rZ2
zjlAE~dh{8Ni!~=*J7g%9rTSy8Q8usnIRr1*8Ru~v1`@awgmfjc*c4&Y3)M?zBSuRd
z^X}fL^d>*Fqz8h4>{&rW!WVSCG0_k?KF=wkE-Ft5wZSt1ZcB<;Hwy|H2diuykI9^O
z2PaP}9d7fX5DiWgLs;V}g%-hxsFSkE$QmTWxmDW&P2ksgIT^h>Z$5%%9AYR*swb3rWA3B0G#X%QN$&C?lTaY-?5*cK!fUJ(vjPLb=XPg)}wi4?@B|
zojI_f6zz1pUQ4nk`ag-oTu^GT`-rIIIy!d?0zx6&Y1G3`cqH!C`8Y6L&WwBvYn#WO
zbE|1<0cz)Za1zz0Qy8SNu@sJUF0KvDmOyhqLFki0#-+>3v!k25eqfQm`D%?kJiVL-qh+ejB76fL)~-GuX+wg`U`H`a-X(9yYE$J}?~!3B+#Z&s0*$YAUEI
zmE8@ZaaSUvkf$!2rnpMIRb7q*CQ+RA{afd%;hfLVvikBel&@7gQtfC4+Z4Nah%QxY
ziUgjK2P$%+&V+VQN%BHvr-qJ6IHJRdEFTuKI^5t|Q@~e?w}kYEZB1u5yr4LFK*ld=
zqRKZ6QiejDDL^X6F^SQ<7{OQ0=!EFox!a%-oP(UZX6Wx`3IsPDVngR#)5c~foNJlP
zgkDYZEPoqm_hC9bp8;_(SH0LI%%%dWi
zuIAlP+`XY2-M5CO*qE}W!zl1qd{`bR$EIx_Xcm7GwI3of{ry626#KVV&Gr<0kdF}xd0bn3M0alNgBOHf*EC|{D57N~626R(@^
zchYtDi_U`=C_|s4B)`LXIjw`{QR%0;sA`)Wxy#)cIJd%bPVf4M+8#$f_%xGLs@C>U
zT#@>$D)2~`$%}oQ>WvZ;zT=-1}Ay6K}$gMPQn50o?$Z;O}MaC;=5-@o-EiYF>$tfc?cyFObJrbyM5@C$%}@v
zo6Q&9Sj6~LTskh-rdC)P-Qf}rjS^1B4#P#c17!)vkB@=7+@)J86E;ufC
vMdt{d2Y#b?T3|-7y
z-w#eEn=Ncp$bwW>M3A@<4>Ca-rAM)DDk~xcy*+fZ1lK3I8y49EFGQA$4Gq#VpX7|c
z*l~*0OxReLxh0Pwt%Rn$p%7wtKY(l^(<-uR;;8XF?pu0Cp92`cO4T73o=mjPIn^#b
z3x-6k9B>u@mauyfBEH1U$8XcIa6j;MSfsb`?y8`|ZJ&uh6u$QzQZbhH9kAMGa2&F6
zG%@X$e0t=GSjfc5`a3r%D<~=Fa0YUXn>CZJH@d$gT!Pg%d6i!S
zsW0G*2AYsx7{zMf83|uC9W2YZ7XqpHr5ZltfRpw6cV4Efn~pYRl_xyU|Vp
z#0)e96veDH-1?JrjJJSyk?xb?4=+czOCZ*vp<(E6o!K*{muz7R`Yo^vt%h_&q<_^p
zEJw%qoT?`UmvS
zQE(B~bO?8H`=y%Pbv)Xg!tMN#k#%W-y9ON`gTzqmJ#+~g4;wuecn1QGHRavL@D+m-
zdT|~QDC!D((vJxWc>kzSs4rKpB5^`>#{l-i|AIuQY-eAhl~sHkPDzE*bx<`xsp!
z<)hYaTuly}Z#GY{{@I7>R)VM9LHZgvGe-`FZr+fgW-iOpRgTgo3K_c1gCD9SvSs`Q
z>^e%uHy10qbhmT_i!(9p`xsTi;fx9|F4FBafh#)@%k$r=MV>~_)cl=I9J5WSxg~J?
zm=ki8X~5p}vZHNW
zwT|3UdYfoXqT6+~*iTV3J)MhyM8>~cfy{Q?;N4j#bNwK=a4Kor6$dh~WSS@2Esa!r
zJ}#N_s2>I`7_u)(qQ&kvpWo;p08BYvyIJw89niSh)UkvwV(=BeUZI&{(ymjbyd^Wu
zw}2(aB^83Ln(Dg>uVL+iZH=pvhTH9*n+r4_m_k@36&@zX{*DeywNO}m3~tQKhRfEq
zNiEzv&AW#C>a48&P-Tz{LFKjs=^W9X1qg>iIs&qd;wjND9YE~|ce^o!ijiRkaa{Et
zgsmbe)#JE9EKqUeJm3uF;YCFd$$c{UA0Qy&je6)n`J6I>4+0n_f*e>R@Pot<##A8%
zjhY~y<}RtxrNZ53VEQXD#PC*vU?99gb!_k>R<&M#NVc$Ya5!820;F%ZDobqXHnvck
zYuJoTvfKpo(k1jWKl`&9{}E0D+wSg0@>?pcX)kaZZTBGd+Qcx63SaD~D2YVa6djX~;Wz`Y>h+9eE??0jCS_w7j#Dny^GURZ!;MFOg9
z;NrA)nn{CI1SK7#r?l_&*v)DZOpNmG5{`1CcwH7G)y?j9Et>7BQCA;i@+1d6Anx+s
zD!^W5B&O9sQ!$^Jtf=6%XIHC6MeAxC!s0gE13OmbM7WAC8UA2K8#+jp)eviWJWJ*3
zD8|y2x*3_I9h(g
zW%t2Bs_3H4oEVp(59L%cAhih-ZLy`-gfK30|4$Hu3iE#9Qqt8B!
zj3ke{GGdxnx_&{P3?h=$vDK!r)|c`S=*q5xf`k9$WxHwy;(eirb)X;YoY7Q27T6&s!m%K}hU1^uyI<=X8nCL%7(au+P>`)+j$pwq*FhK0
z1krRGS*?ICgr+jwLiu|=z|jXqbRi3MXs>Rdl-s|Kbl1KlxftFGDw@1{?W?|*NJg?K
zfk6#>gdQ!I8pkPHz3A|utu_Sds>w}m)gtc)fR=t&)F2RtUkSKC77JV@?$95}KcJsJ
z|Hx4Ouyjz+@{{yHGntpdNgoa5z1!#+wZ#XbP2Cj`3B7}+6k}x6NnjU>nf|X
zCVjFpfsA#G=xR~bW6|^()lT668V*h2NG>K8!I*(1Gj30j2)@7XWM4Vkc0EG@<8~&A
zE`CZMpnJte7u$Gr)cdz>ZP=!X)$+7<(Lw9OAMR`3h56S0vF
zKC{>=Zo?AjT~=miH3-IFix5)gT#l`bg60DWd_(82@ew0vmw&@?)vW>HYS{?lYW&Ck
z_v8Qc$x5hUN7;JxpNafK*u;}exC7Kzat19}~ubQVL@MH8lY*kB{pY7-ni
zfUZ!NR2LAPg9)bN)`7+G}v_T5DsL3T1sGs+>lw0)HI
z5Q}`~PcZT~*E>v?8j2j8{dhoSBK_*}eBrZY+x75POwJlZIg|TXPaR_`-L3?&qY|(g
zAVf36tnkg;ppg_!jE?Cb)u~dRFLizFZ}{cfuWq*BLzJBKCQq5l#))8Yj%{w9t?lEN
zjeS;_kMYR}+w5#hi>F^p>-6Jd^?&5Yk4b$G_Ir`<6Q{Ol_j7lLIv4`ID$uu#}s+}}kYNN@Tr`;qdx&uYIBB3ET
zG$+GbJE6w<0_+%wmT9-eOYRYW{jtdeXEHzk^FR?VJRm=*8ns~%SGfNneEpWYA?DrC
zkg4bxX90hnJJIZqR)FW!(`sC@+wl%p&aFnjvU?@1o40SEb?m
zRIOp!RGVIF{3Kd+=mt5XqF(n_CKk8@8v$Z+c1x$|+E@j}dg2U?$5al(IvjVUhY*T(
z;tVd6;%x^sXA01L)|p&in8(L15f9EDFb1OrxbHy_ZXlp#AMjK%mGo={6*#nx5)8(W
z0-rkifgv>m6-*3+dVx*M6C-LBs)#3A)h@&lIW&l41LN4REQeZ2>alVD#WYm4PF&2h
z&bx?5!z?*W7I5#wtr;*)Fui`P#Mtb(fx*m=4%;A6ykJ@6!m}4C9jZIPy_QyJoP(
zA4BY>(6Cdv?OFTIO!b?lwAPuC$&eaP#{_6J)C0CJr;*=6_LLomQgBitVb<`(Ft<#7
z-p7+!|KFF=R{Pzq&zn_$zt7e<|L@h>ZTp_z-CZ|*-(_C^Z@*R$rrATgp7+Dg(rnq=
zF{O~Us>@t$_iwqu(19<5B)%>@jMid$`L3>_lNj;b)x*P>@T`Ud?!vbO`j0}P>$?nJ
z#bted(i!`T8u~t8oyt_zW%XCWv;n_Dc}Vm+Rd@B}ZEmGyg%ZL^%KQ{bkDMHI9ukLn
zQO8BfY>-0S9cs~(b85}N$fl#RK?!D~S6Wym$HY*bFQKIUpWNkFed2H(iO4V;03dWd
zWrbn8I{%@pMsx_TJAvzQ)#WDK&p)^|cg1eDVw6=;#
z)xgHs5g^sosXij<5k>>nuJD4n6Q_b(%F{G`Opw#QNVKLW`zxkY+|01c>W-P87Bbi3
zJiz(ckV#ioHpU)(nP}Shah^pZqrbU@FKA7C937uJM`ANg(ux`0f1^6~KFXP#ma+Jv
z{wUU>>OTr+3aO!S!*Qi+F4!C1wl7`{<(x{=h}^r%!e%*#*PeCk5u|vVBT7tIrCA7P
zs3Y61sdgTjZw_UC+2m;;+isDr{QqZ3c^~DBTiMU?dB=ttZX$jx>UmY3DDReXG)5U{
zgGHRxWE(0a%T@MiJh7(HtlPozEo7lIm0FMDE5o%JWIkEeauZPTtnQ|uTbaiLf?X$r
zRbzAD64?Fui-|{F~|>B9{*>vg%iTu&`qj-5p=8^o>>zi(4yI)B&e%
znyl~ZH4CKtTP6R3$Xq&@s?B`*$NH}qtV$#&nldd}MVVV%m$OY)za0;93H#yuVHo{%
zz61*O&=&H%MsH8vTReG@xMGrtOq4xnjh6~qf?9WD0qR6v3NM
zf0x8Kp2^G&iGEF0FaB%=m!acFkx}{lMZYl%E!C=8GnS+4QCFS~Q8S5oq|C9$B|6fI
zwRV#`xv?$q)dwun;r}30S0l9Z3CYULv4~1^(>W+`n8aO{LGP^-Y^zwUT7iBkNGjxEn5X+q_Af{ssS}+yb
z@JvXL^OX=Lo<+5Bf`(*9ajX}c1g1J;OL#iy$aArvibpN`ysX!HF0aRLVMrwtOc>PNgUWNj}8Dw}U_rSp3n+q2IN
z{`T)*hIhFaUxFiDT!vhPGKD-4&sSI&S3Y+`ai^gRgV#I|`
zmr@^CN*?C52QdWnld=Jh+hRol5L}ry3S4)%cI47ghILOZY67y3=8kk|YE3`15Um_xHfBWST~s_HSR+#WFq>
z_3>2E1eD=4ieQva|D0=^SMj88Oq@)F4t#&lw`)Ki?6@t#rte<}I;>ehZlGu~q_>+%
zd3gVC;n&MSdw3Io_TRm`&85${9jJ7IY(6U@MA!FO|4(IgcE9(pyY0NP${z$mF7LA}TFY<8kHQi;y5P7AbFNnLOB`^}muX@W
zI^c+o)Ox@h4f)V^|Ze|*c%OT&%DLKu|{y#^DKR;EU*kHvggt}
zwb*SV!uJaR=3cfjVF%5CF0-t7E5bsBcIyg@g+AqBfNU0uJu8C%*iB_&E|whTjbQ(N
z0@TAYkf^Qx*J2wus0=E=VtRq~R~Z6_9uxzH)&UCNZvY6d1sL*@ywIBn6jZ+#{NpL`
zw*?h`*MbM$$i%-O%+UutW;6IN9tUg&lv-GZHdu@bLSG7WYOnJDL4T+8U-Y)5_#Z7P
zJ`%R;qtVwPx-ZP*{T269@zfbtvuPaP1@M)jRpRM*44&qVc=(^-CPYqs
zRla9;`$=#tTueBYpe!-K>wDv-2psGKG#EUE+5(3^fY`S{9~9tTQ{fRuRj-Dy%^3GY
z2aWHO9O205{qMlM9_WBJ!J6{?v5VqJ27kWCEkNcd*_1~_M%Iq`Kd=3^|2;h5H#pj7
zAdD@_nrF+OAj!CWAV`7{=h|M4FjD03`Y8;Del|W!coEM%1<$@XP+yNIzM1GNPGK{0
z8*g)7x}}z-Sd}rLm6iN{8aM4yYBl4q0uXLAdQ;HTrxga|E1FoT~w$DwT!@?km6o-ubjq
zmjzyOIGq-k%{)*$S_JS1+nA@oVnd=LvW+5e{mvz*Q&*OtNm#9k*UJb|Sb$d{K-d0}
zVZ*mE;Ua%wSqHe}uh1R(V;=#84Al!ik7?`qykW-GRR@URF
zOrMN!uNt+P4`Zb&nd!7{`)kAAZgzi9?R))xzuj;BzD|FCu5a`FK6?G#ejgtFzpY;f
z;kW;Ne6fNE#0?7I|9mCq1Aw1>@AqH4Ti|YBYLN0-`kdNLyR+Cn2I@fA;p1{dfWZ+3
zqGg$^KnFdpjEYV~Z=L>YB*v9wBcmW3^uJ_z
zJ$b9ep>gTe;63YjNIz3(-=~#{W75^A#eMLkW>ts_{Fq%R%1e%DSBOL3_}gD3jCi>N
zrs>zCxzz87ScDFH=$QE;dnG~Z254W%;=H;!l}4&R2X+8gXvJ4pTJp@?HZih57W!F%
zy#CQ~FOYucWlz3j_Ea%xBx6?*;Xp;a*gYrcnOJ&AN>jd-J^c1g4lu%5VV4iVQ9Esm2Kv;6w4BDqfp=-_Z{xZLMNt`K5ogxTU4!KHB%OoN^{~?s}hUJ|R0Slalzr~nd
zi)e90#-eL4*$9mdb>Bl;T{O;33^)Ow8$&A`pJftfswR-#Af_^!SYdWy*XbszvwWV_
zihxSx>#$r}DYOiC1BpLnUN5n7Zek%Bv&fUate!9J)IqDh?9KZHPTlVqp2}}Lm2z;4{xJ=4uA|`B?zyq}esn`bpVepxA!
z3i#q82OCI}j(o4*T@WA5bajcm5jUfrxN_01oI8Nr_;;-D5i#Za21Ky&GPe9!+wl1)
zd_K%rcxcztsG>W+>q=U15N;nW^FG5|?i_zNI~#8sR&xMm_
zR+}G|^rp7h$4aTVsv_PfAx!}+;$^*hRtVkrazyXm2@h52j=>Y7z#4gIh0)pPdlQ1%
zr{||)hncSgxfm)0xiH5P#3Z`WS-b(??moGmJjaxW;a_ov8OhsN?RjF<;0VM=D54||
z!sI>9tckGuK!JRkJ$9ryOp;q=(0bICpuiy&O%m%9ol&GAHJf3u1YpgzFt-}`LlUnk
z>kXJn{HlnRk1^EWC4=c*p;3_csnP+?8STGUfbu+8xT0Ap8<_BluU~r*)Z1NRE6jHl
z$wr_wt_VKaVD;%pHt2ks6Oio~xEsfiayU5SQ23alrG*m++|1(_S2pxuBGx1y3UrZi
zi|WJ^BO@3|ZHIkMd;QsZdo-OJUXGGpfI_4_Bne5JCh&Np^2H{A5h=JBF~w)u+;_$B
zqa>i3=0<0&SD>6l+)X99J=x^SQ=J}r3gK#A<_eVF
zrBE5L^ru6bdi?ONKlj>}?O|$cImppUCzdvN=uGlpsHE;MZOcF^v8Z5)4%Kv&1X7$K
z?-9HIMkZZoZTY|)4_PT|R_dBq@~|&hHRg`ZYVsx|_6OjJi@watTMEOT3Elx5u!P8g
z`z(<8N{P8)HvV?7&5#2OMz)@W*udk
zZ>(5n!xH?xCnT(+4X>9(x#@!}f#B3~Y)yG+9xQ6XGhC?aa~2v&S?{b*?{-YH2&9W?h{$X9W3We|8&ZME}Q&g@hm`sj+dTp4fvBn_N9~o`N&JZhtK)?
z6fg1o4^{T2s7)hy+yqZ71vHc{?NkMkR=NLX(HYdiZG0lH4Y6
z9-!tIlnM+*@Kwe_*qTpz!Ty3sYVMFC8<2pgHdEY(vj~qd
zjHk0kqci9>eq?f`Vsm8a#M@B~nGVvPRUYC@4vH4u4z<;#Mt45EfemaAmTm4(6;2k_
z4#npDy^ahk^=Dww$(6kjs}(3bB;B}b_wO{kaN=k?n%YSK+NV)
zmfk)?s^uRQUI#RJuR&~`I44CaJKj@kDJ5Df0;%h!_w$rI#**;{!s{L@Fq8*p({SLZ
z_f}gf&xI}A(ezDIF1^-`njQnPRnuQG5_+T(jkU$TMC2BgTx&bcL7yotdDZl*m?LAa
zp~jV|#;zw{52n~xp*zWSlLb<1<=ar|WD+v%>}L>#p+oAz6ud2Eo%1YD7mOm;{g8FB
z5WNdINnw}H;B)F7Uhva8?f{lmYcpg;->sMi@1HmZnA!DwGQ@zqu{Y6_eG9v39Q5MZ
zBo|!rHBOE^ZX#JapXFM{54
z*}_$M#fr=en~%w?$!C6?tgf9@0L3DNlqXNij|nB*J1g%JJmgf>%f%-$Rmce;wTV9Z
zm+`%Hl7=*o%zs!h8kqQRQJOB@J?+Gmw{pZ~45g`DUg8$#%&?IR)<0$~KtlX=hvqa&9JH4-4_lo}&Qk~xGaBL?2W
zFQto@8EHTj=j3zvSe8tBn$9HqBUtG=DqF%fNow4j>a$P;XI`YKH^~u
zOnN*3ahqwL1abb!RY<4}hBSST)Qr!nt&V{43)W#meB@um^Vbk`fCL$B
zJl^b#H#7eQVap~K*cEj2$u}8mIu_r*hJZ4e_MpGbIrmzX`rvkn;aMInx7bu08r=v~
zVy93IMJuINW|qROJuAWC3SiilcC>F9(Yx7&(6ns4=pLS|!kXxyY;&5&5nJX(D$(aq
z546F7@CZ&+O