Merge origin/main into feat/keithley-2400
CI for csaxs_bec / test (pull_request) Successful in 3m22s
Read the Docs Deploy Trigger / trigger-rtd-webhook (push) Successful in 2s
CI for csaxs_bec / test (push) Successful in 3m30s

Brings the LamNI beamtime-2 line (fixes/lamni_during_beamtime_2, contained in
this branch) and the Keithley 2400 work up to main so it can go in by
fast-forward. Conflicts resolved:
- omny_general_tools.py: keep stitch_tile_offsets/lamni_smallest_tile_position_count
  (branch) and read_escape_suffix (main)
- flomni.py: import both helpers; state keeps recent_projection_gaps and tomo_completed
- tomo_params.py: _job_tooltip(job, registry=None, profile=None) combines the
  branch's command-step formatting and main's per-profile field filtering;
  call site passes both by keyword
- test_rt_flomni.py: keep both new tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #348.
This commit is contained in:
Mirko HollerandClaude Opus 5.5 committed 2026-11-03 09:25:35 +01:00
commit ffcc879d42
84 files changed
+11021 -1568

No files matched your search

@@ -0,0 +1,37 @@
# TODO: port the flOMNI `rt_*` naming to LamNI and OMNY
**Status: done on flOMNI (2026-10-26)**, not yet on LamNI and OMNY. Goal: `<endstation>.rt_<TAB>` lists the whole
real-time system, and the names match across all three endstations.
## flOMNI scheme (reference)
| Function | flOMNI name | former name |
| --- | --- | --- |
| BEC device enable rtx/rty/rtz | `rt_on()` / `rt_off()` | unchanged |
| feedback | `rt_feedback_enable_with_reset()`, `rt_feedback_enable_without_reset()`, `rt_feedback_disable()`, `rt_feedback_status()` | `feedback_*` (kept as aliases) |
| laser tracker | `rt_laser_tracker_on()`, `rt_laser_tracker_off()`, `rt_laser_tracker_show_all()`, `rt_laser_tracker_tweak()` | `laser_tracker_*`, `laser_parameters_tweak_tracking` |
| signal strength | `rt_signal_strength()`, alias `show_signal_strength_interferometer()` | `show_signal_strength_interferometer` |
| loop settings (staff) | `rt_settings` incl. `tracking_show/set_target_y/set_target_z/set_interferometer_threshold/set_psd_threshold_low` | `realtime_system`, `laser_parameters_*` |
## LamNI (`plugins/LamNI/lamni.py`)
- `rt_on` / `rt_off` already exist (line ~774).
- `feedback_enable_with_reset`, `feedback_enable_without_reset`, `feedback_disable`, `feedback_status`
-> `rt_feedback_*` (keep the old names as aliases, as on flOMNI); also `feedback_disable_and_reset_angle` -> `rt_feedback_disable_and_reset_angle`.
- `show_signal_strength` -> `rt_signal_strength` (keep the old name as an alias).
- LamNI has no laser tracker and no `rt_settings` (no web HMI loop settings).
- Callers: `plugins/LamNI/x_ray_eye_align.py` and `plugins/omny/x_ray_eye_align.py` (the LamNI one calls
`rtx.controller` directly), LamNI tomo/optics mixins, `docs/user/ptychography/lamni.md`, tests with
`feedback_*` mocks (`test_lamni_optics_skip_if_in_position.py`).
## OMNY (`plugins/omny/omny.py`, `plugins/omny/omny_rt.py`)
- `rt_on` / `rt_off` already exist (`omny.py` ~2166).
- `omny_rt.py`: `feedback_*` -> `rt_feedback_*` (old names as aliases), `laser_tracker_on/off/show_all` -> `rt_laser_tracker_*`,
`laser_tracker_check_and_wait_for_signalstrength` -> `rt_laser_tracker_check_and_wait_for_signalstrength`,
`show_signal_strength_interferometer` -> `rt_signal_strength` + alias.
- Callers: OMNY tomo/alignment code, `docs/user/ptychography/omny.md`, `test_omny_tomo_scan.py`
(`omny.laser_tracker_on` mock), `test_omny_optics_skip_if_in_position.py`.
Do not rename the controller methods (`dev.rtx.controller.feedback_*`, `laser_tracker_*`): only the
client-level user commands change.
@@ -85,6 +85,26 @@ def lamni_smallest_tile_position_count(
return counts[tile], tile
def read_escape_suffix(stream, timeout: float = 0.05) -> str | None:
"""The two characters after an ESC on a non-blocking stdin (arrow keys: "[A".."[D"),
or None if they do not arrive within `timeout` (ESC alone, or a slow terminal).
A plain stream.read(2) can return fewer characters, or None, before the rest of the
sequence has arrived; unpacking that raised and ended the tweak with a traceback."""
chars = ""
deadline = time.monotonic() + timeout
while len(chars) < 2 and time.monotonic() < deadline:
try:
got = stream.read(2 - len(chars))
except (IOError, TypeError):
got = None
if got:
chars += got
else:
time.sleep(0.005)
return chars if len(chars) == 2 else None
class OMNYTools:
HEADER = "\033[95m"
@@ -195,9 +215,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":
@@ -214,10 +235,10 @@ class OMNYTools:
self, dev1, step1: float, dev2="none", step2: float = "0", special_command="none"
):
if dev1 not in dev.enabled_devices:
print(f"Device 1 {dev} is not in enabled devices.")
print(f"Device 1 {dev1} is not in enabled devices.")
return
if dev2 not in dev.enabled_devices and dev2 != "none":
print(f"Device 2 {dev} is not in enabled devices.")
print(f"Device 2 {dev2} is not in enabled devices.")
return
# Save the current terminal settings
fd = sys.stdin.fileno()
@@ -237,7 +258,8 @@ class OMNYTools:
print("\n\rExiting tweak mode\r")
break
elif key == "\x1b": # Escape sequences for arrow keys
next1, next2 = sys.stdin.read(2)
suffix = read_escape_suffix(sys.stdin)
next1, next2 = suffix if suffix else ("", "")
if next1 == "[":
if next2 == "A":
if dev2 != "none":
@@ -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
@@ -246,6 +251,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})
# tomo_parameters() GUI window: subclasses set a unique window name (a valid
# Python identifier, e.g. "lamni_tomo_params") and their own error class.
_TOMO_PARAMS_WINDOW_NAME: str = ""
@@ -975,7 +987,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(
@@ -1112,10 +1131,10 @@ 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, 3, or 4 = angle-file index) -- 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); for
a tomo_type-4 angle-file job (flOMNI/LaMNI) it is the angle-file index.
Raises:
TomoQueueError: ``job_index`` is out of range, the job at that
@@ -1149,7 +1168,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
@@ -1191,6 +1210,8 @@ class TomoQueueMixin:
self.progress["accumulated_idle_time"] = 0.0
self.progress["recent_projection_gaps"] = []
self.progress["heartbeat"] = None
# flOMNI: a completed scan is not resumed (tomo_scan_resume); reopened here on purpose
self.progress["tomo_completed"] = False
job_id = job["id"]
self._tomo_queue_proxy.update_by_id(job_id, status="incomplete")
@@ -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
@@ -0,0 +1,204 @@
# 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 (corrected 2026-10-23: "move-ahead" → **move-beyond**, see the section at the end).
```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"
```
Naming: "move ahead" in the text above (sample PIDs `FAST_FZP_PID_LOOP(0)` < 0) is called **move-beyond** from
2026-10-23 on: the sample stage moves *beyond* the scan point towards the next one, the FZP compensates and so sits
beyond its centre during the exposure, on the side that gives it more stroke for the next step. It is not a
"pre-move" of the FZP to the next point (that is `TargetSelect` `use_final` 1, see below).
## 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-beyond (negative, µm, point-by-point
scans only; section "Move-beyond" below), (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 (parameters
`ontarget_tolerance_um` / `ontarget_hold_ms`, BEC `flomni.rt_settings.trigger_*`, default 10 nm / 3 ms; until
2026-10-23 ConstEmitter lines) 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-beyond: −0.5 µm is larger than the ±1.5 V FZP stroke, x ≈ ±0.19 µm, y ≈ ±0.08 µm) |
| `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`; `pid_priority` | 1,1; ±1.5 V (as the FZP `Ub` / `Lb`; `ff_limit` unused with `pid_priority` 1); 1,1 (since 2026-10-26, with `PID_*_fzp` `antiwindup` 1) |
| `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-beyond, sized to the FZP stroke (≈ half of it, see below): at ±7 V (x ≈ ±0.9 µm, y ≈ ±0.36 µm)
`PID_x` (0) e.g. −0.5, `PID_y` (0) e.g. −0.2 (the values tested in `feedback_report.md` 6q).
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.
## With the loop settings (since 2026-10-23)
The steps above are profiles of the loop settings (web HMI tab *Loop settings*, BEC `flomni.rt_settings`;
flOMNI `FeedbackLoop/loopsettings/`). The rules keep the modules consistent, so the individual parameters above do
not have to be set by hand:
| Step | Profile | BEC |
|---|---|---|
| 1, FZP disabled (alignment, affine matrix) | `fzp_off` | `flomni.rt_settings.apply_profile("fzp_off")` |
| 2, FZP-only calibration scan (clear `MatrixCorr` first: not part of the settings) | `fzp_calibration` | `apply_profile("fzp_calibration")` |
| 3, back to operation | `production` | `apply_profile("production")` |
| afterwards, fast scans with the FZP at ±9 V (scan modes, flOMNI `FeedbackLoop/scan_modes.md`) | `high_res`, `normal`, `low_res` | `apply_profile("high_res")` |
Single values: `flomni.rt_settings.set(fzp_range_V_x=7, fzp_range_V_y=7)`,
`set(move_beyond_um_x=0.3)`, `set(exposure_during_motion=1)`; refused with the reason if a rule objects
(`adjust=True` changes the dependent settings too). FZP settings only between scans. Live only until
`save_as_default()`.
## Move-beyond (`PID_x` / `PID_y` `FAST_FZP_PID_LOOP(0)` < 0), 2026-10-23
**What it does** (`PID.cpp`, unchanged since the original loop). Only in point-by-point scans (scan state 2). The
TrajectoryGenerator gives the next scan point (outputs 5 / 6, PID input 9). The sample PID then aims not at the
target T but at T + m · sign(next − T), m = min(|FAST_FZP_PID_LOOP(0)|, |next − T|): the sample stage moves *beyond*
the point towards the next one, by at most the step. The FZP loop keeps its own target T (it does not see m), so the
FZP compensates the offset: during the exposure it holds the relative position at T while sitting at +m off its
centre. At the next point the FZP then has stroke R + m in the step direction instead of R (R = stroke from the centre),
so larger steps are taken by the FZP alone. Steps smaller than |FAST_FZP_PID_LOOP(0)|: the sample stage is one point
ahead, the FZP moves between 0 and +step only. The offset is not shaped (InputSlew acts on T only); the sample stage
moves to it with its own (slow) loop, i.e. partly during the exposure, and the FZP compensates that motion.
**Is it still the same in the new loop?** Yes. The relative position is computed as before:
FZP error = Cap − (Chan − target) (mode 1), `Position sample vs FZP` = Chan − Cap (PID output 3, RT_sampler). The
sample PIDs still get the shaped target (InputSlew) + move-beyond and the raw interferometer. The FZP side now has
two additions that act on the FZP side only: `TargetSelect` (`use_final` 0 = shaped target as before, 1 = final
target) and `AbbeCorrection` (Chan corrected for the stage tilt, see below). The `Feedforward` uses the same FZP
target and Chan, so it supplies the +m offset of the FZP directly (static term m / K).
**Sizing.** During the exposure the FZP has to hold m + Abbe correction + noise / settling inside its stroke:
|m| ≤ R − reserve, as a rule ≈ R / 2. R per axis = `Ub` × K (K ≈ 0.13 µm/V x, ≈ 0.05 µm/V y). The loop settings
refuse more than 0.6 × R minus the Abbe limit (rule `move_beyond_*_fits`, constant `move_beyond_fraction`; the
tested ±7 V values are 0.56 × R):
| FZP `Ub`/`Lb` | R x / y | move-beyond x / y (≈ R/2) |
|---|---|---|
| ±1.5 V (production) | ±0.19 / ±0.08 µm | ≈ −0.1 / ≈ −0.04 µm (production: 0) |
| ±7 V (after calibration) | ±0.9 / ±0.36 µm | −0.5 / −0.2 µm (tested, 6q) |
The original default −0.5 µm in both axes dates from the ±9 V limits of the old loop (x ≈ ±1.2 µm, about R/2; in y
±9 V ≈ ±0.45 µm, i.e. about the full stroke). When the FZP saturates while holding the offset, the relative error grows and the trigger
waits.
**Abbe correction.** `AbbeCorrection` adds a new term to the measured sample x / y on the FZP side only:
x = Chan2_x + θz · offset_y_for_x, y = Chan1_y + θz · offset_x_for_y (θz = Chan3 / 13 mm). So yes: it is an error of
the sample x / y position (stage tilt about z, not seen by the x / y interferometer beams) that is corrected by the
FZP x / y, not by the sample stage (`PID_x` / `PID_y` keep the raw Chan, the rotZ piezo is too slow). The positions
recorded for the reconstruction (`Position sample vs FZP`) contain it as well. It uses FZP stroke (limited to
`correction_limit_um` 0.1 µm per axis; y at rest ≈ 2 nm, ≈ 30–37 nm during 20 µm x steps) — keep this in the reserve
of the move-beyond, in y it is a large part of the ±0.08 µm stroke. Keep it off (`enable` 0) during the FZP
calibration scan (step 2).
**Notes.**
- Hold of the sample stage, (4) / (5): input 8 of `PID_x` / `PID_y` was the FZP PID output *before* the
`Feedforward` (with the feedforward on only the remainder, not the real FZP drive). Since 2026-10-23 it is the
`Feedforward` output (FZP drive incl. feedforward), so (5) again compares the real FZP drive. No change with the
feedforward off or with (5) = 0 (production).
- On the last point of a scan the next point output is 0, so the move-beyond points towards 0 (as in the old loop).
- `TargetSelect` `use_final` 1 is a different thing: the FZP aims at the new point at once while the sample stage
follows the S-curve. Both can be combined (6q, E0).
@@ -0,0 +1,48 @@
# How to limit the FZP scanner range of flOMNI
Source of `local_contact_only-howto_limit_the_fzp_scanner_range.pdf`. The original text is kept unchanged; the
section "Update 2026-10-20" lists what the present flOMNI loop (PREEMPT_RT, 100 µs, branch `rt_preemt`) needs in
addition. "ahead movement" (element 0) is called **move-beyond** from 2026-10-23 on: the sample stage moves beyond the
scan point towards the next one, the FZP compensates and sits off-centre, which gives it stroke for the next step.
```text
to limit the fzp scanner ranges
in PID_ FAST FZP PID LOOP
0: reduce to small ahead movement range, such as -0.05
4: reduce error threshold, eg 0.01
5: inform PID that FZP range is small, such as 0.1
in PID_ _FZP
limit the analog output LB and UB
```
## Update 2026-10-20 (flOMNI loop on PREEMPT_RT, mpc2844)
The limited range is the present operating state: `PID_x_fzp` / `PID_y_fzp` `Ub` / `Lb` = ±1.5 V because the cap
sensors are calibrated over about ±2 V only (flOMNI `feedback_report.md` 6n). FZP ≈ 0.13 µm/V in x, ≈ 0.05 µm/V in y,
so ±1.5 V ≈ ±0.19 µm (x) / ±0.08 µm (y). In the sample stage PIDs (`PID_x`, `PID_y`) `FAST_FZP_PID_LOOP` is
`0,0,0,0,0.1,0,0`: no move-beyond, (5) = 0 (the sample stage always compensates fully).
Changing the FZP range now involves, in addition to the text above:
- **`Feedforward`** (static FZP feedforward): `ff_limit`, `out_min` / `out_max` to the same values as the FZP `Ub` /
`Lb` (they limit drive + feedforward). Since 2026-10-26 (`fzp_ff_priority` 1, `pid_priority` 1,1) `ff_limit` is not
used: the feedforward has the range first, the FZP PID limits follow `Ub` / `Lb` − feedforward (flOMNI
`FeedbackLoop/scan_modes.md`); `out_min` / `out_max` still limit the sum.
- **`FreqGen`** `out_min` / `out_max` (channels 0 / 1) limit the FZP drive only while an excitation is running
(measurements).
- **`OnTargetTrigger`** `WaitForSlew` and **`TargetSelect_x` / `_y`** `use_final`: keep the defaults (1 / 0) with a
small range. `use_final` 1 + `WaitForSlew` 0 ("exposure during the motion") only makes sense when the FZP range
covers the scan step; steps larger than the range then still wait for the sample stage.
- Move-beyond (0) must fit into the stroke: the FZP holds it during the exposure, together with the Abbe correction
(up to `correction_limit_um` 0.1 µm when enabled) and noise. Rule ≈ half the stroke (the loop settings refuse more than 0.6 × stroke minus the Abbe limit): at ±1.5 V ≈ −0.1 µm (x),
≈ −0.04 µm (y); at ±7 V −0.5 / −0.2 µm (tested). Production: 0. Details: calibration how-to, section "Move-beyond".
- (5) compares the real FZP drive incl. the feedforward (input 8 of `PID_x` / `PID_y` = `Feedforward` output since
2026-10-23; before it was the FZP PID output without the feedforward).
- After a change: web HMI tab **Loop check** level 1 (expected settings) and, with the beamline ready, the scan test
(level 4: positioning time, stability during the exposure per step size).
**With the loop settings (since 2026-10-23):** `flomni.rt_settings.set(fzp_range_V_x=..., fzp_range_V_y=...)`
(web HMI tab *Loop settings*) sets `PID_*_fzp` `Ub` / `Lb` and the `Feedforward` limits together; a move-beyond that
no longer fits is refused, or clamped with `adjust=True` (rule `move_beyond_*_fits`). `FreqGen` limits stay
manual (measurements only).
@@ -0,0 +1,101 @@
# flOMNI real-time loop settings (beamline scientists / local contact only)
**Not for users.** `flomni.rt_settings` changes how the FZP scanner, the detector trigger and the laser tracker
work, and `save_as_default()` makes a change the default of every following loop start. A wrong combination can spoil
the data of a beamtime. The user guide only says that these settings exist, are set by the local contact and are
stored with every scan; the details are here.
Background and reference (flOMNI control repository, `OMNY/flOMNI` on gitea):
- `FeedbackLoop/loopsettings/README.md` — every setting with the loop parameters it sets and its production value,
the rules, the profiles; `FeedbackLoop/loopsettings/flomni.json` — the definition (help texts, rules, profiles)
- `FeedbackLoop/tuning_guide.md`, `FeedbackLoop/feedback_report.md` — characterization, measurements, why the values
- RT-system `docs/loop-settings.md` — design, the web HMI side, installation
- the FZP how-tos next to this file (`local_contact_only-howto_calibrate_the_fzp_scanner.md`,
`local_contact_only-howto_limit_the_fzp_scanner_range.md`) — move-beyond, FZP range, calibration steps as profiles
## What can be set
| Group | Settings |
|---|---|
| FZP scanner (only between scans) | `fzp_enable` (both axes or none), `fzp_range_V_x` / `_y`, `feedforward_enable`, `fzp_ff_priority` (feedforward first, 1 in production and all scan profiles since 2026-10-26), `move_beyond_um_x` / `_y`, `exposure_during_motion`, `abbe_enable`, `abbe_limit_um` |
| Sample stage moves (only between scans) | `target_shaper_scale`, `jump_max_um_x` / `_y` |
| Detector trigger | `trigger_tolerance_nm`, `trigger_hold_ms`, `trigger_min_delay_ms`, `trigger_on_move` |
| Trigger source (only between scans) | `trigger_soft` (never saved, set by the Fermat scan), `soft_exposure_ms`, `soft_latency_ms` |
| Laser tracker and thresholds | `tracking_target_y` / `_z`, `psd_threshold_low`, `interferometer_threshold`, `interferometer_threshold_rot` |
| FZP calibration (expert, only between scans) | `sample_hold_fzp_V`, `sample_hold_err_um`, `sample_hold_zero` |
`flomni.rt_settings.help("all")` explains every setting, `help("<setting>")` one setting (what it does, range,
live and saved value, the loop parameters it sets, its rules).
## Commands
- `flomni.rt_settings.show()` — all settings with live and saved value, violated rules, scan state
(`show(expert=True)` also the FZP calibration group)
- `flomni.rt_settings.set(name=value, ...)` — change; refused with the reason if a rule objects (e.g. the
feedforward must be off while the FZP loop is off); `set(..., adjust=True)` changes the dependent settings the
rule names (never the ones given)
- `flomni.rt_settings.check(name=value)` — dry run
- `flomni.rt_settings.profiles()`, `apply_profile("production" | "fzp_off" | "fzp_calibration" | "high_res" | "high_res_1v5" | "normal" | "low_res")` (scan modes 2026-10-26: flOMNI `FeedbackLoop/scan_modes.md`)
- `flomni.rt_settings.unsaved()` — what differs from the loop's parameter files
- `flomni.rt_settings.save_as_default()` — writes the live values into the loop's parameter files: the next
loop start uses them. Only for a change that should stay (a backup of each file is kept on the loop machine).
- `flomni.rt_settings.help()`, `help("all")`, `help("<setting>")`
Changes are live only until the next start of the control loop, unless saved. FZP settings can only be changed
between scans (refused while a scan runs); trigger and tracker settings any time.
### Detector trigger
In step scans the detector is triggered when the relative position sample vs FZP (interferometer minus FZP cap
sensor) stays within the tolerance in x and in y (each averaged over 0.5 ms) for the hold time.
- `flomni.rt_settings.trigger_show()` — tolerance, hold time, minimum delay, trigger after a move
- `flomni.rt_settings.trigger_set_tolerance_nm(10)` — about 30 for a fast low-resolution scan, 10 standard,
2 for the highest resolution (a few ms more per point); 0 = the former criterion (on-target flags of the FZP loops)
- `flomni.rt_settings.trigger_set_hold_ms(3)` — hold time in ms
- `flomni.rt_settings.trigger_set_after_move(True)` — also one trigger after every position change of the
sample stage (e.g. `umv(dev.rtx, ...)`, `dev.rtx.controller.move_xyz(x, y, z)`) once on target; `False` (default):
scan points only
The former `flomni.interferometer_trigger_*` were removed 2026-10-23.
### Soft trigger (working without detector)
With the soft trigger no trigger leaves the loop (the detector trigger output stays 0) and the loop emulates the
detector: every trigger starts an exposure of `soft_exposure_ms` after `soft_latency_ms` (1 ms). Scans then run
without detector or trigger PLC. The loop always starts with the hardware trigger; `trigger_soft` is never saved as
default and is in no profile (flOMNI `FeedbackLoop/soft_trigger.md`).
**`flomni_fermat_scan` sets the trigger source at every scan start** (2026-10-26):
- **At the beamline → hardware.** At the beamline means: `ddg1` is configured with a real (not simulated) device
class (only `ddg1` decides, 2026-10-27). If this cannot be confirmed (web HMI not reachable, refused) the scan is
aborted: with the soft trigger the detector would not be triggered while flOMNI exposes internally. A failing check
also counts as beamline.
- **Otherwise (simulated or no `ddg1`) → soft** with `soft_exposure_ms` = `exp_time` × `frames_per_trigger` × 1000. If that fails the scan continues with a warning (alarm).
By hand: `flomni.rt_settings.trigger_set_soft(True, exposure_ms=100)`, `trigger_set_soft(False)`;
`trigger_show()` shows both groups.
### Laser tracker
`flomni.rt_settings.tracking_show()`, `tracking_set_target_y(v)` / `tracking_set_target_z(v)`,
`tracking_set_interferometer_threshold(v)`, `tracking_set_psd_threshold_low(v)` and the arrow-key tweak
`flomni.rt_laser_tracker_tweak()` use the same settings (`tracking_target_y` / `_z`, `interferometer_threshold`,
`psd_threshold_low`). The former `flomni.laser_parameters_*` were moved here 2026-10-26.
## Stored with every scan
The baseline device `flomni_loop_settings` is read at the start of every scan: one value per setting,
`available` (0 when the loop machine could not be reached — the scan continues), the number of settings not saved
as default or inconsistent, violated error rules, the definition with its sha1, and a JSON of all settings.
`dev.flomni_loop_settings.show()` prints what a scan would store now.
## Connection
`flomni.rt_settings` sends its requests through the device `flomni_loop_settings` (device server). The device
talks to the web HMI on the loop machine (`http://mpc2844:8080`, REST `/api/settings`; port 80 as fallback) with the
API token in its device config (`ptycho_flomni.yaml`, `token:`; the line of `~/loop-data/webhmi/api_token` on
mpc2844). Without that device (e.g. a simulated config) the client talks to the web HMI directly with
`~/.flomni_webhmi_token`. The web HMI tab *Loop settings* shows and changes the same settings with the same rules.
@@ -17,6 +17,7 @@ from typeguard import typechecked
from csaxs_bec.bec_ipython_client.plugins.cSAXS import cSAXSBeamlineChecks
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin import FlomniOpticsMixin
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_realtime_system import FlomniRealtimeSystem
from csaxs_bec.bec_ipython_client.plugins.flomni.gui_tools import flomniGuiTools
from csaxs_bec.bec_ipython_client.plugins.flomni.x_ray_eye_align import XrayEyeAlign
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import (
@@ -41,10 +42,10 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import
PtychoReconstructor,
TomoIDManager,
stitch_tile_offsets,
read_escape_suffix,
)
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
@@ -84,7 +85,8 @@ class FlomniInitStagesMixin:
sensor_voltage_target = dev.ftransy.user_parameter.get("sensor_voltage")
sensor_voltage = float(dev.ftransy.controller.socket_put_and_receive("MG@AN[1]").strip())
if not np.isclose(sensor_voltage, sensor_voltage_target, 0.25):
# absolute tolerances (V, mm): the third positional argument of np.isclose is rtol
if not np.isclose(sensor_voltage, sensor_voltage_target, rtol=0, atol=0.25):
print(
f"Sensor voltage of the gripper is {sensor_voltage}, while target from config is {sensor_voltage_target}"
)
@@ -412,7 +414,7 @@ class FlomniSampleTransferMixin:
self.ensure_osa_back()
fsamx_in = dev.fsamx.user_parameter.get("in")
if not np.isclose(dev.fsamx.readback.get(), fsamx_in, 0.2):
if not np.isclose(dev.fsamx.readback.get(), fsamx_in, rtol=0, atol=0.2):
raise FlomniError("fsamx not in position. Aborting.")
fheater_in = dev.fheater.user_parameter.get("in")
@@ -495,37 +497,25 @@ class FlomniSampleTransferMixin:
self.move_fheater_down()
self.xrayeye_update_frame()
def laser_tracker_show_all(self):
def rt_laser_tracker_show_all(self):
dev.rtx.controller.laser_tracker_show_all()
def laser_tracker_on(self):
def rt_laser_tracker_on(self):
dev.rtx.controller.laser_tracker_on()
time.sleep(0.2)
dev.rtx.controller.laser_tracker_check_signalstrength()
def laser_tracker_off(self):
def rt_laser_tracker_off(self):
dev.rtx.controller.laser_tracker_off()
def laser_parameters_show_all(self):
dev.rtx.controller.emitter_show_all()
# laser tracker targets and thresholds: settings of the real-time loop, flomni.rt_settings.tracking_*
# (the former flomni.laser_parameters_* moved there 2026-10-26)
def laser_parameters_set_targety(self, val: float):
dev.rtx.controller.emitter_set_tracking_target_y(val)
def laser_parameters_set_targetz(self, val: float):
dev.rtx.controller.emitter_set_tracking_target_z(val)
def laser_parameters_set_intensity_threshold_laser(self, val: float):
dev.rtx.controller.emitter_set_intensity_threshold_laser(val)
def laser_parameters_set_psd_intensity_threshold_tracking_low(self, val: float):
dev.rtx.controller.emitter_set_psd_intensity_threshold_tracking_low(val)
def laser_parameters_tweak_tracking(self):
def rt_laser_tracker_tweak(self):
"""Interactive tweak of the two laser tracking target positions.
Arrow keys nudge the ConstEmitter tracking targets by a fixed step of
0.01 (absolute set of current +/- step):
Arrow keys nudge the tracking targets (rt_settings tracking_target_y / _z) by a fixed step of
0.02 (absolute set of current +/- step):
left / right : target y -/+
up / down : target z +/-
Keys:
@@ -545,10 +535,11 @@ class FlomniSampleTransferMixin:
import tty
controller = dev.rtx.controller
rts = self.rt_settings
if not controller.laser_tracker_check_enabled():
print(
"The laser tracker is disabled. Enable it with laser_tracker_on() "
"The laser tracker is disabled. Enable it with rt_laser_tracker_on() "
"before tweaking the tracking targets - otherwise nothing moves on "
"the hardware side."
)
@@ -557,14 +548,10 @@ class FlomniSampleTransferMixin:
step = 0.02
def read_targets():
values = controller.emitter_get()
return (
values["targetpos_tracking_y"],
values["targetpos_tracking_z"],
)
return rts.tracking_targets()
def signal_fzpx():
# second channel (index 1) of show_signal_strength_interferometer,
# second channel (index 1) of rt_signal_strength,
# i.e. the fzp x channel / tracker signal
return controller.read_ssi_interferometer(1)
@@ -600,25 +587,25 @@ class FlomniSampleTransferMixin:
print("\n\rExiting tweak mode\r")
break
elif key == "s":
controller.emitter_set_tracking_target_y(start_y)
controller.emitter_set_tracking_target_z(start_z)
rts.set(tracking_target_y=start_y, tracking_target_z=start_z, quiet=True)
print("\rReturned to start positions\r")
print_status()
elif key == "\x1b": # escape sequences for arrow keys
next1, next2 = sys.stdin.read(2)
suffix = read_escape_suffix(sys.stdin)
next1, next2 = suffix if suffix else ("", "")
if next1 == "[":
target_y, target_z = read_targets()
if next2 == "A": # up: target z +
controller.emitter_set_tracking_target_z(target_z + step)
rts.set(tracking_target_z=target_z + step, quiet=True)
print_status()
elif next2 == "B": # down: target z -
controller.emitter_set_tracking_target_z(target_z - step)
rts.set(tracking_target_z=target_z - step, quiet=True)
print_status()
elif next2 == "C": # right: target y +
controller.emitter_set_tracking_target_y(target_y + step)
rts.set(tracking_target_y=target_y + step, quiet=True)
print_status()
elif next2 == "D": # left: target y -
controller.emitter_set_tracking_target_y(target_y - step)
rts.set(tracking_target_y=target_y - step, quiet=True)
print_status()
except IOError:
pass
@@ -631,14 +618,14 @@ class FlomniSampleTransferMixin:
def umvr_fsamy_tracked(self, shift: float, step: float = 0.01, _internal: bool = False):
"""
Relative move of fsamy by `shift` mm, broken into steps of at most
`step` mm (default 0.01 mm = 10 um), calling laser_tracker_on()
`step` mm (default 0.01 mm = 10 um), calling rt_laser_tracker_on()
after every step so the tracker stays locked during the move
(fsamy travels a lot during sample change and the tracker can't
keep up in one big jump). fsamy itself moves in mm, so shift/step
are taken directly in mm -- no unit conversion needed.
Uses dev.rtx.controller.laser_tracker_on() directly rather than
self.laser_tracker_on() (which adds a 0.2s sleep plus a
self.rt_laser_tracker_on() (which adds a 0.2s sleep plus a
signal-strength check) since this runs once per small step and that
overhead would add up across what can be 100+ steps for a typical
sample-change-sized move; the goal here is just to keep the tracker
@@ -664,7 +651,7 @@ class FlomniSampleTransferMixin:
)
if not self.OMNYTools.yesno("Disable feedback and continue?", "y"):
return
self.feedback_disable()
self.rt_feedback_disable()
direction = 1 if shift >= 0 else -1
remaining = abs(shift)
@@ -692,27 +679,39 @@ class FlomniSampleTransferMixin:
shift_mm = target - current
self.umvr_fsamy_tracked(shift_mm, step, _internal=_internal)
def show_signal_strength_interferometer(self):
def rt_signal_strength(self):
dev.rtx.controller.show_signal_strength_interferometer()
def feedback_disable(self):
# the familiar name, kept as an alias
show_signal_strength_interferometer = rt_signal_strength
def rt_feedback_disable(self):
self.device_manager.devices.rtx.controller.feedback_disable()
def feedback_enable_with_reset(self):
def rt_feedback_enable_with_reset(self):
self.device_manager.devices.rtx.controller.feedback_enable_with_reset()
self.feedback_status()
self.rt_feedback_status()
def feedback_enable_without_reset(self):
def rt_feedback_enable_without_reset(self):
self.device_manager.devices.rtx.controller.feedback_enable_without_reset()
self.feedback_status()
self.rt_feedback_status()
def feedback_status(self):
def rt_feedback_status(self):
feedback_status = self.device_manager.devices.rtx.controller.feedback_is_running()
if feedback_status == True:
print("The rt feedback is \x1b[92mrunning\x1b[0m.")
else:
print("The rt feedback is \x1b[91mNOT\x1b[0m running.")
# the familiar names, kept as aliases
feedback_disable = rt_feedback_disable
feedback_enable_with_reset = rt_feedback_enable_with_reset
feedback_enable_without_reset = rt_feedback_enable_without_reset
feedback_status = rt_feedback_status
# detector trigger: flomni.rt_settings.trigger_* (the former flomni.interferometer_trigger_* removed
# 2026-10-23, Mirko: "interferometer" was confusing next to flomni.laser_*)
def lights_off(self):
self.device_manager.devices.fsamx.controller.lights_off()
@@ -725,7 +724,7 @@ class FlomniSampleTransferMixin:
self.flomnigui_raise()
target_pos = -162
if np.isclose(dev.fsamx.readback.get(), target_pos, 0.01):
if np.isclose(dev.fsamx.readback.get(), target_pos, rtol=0, atol=0.5):
return
umv(dev.fsamroy, 0)
@@ -733,7 +732,7 @@ class FlomniSampleTransferMixin:
self.feye_in()
self.foptics_out()
self.feedback_disable()
self.rt_feedback_disable()
self.ensure_fheater_up()
@@ -749,7 +748,7 @@ class FlomniSampleTransferMixin:
self.umv_fsamy_tracked(fsamy_in, _internal=True)
time.sleep(0.05)
self.laser_tracker_off()
self.rt_laser_tracker_off()
time.sleep(0.05)
@@ -765,7 +764,7 @@ class FlomniSampleTransferMixin:
sensor_voltage_target = dev.ftransy.user_parameter.get("sensor_voltage")
sensor_voltage = float(dev.ftransy.controller.socket_put_and_receive("MG@AN[1]").strip())
if not np.isclose(sensor_voltage, sensor_voltage_target, 0.5):
if not np.isclose(sensor_voltage, sensor_voltage_target, rtol=0, atol=0.5):
raise FlomniError(f"Sensor voltage is {sensor_voltage}, indicates an error. Aborting.")
def ftransfer_get_sample(self, position: int):
@@ -775,6 +774,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."
@@ -820,13 +820,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)
@@ -836,6 +834,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.")
@@ -879,9 +878,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()
@@ -891,18 +889,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()
@@ -1042,23 +1029,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:<slot>: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,
}
)
@@ -1071,21 +1057,17 @@ 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:
return
raise FlomniError(
f"The given position number [{position}] is not in the valid range of 0-21. "
f"The given position number [{position}] is not in the valid range of 0-20. "
)
def ftransfer_controller_enable_mount_mode(self):
@@ -1135,16 +1117,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
@@ -1665,6 +1656,8 @@ class _ProgressProxy:
"heartbeat": None,
"accumulated_idle_time": 0.0,
"recent_projection_gaps": [],
# set when tomo_scan() reached its end: tomo_scan_resume() then has nothing to resume
"tomo_completed": False,
}
def __init__(self, client):
@@ -1714,83 +1707,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,
@@ -1817,7 +1733,7 @@ class Flomni(
self.device_manager = client.device_manager
self.special_angles = []
self.special_angle_repeats = 20
self.special_angle_tolerance = 20
self.special_angle_tolerance = 0.5
self._current_special_angles = []
self._beam_is_okay = True
self._progress_proxy = _ProgressProxy(self.client)
@@ -1844,10 +1760,12 @@ class Flomni(
self._webpage_gen.start()
self.OMNYTools = OMNYTools(self.client)
# expert functions of the real-time loop (settings with rules, via the web HMI on the loop machine;
# flomni_realtime_system.py): flomni.rt_settings.show() / .set(...) / .help()
self.rt_settings = FlomniRealtimeSystem(device_manager=self.device_manager)
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)
@@ -1946,10 +1864,10 @@ class Flomni(
try:
self.align.align(keep_shutter_open)
except KeyboardInterrupt as exc:
fsamx_in = self._get_user_param_safe(dev.fsamx, "in")
if np.isclose(fsamx_in, dev.fsamx.readback.get(), 0.5):
fsamx_in = self._get_user_param_safe("fsamx", "in") # name: dev[...] lookup (a device raised)
if np.isclose(fsamx_in, dev.fsamx.readback.get(), rtol=0, atol=0.5):
print("Stopping alignment. Returning to fsamx in position.")
self.feedback_disable()
self.rt_feedback_disable()
umv(dev.fsamx, fsamx_in)
raise exc
@@ -2008,10 +1926,14 @@ class Flomni(
def set_special_angles(self, angles: list, repeats: int = 20, tolerance: float = 0.5):
"""Set the special angles for a tomo
The first projection of the tomogram within `tolerance` of a special angle is acquired
`repeats` times; each special angle is used once per tomogram (in any order), also across
tomo_scan_resume() in the same session.
Args:
angles (list): List of special angles
repeats (int, optional): Number of repeats at a special angle. Defaults to 20.
tolerance (float, optional): Number of repeats at a special angle. Defaults to 0.5.
tolerance (float, optional): Angle tolerance in degrees. Defaults to 0.5.
"""
self.special_angles = angles
@@ -2352,6 +2274,8 @@ class Flomni(
self.write_alignment_scan_numbers(bec.queue.next_scan_number)
start_angle = 0
alignment_scan_numbers = []
# first scan number of the accepted attempt per angle (a retry shifts the scan numbers)
alignment_first_scans = []
angle_end = start_angle + 180
for angle in np.linspace(start_angle, angle_end, num=int(180 / 45) + 1, endpoint=True):
@@ -2369,16 +2293,24 @@ class Flomni(
bec.queue.request_queue_reset()
time.sleep(2)
error_caught = True
print(f"Timeout at angle {angle}, repeating it (Ctrl-C to stop).")
else:
raise exc
# todo here was if blchk success, then setting to success true
successful = True
# repeat the angle after a timeout (it was skipped silently before, 2026-10-24)
successful = not error_caught
end_scan_number = bec.queue.next_scan_number
alignment_first_scans.append(start_scan_number)
for scan_nr in range(start_scan_number, end_scan_number):
# self._write_tomo_scan_number(scan_nr, angle, 0)
alignment_scan_numbers.append(scan_nr)
consecutive = [alignment_first_scans[0] + k for k in range(len(alignment_first_scans))]
if alignment_first_scans != consecutive:
# a retry (or stitching) shifted the scan numbers: rewrite the file with the accepted scans
self.write_alignment_scan_numbers(alignment_first_scans[0], scans=alignment_first_scans)
umv(dev.fsamroy, 0)
self.OMNYTools.printgreenbold(
"\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit by flomni.read_alignment_offset() ."
@@ -2398,13 +2330,14 @@ class Flomni(
print(scilog_content)
self._scilog_write(scilog_content, "alignmentscan")
def write_alignment_scan_numbers(self, first_scan):
def write_alignment_scan_numbers(self, first_scan, scans=None):
import os
file = os.path.expanduser("~/data/raw/logs/ptychotomoalign_scannum.txt")
os.makedirs(os.path.dirname(file), exist_ok=True)
scans = [first_scan + k for k in range(5)]
if scans is None:
scans = [first_scan + k for k in range(5)]
angles = [0, 45, 90, 135, 180]
x_vals = []
@@ -2682,10 +2615,16 @@ class Flomni(
# succeeded, so a retried attempt still sees the same special
# angle and repeat count.
num_repeats = 1
is_special_angle = bool(
self._current_special_angles
and np.isclose(angle, self._current_special_angles[0], atol=0.5)
# any remaining special angle within the tolerance (not only the next one in the list)
special_index = next(
(
k
for k, special in enumerate(self._current_special_angles)
if np.isclose(angle, special, rtol=0, atol=self.special_angle_tolerance)
),
None,
)
is_special_angle = special_index is not None
if is_special_angle:
num_repeats = self.special_angle_repeats
start_scan_number = bec.queue.next_scan_number
@@ -2695,7 +2634,7 @@ class Flomni(
self._at_each_angle(angle, **overrides)
projection_duration = time.perf_counter() - projection_start
if is_special_angle:
self._current_special_angles.pop(0)
self._current_special_angles.pop(special_index)
end_scan_number = bec.queue.next_scan_number
for scan_nr in range(start_scan_number, end_scan_number):
@@ -2712,8 +2651,8 @@ class Flomni(
)
def collect_empty_frames(self):
"""Acquire 10 empty-frame (flat-field) images at angle 0, with fsamx
shifted out of the beam by half the field of view (fovx/2), in the
"""Acquire 10 empty-frame (flat-field) images at angle 0, with the
sample moved 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).
@@ -2826,15 +2765,15 @@ class Flomni(
if self.OMNYTools.yesno("Shall I continue?", "n"):
print("OK")
else:
print("Stopping.")
return
# raise, not return: a return let tomo_queue_execute() mark the job "done" without data
raise FlomniError("Tomo scan stopped: the setup is not in measurement condition.")
all_out, offending = filters_out_of_beam(dev)
if not all_out:
warning = f"Not all filters are out of the beam: {', '.join(offending)}."
if not warn_and_confirm(self, warning, interactive=interactive, force=False):
print("Aborting tomo scan.")
return
# raise, not return (see above)
raise FlomniError(f"Tomo scan aborted: {warning}")
self.flomnigui_show_progress()
@@ -2844,7 +2783,9 @@ class Flomni(
bec.builtin_actors.scan_interlock.trigger_setting = "restart_scan"
bec.builtin_actors.scan_interlock.enabled = True
self._current_special_angles = self.special_angles.copy()
if is_new_scan or not self.special_angles:
# on a resume keep the special angles not yet acquired (they were taken again before)
self._current_special_angles = list(self.special_angles)
if is_new_scan:
# pylint: disable=undefined-variable
# Always attempt registration (even for an empty/test account)
@@ -2860,11 +2801,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",
account=bec.active_account or "",
)
self.write_pdf_report()
# estimated_remaining_time/estimated_finish_time/accumulated_idle_time/
@@ -2872,6 +2814,7 @@ class Flomni(
# self.progress.reset() above, before the gates -- only the actual
# start time is set here, once the scan is genuinely committed to.
self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
self.progress["tomo_completed"] = False
if file_angles is not None:
self._init_angle_file_progress(file_angles, projection_number)
self.collect_empty_frames()
@@ -2896,6 +2839,9 @@ class Flomni(
start_angle is None
and self._subtomo_starts_near_zero(ii)
and self.zero_deg_reference_at_each_subtomo
# new scan: the fixed 0-deg shot above and sub-tomogram 1's first
# projection (0 deg) are already the two at the start (Mirko 2026-10-24)
and not (is_new_scan and ii == 1)
):
# Dedicated reference shot at exactly 0 degrees, taken
# every time the rotation passes back through 0, for
@@ -3048,16 +2994,19 @@ class Flomni(
# (120s / 90s) after a tomogram that's actually already done.
self.progress["heartbeat"] = None
# all projections acquired: nothing left for tomo_scan_resume() (also if a step below fails)
self.progress["tomo_completed"] = True
self.progress["projection"] = self.progress["total_projections"]
self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"]
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",
account=bec.active_account or "",
)
timing_lines = self._build_timing_lines()
for line in timing_lines[3:]:
@@ -3100,6 +3049,10 @@ class Flomni(
if self.progress.get("tomo_start_time") is None:
print("No tomo scan in progress to resume -- nothing to do.")
return
if self.progress.get("tomo_completed"):
# it re-acquired the last projection (and repeated the end-of-scan steps) before, 2026-10-24
print("The last tomo scan was completed -- nothing to resume.")
return
if not from_queue and not self._confirm_resume_outside_queue():
print("Aborted.")
@@ -4109,7 +4062,7 @@ class Flomni(
if self.tomo_type == 1:
self.tomo_angle_range = self._get_val(
"Angular range (180 or 360)", self.tomo_angle_range, int
"Angular range (180 or 360)", self.tomo_angle_range, int, choices=(180, 360)
)
# Default shown here must be the actual achievable total
# (int(range/stepsize)*8), not the raw algebraic inverse of
@@ -4226,14 +4179,23 @@ class Flomni(
self._prompt_angle_file()
@staticmethod
def _get_val(msg: str, default_value, data_type, min_=None, max_=None):
def _get_val(msg: str, default_value, data_type, min_=None, max_=None, choices=None):
"""Prompt for a value, casting to data_type. When min_/max_ are
given, re-prompts (rather than silently clamping) until the typed
value is in range, mirroring the Tomo Parameters GUI's spinbox
bounds (tomo_params.py) so tomo_parameters() enforces the same
limits instead of accepting anything of the right type."""
limits instead of accepting anything of the right type. Also
re-prompts on a value that cannot be cast, or that is not one of
`choices` (when given)."""
while True:
val = data_type(input(f"{msg} ({default_value}): ") or default_value)
try:
val = data_type(input(f"{msg} ({default_value}): ") or default_value)
except ValueError:
print(f"Not a valid {data_type.__name__}. Please try again.")
continue
if choices is not None and val not in choices:
print(f"Value must be one of {', '.join(str(c) for c in choices)}. Please try again.")
continue
if min_ is not None and val < min_:
print(f"Value must be >= {min_}. Please try again.")
continue
@@ -88,8 +88,10 @@ class FlomniOpticsMixin:
if self.OMNYTools.yesno("Did the direct beam on the xray eye disappear?"):
print("excellent.")
else:
print("Aborting. With visible parts of the direct beam on the xray eye, it cannot be removed.")
return
# raise, not return: callers (tomo_alignment_scan) went on measuring with the eye in
raise FlomniOpticsError(
"Aborting. With visible parts of the direct beam on the xray eye, it cannot be removed."
)
feyex_out = self._get_user_param_safe("feyex", "out")
umv(dev.feyex, feyex_out)
@@ -104,8 +106,7 @@ class FlomniOpticsMixin:
"this could cause collisions when operating on the real "
"beamline."
):
print("Aborting feye_out(): fttrx1 not moved.")
return
raise FlomniOpticsError("Aborting feye_out(): fttrx1 not moved.")
else:
fttrx_in = self._get_user_param_safe("feyex", "fttrx_in")
umv(dev.fttrx1, fttrx_in)
@@ -119,8 +120,7 @@ class FlomniOpticsMixin:
"this could cause collisions when operating on the real "
"beamline."
):
print("Aborting feye_in(): fttrx1 not moved.")
return
raise FlomniOpticsError("Aborting feye_in(): fttrx1 not moved.")
else:
fttrx_out = self._get_user_param_safe("feyex", "fttrx_out")
umv(dev.fttrx1, fttrx_out)
@@ -420,7 +420,8 @@ class FlomniOpticsMixin:
print("\nOSA Information:")
print(f" Current fosaz {fosaz_val:.1f}")
print(f" The OSA will collide with a normal OMNY pin at fosaz \033[1m{(33-fosaz_val):.1f}\033[0m")
# collision where compute_osa_remaining_space() = 33 - foptz - fosaz reaches 0 (was 33 - fosaz)
print(f" The OSA will collide with a normal OMNY pin at fosaz \033[1m{(33-foptz_val):.1f}\033[0m")
print(f" Remaining space (right now): \033[1m{remaining_current:.1f}\033[0m")
diff = fosaz_val - fosaz_in # >0: OSA is currently more "in" than nominal -> worse
@@ -0,0 +1,309 @@
"""flomni.rt_settings: the operating settings of the flOMNI real-time loop.
BEAMLINE SCIENTISTS / LOCAL CONTACT ONLY - not for users. These settings change how the FZP scanner, the detector
trigger and the laser tracker work; a wrong combination can spoil a beamtime's data. The rules refuse combinations
that do not work, but not ones that are only unsuitable for an experiment.
The settings, the parameters they map onto and the rules between them live in the flOMNI instrument repository
(FeedbackLoop/loopsettings/flomni.json) and are applied by the web HMI on the loop machine (RT-system
webhmi/loopsettings.py). This client only talks to its REST API (/api/settings); it holds no logic of its own, so the
web page (tab "Loop settings") and BEC always behave the same way.
flomni.rt_settings.show() all settings, rules, scan state
flomni.rt_settings.set(trigger_tolerance_nm=5) change (refused if a rule objects)
flomni.rt_settings.set(fzp_enable=0, adjust=True) ... and adjust the dependent settings
flomni.rt_settings.check(fzp_range_V_x=1.0) dry run
flomni.rt_settings.apply_profile("fzp_off") a named set of settings
flomni.rt_settings.save_as_default() write the live values into the .xmp files
Changes are live only: the next start of the control loop uses the .xmp files again (save_as_default keeps them).
Connection: through the BEC device flomni_loop_settings (RPC; the API token is in its device config,
ptycho_flomni.yaml) when it exists and is enabled, else directly to the web HMI (devices/omny/webhmi_client.py: URL
http://<rtx host>:8080 with port-80 fallback, token from ~/.flomni_webhmi_token or FLOMNI_WEBHMI_TOKEN_FILE).
"""
from csaxs_bec.devices.omny.webhmi_client import WebHmiClient
from csaxs_bec.devices.omny.webhmi_client import WebHmiError as RealtimeSystemError # name kept for BEC users
STAFF_ONLY = ("flomni.rt_settings: beamline scientists / local contact only - not for users "
"(plugins/flomni/docs/local_contact_only-realtime_system.md).")
def _fmt(v):
if v is None:
return "-"
if isinstance(v, float):
return f"{v:.6g}"
return str(v)
class FlomniRealtimeSystem(WebHmiClient):
"""Requests go through the BEC device flomni_loop_settings (RPC api_get / api_post; the token is in its device
config) when it exists and is enabled; otherwise directly to the web HMI (WebHmiClient,
devices/omny/webhmi_client.py; token from token_file / ~/.flomni_webhmi_token)."""
DEVICE = "flomni_loop_settings"
def __init__(self, url=None, token_file=None, device_manager=None, timeout=5.0, token=None):
super().__init__(url=url, token=token, token_file=token_file, device_manager=device_manager, timeout=timeout)
def _device(self):
try:
dev = getattr(self._device_manager.devices, self.DEVICE)
except Exception: # noqa: BLE001 (no device manager / no such device)
return None
return dev if getattr(dev, "enabled", True) else None
def _request(self, path, body=None):
dev = self._device()
if dev is None:
return super()._request(path, body) # direct HTTP
try:
return dev.api_get(path) if body is None else dev.api_post(path, body)
except Exception as exc: # noqa: BLE001 (RPC error with the device-side message)
raise RealtimeSystemError(f"via device {self.DEVICE}: {exc}") from None
@property
def route(self):
"""'device flomni_loop_settings' or the direct URL."""
return f"device {self.DEVICE}" if self._device() is not None else self.url
# ── reading ───────────────────────────────────────────────────────────────
def read(self) -> dict:
"""The full state as the web HMI reports it (settings, rules, scan state, profiles)."""
return self._request("/api/settings")
def get(self, name: str = None):
"""Live value of one setting, or a dict of all values."""
values = {s["name"]: s["value"] for s in self.read()["settings"]}
if name is None:
return values
if name not in values:
raise RealtimeSystemError(f"unknown setting '{name}'; known: {', '.join(values)}")
return values[name]
def show(self, group=None, expert: bool = False):
"""Print the settings (live value, .xmp value, markers), the violated rules and the scan state; group: one
group name or several (tuple)."""
snap = self.read()
wanted = (group,) if isinstance(group, str) else tuple(group or ())
groups = {g["name"]: g for g in snap["groups"]}
sc = snap.get("scan") or {}
print(STAFF_ONLY)
print(f"Loop settings {snap.get('instrument', '')} ({self.route}) - "
+ (f"scan running (mode {_fmt(sc.get('mode'))})" if sc.get("active") else "no scan"))
for gname, g in groups.items():
if wanted and gname not in wanted:
continue
if g.get("expert") and not expert and not group:
continue
rows = [s for s in snap["settings"] if s["group"] == gname]
if not rows:
continue
print(f"\n{g.get('title', gname)}" + (" (changed only between scans)" if g.get("interlock") else ""))
for s in rows:
info = f" ({s['info']['label']} {s['info']['value']:.4g} {s['info']['unit']})" if s.get("info") else ""
mark = (" NOT SAVED (.xmp " + _fmt(s["saved"]) + ")" if s["unsaved"] else "") + \
(" INCONSISTENT: " + "; ".join(s["issues"]) if not s["consistent"] else "")
print(f" {s['name']:30s} {_fmt(s['value']):>10s} {s.get('unit', ''):3s}{info}{mark}")
bad = [r for r in snap["rules"] if not r["ok"]]
if bad:
print()
for r in bad:
print(f"{r['severity'].upper()}: {r['message']}")
if not expert and not group and any(g.get("expert") for g in snap["groups"]):
print("\n(expert groups hidden: show(expert=True))")
print("\n" + (snap.get("note") or ""))
def profiles(self):
"""Print the named profiles and their values."""
for p in self.read()["profiles"]:
print(f"{p['name']:18s} {p.get('title', '')}")
if p.get("help"):
print(f"{'':18s} {p['help']}")
print(f"{'':18s} " + ", ".join(f"{k}={_fmt(v)}" for k, v in p["values"].items()))
def unsaved(self):
"""Print the parameters whose live value differs from the .xmp (settings only)."""
un = self._request("/api/settings/unsaved")
if not un:
print("All settings are saved: the .xmp files hold the live values.")
for u in un:
print(f" {u['module']}.{u['param']}: .xmp {u['saved']} -> live {u['live']}")
return un
# ── changing ──────────────────────────────────────────────────────────────
@staticmethod
def _print_result(r, dry=False):
head = ("Check: " if dry else "") + ("applied" if r.get("applied") else ("ok" if r["ok"] else "REFUSED"))
if r["ok"] and not r["params"]:
head = "no change"
print(head + (f" (profile {r['profile']})" if r.get("profile") else ""))
for c in r["changes"]:
print(f" {c['name']}: {_fmt(c['old'])} -> {_fmt(c['new'])}"
+ (f" (adjusted by rule {c['adjusted']})" if c.get("adjusted") else ""))
for e in r["errors"]:
print(f" ERROR: {e}")
for w in r["warnings"]:
print(f" warning: {w}")
if r.get("applied"):
print(" " + r.get("note", "Live only: save_as_default() to keep it."))
def check(self, adjust: bool = False, **changes) -> dict:
"""Dry run: what would change, which rules object. Nothing is sent to the loop."""
r = self._request("/api/settings/plan", {"changes": changes, "profile": None, "adjust": adjust})
self._print_result(r, dry=True)
return r
def set(self, adjust: bool = False, quiet: bool = False, **changes) -> dict:
"""Change settings, e.g. set(trigger_tolerance_nm=5). All parameters change in one loop step.
A rule that objects refuses the request (RealtimeSystemError); with adjust=True the settings the rule names
are changed with it (never the ones given here). Live only until save_as_default(). quiet: print nothing
(interactive tools); a refusal still raises.
"""
if not changes:
raise RealtimeSystemError("nothing to set: e.g. set(trigger_tolerance_nm=5); show() lists the names")
r = self._request("/api/settings/apply", {"changes": changes, "profile": None, "adjust": adjust})
if not quiet:
self._print_result(r)
if not r["ok"]:
raise RealtimeSystemError("; ".join(r["errors"]))
return r
def apply_profile(self, name: str, adjust: bool = False, **changes) -> dict:
"""Apply a named profile (profiles() lists them); extra keyword values override the profile's."""
r = self._request("/api/settings/apply", {"changes": changes, "profile": name, "adjust": adjust})
self._print_result(r)
if not r["ok"]:
raise RealtimeSystemError("; ".join(r["errors"]))
return r
def save_as_default(self, *names: str, confirm: bool = True) -> dict:
"""Write the live values of the settings' parameters (all, or the named settings) into the .xmp files, so the
next loop start uses them (the web HMI keeps a backup of each file)."""
un = self._request("/api/settings/unsaved")
if not un:
print("Nothing to save: the .xmp files hold the live values.")
return {"saved": []}
if not names:
print("Save as default (written into the .xmp files of the loop):")
for u in un:
print(f" {u['module']}.{u['param']}: {u['saved']} -> {u['live']}")
if confirm and input("Save? [y/N] ").strip().lower() not in ("y", "yes"):
print("not saved")
return {"saved": []}
r = self._request("/api/settings/save", {"names": list(names) or None})
print(r.get("message", ""), "; ".join(r.get("saved", [])))
return r
# ── shortcuts (detector trigger, laser tracker) ────────────────────────────
# the former flomni.interferometer_trigger_* (removed 2026-10-23)
def trigger_show(self):
"""Detector trigger (step scans): tolerance, hold time, minimum delay, trigger after every move; trigger source
(hardware / soft)."""
self.show(group=("trigger", "trigger_source"))
def trigger_set_tolerance_nm(self, tolerance_nm: float):
"""|relative position sample vs FZP| x and y within this (nm) for the hold time. 30 coarse, 10 standard, 2 finest;
0 = the former criterion (OnTarget_legacy flags of the FZP PIDs)."""
return self.set(trigger_tolerance_nm=tolerance_nm)
def trigger_set_hold_ms(self, hold_ms: float):
"""How long the position must stay within the tolerance before the trigger (ms), typical 3."""
return self.set(trigger_hold_ms=hold_ms)
def trigger_set_after_move(self, enable: bool):
"""True: one detector trigger after every direct position change (umv rtx / rty / rtz, move_xyz) once on target;
False: triggers at step-scan points only."""
return self.set(trigger_on_move=1 if enable else 0)
def trigger_set_soft(self, enable: bool, exposure_ms: float = None):
"""Soft trigger (working without detector / trigger PLC): True = no hardware trigger leaves the loop, every
trigger starts an emulated exposure of exposure_ms (default: unchanged); False = hardware trigger. Never saved
as default (the loop starts with the hardware trigger); the flOMNI Fermat scan sets it at every scan start
(hardware at the beamline, soft otherwise). Only between scans."""
changes = {"trigger_soft": 1 if enable else 0}
if enable and exposure_ms is not None:
changes["soft_exposure_ms"] = exposure_ms
return self.set(**changes)
def tracking_targets(self):
"""-> (target y, target z) of the laser tracker on the PSD."""
v = self.get()
return v["tracking_target_y"], v["tracking_target_z"]
# the former flomni.laser_parameters_* (moved here 2026-10-26)
def tracking_show(self):
"""Laser tracker: targets on the PSD and intensity thresholds."""
self.show(group="tracking")
def tracking_set_target_y(self, val: float):
"""Laser tracker target y on the PSD (arrow-key tweak: flomni.rt_laser_tracker_tweak())."""
return self.set(tracking_target_y=val)
def tracking_set_target_z(self, val: float):
"""Laser tracker target z on the PSD (arrow-key tweak: flomni.rt_laser_tracker_tweak())."""
return self.set(tracking_target_z=val)
def tracking_set_interferometer_threshold(self, val: float):
"""Intensity threshold of the interferometer signal."""
return self.set(interferometer_threshold=val)
def tracking_set_psd_threshold_low(self, val: float):
"""Lower PSD intensity threshold of the tracking."""
return self.set(psd_threshold_low=val)
def help(self, name: str = None):
"""help(): the functions; help("all"): every setting with its explanation; help(name): one setting (what it
does, unit, range, the loop parameters it sets, live and saved value), from the definition on the loop
machine (the same text as the tooltips of the web HMI tab)."""
if name is None:
print(__doc__.split("\n\nConnection:")[0])
print("\nShortcuts: trigger_show(), trigger_set_tolerance_nm(v), trigger_set_hold_ms(v),"
" trigger_set_after_move(True/False), trigger_set_soft(True/False, exposure_ms), tracking_targets(), tracking_show(), tracking_set_target_y(v),"
" tracking_set_target_z(v), tracking_set_interferometer_threshold(v), tracking_set_psd_threshold_low(v);"
" profiles(), unsaved(), get(name)")
print('Explanations: help("all") or help("<setting>"), e.g. help("move_beyond_um_x")')
return
snap = self.read()
settings = {s["name"]: s for s in snap["settings"]}
groups = {g["name"]: g for g in snap["groups"]}
if name == "all":
print(STAFF_ONLY)
for gname, g in groups.items():
rows = [s for s in snap["settings"] if s["group"] == gname]
if not rows:
continue
print(f"\n{g.get('title', gname)}" + (" (only between scans)" if g.get("interlock") else ""))
for s in rows:
unit = f" [{s['unit']}]" if s.get("unit") else ""
print(f" {s['name']}{unit}: {s.get('title', '')}")
if s.get("help"):
print(f" {s['help']}")
return
s = settings.get(name)
if s is None:
raise RealtimeSystemError(f"unknown setting '{name}'; help('all') lists them")
g = groups.get(s["group"], {})
print(STAFF_ONLY)
print(f"{s['name']} - {s.get('title', '')} (group {g.get('title', s['group'])}"
+ (", only between scans)" if g.get("interlock") else ")"))
if s.get("help"):
print(f" {s['help']}")
rng = "0 / 1" if s.get("type") == "bool" else f"{_fmt(s.get('min'))} ... {_fmt(s.get('max'))} {s.get('unit', '')}"
print(f" range: {rng}")
print(f" live: {_fmt(s.get('value'))} saved (.xmp): {_fmt(s.get('saved'))}"
+ (" NOT SAVED" if s.get("unsaved") else "") + ("" if s.get("consistent", True) else " INCONSISTENT"))
if s.get("info"):
print(f" {s['info'].get('label', '')} {s['info']['value']:.4g} {s['info'].get('unit', '')}")
print(" sets: " + ", ".join(f"{t['module']}.{t['param']}[{t['index']}] = {t.get('write', 'v')}"
for t in s.get("targets", [])))
rules = [r for r in snap.get("rules", []) if name in r.get("settings", [])]
if rules:
print(" rules: " + ", ".join(r["name"] + ("" if r.get("ok") else " (VIOLATED)") for r in rules))
@@ -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 = {
@@ -271,9 +271,9 @@ class XrayEyeAlign:
self.flomni.feye_in()
self.flomni.laser_tracker_on()
self.flomni.rt_laser_tracker_on()
self.flomni.feedback_enable_with_reset()
self.flomni.rt_feedback_enable_with_reset()
# disable movement buttons
self.movement_buttons_enabled(False, False)
@@ -282,12 +282,12 @@ class XrayEyeAlign:
self.gui.sample_name = sample_name
# this makes sure we are in a defined state
self.flomni.feedback_disable()
self.flomni.rt_feedback_disable()
if not self.test_wo_movements:
self.flomni.fosa_out()
self.flomni.ffzp_in()
self.flomni.feedback_disable()
self.flomni.rt_feedback_disable()
fsamx_in = self.flomni._get_user_param_safe("fsamx", "in")
umv(dev.fsamx, fsamx_in - 0.25)
@@ -363,7 +363,7 @@ class XrayEyeAlign:
self.movement_buttons_enabled(False, False)
self.gui.enable_submit_button(False)
self.flomni.feedback_disable()
self.flomni.rt_feedback_disable()
if not self.test_wo_movements:
fsamx_in = self.flomni._get_user_param_safe("fsamx", "in")
umv(dev.fsamx, fsamx_in)
@@ -372,7 +372,7 @@ class XrayEyeAlign:
time.sleep(0.5)
self.flomni.feedback_enable_with_reset()
self.flomni.rt_feedback_enable_with_reset()
self.update_frame(keep_shutter_open)
@@ -411,11 +411,11 @@ class XrayEyeAlign:
f"delta_fsamy={delta_y_mm:.5f} mm"
)
self.flomni.feedback_disable()
self.flomni.rt_feedback_disable()
if not self.test_wo_movements and delta_y_mm != 0:
self.flomni.umvr_fsamy_tracked(delta_y_mm, _internal=True)
time.sleep(2)
self.flomni.feedback_enable_with_reset()
self.flomni.rt_feedback_enable_with_reset()
self._height_centered = True
# From here through step 5, the sample's vertical position is
@@ -472,12 +472,12 @@ class XrayEyeAlign:
# allow movements, store movements to calculate center
_xrayeyalignmvy = dev.omny_xray_gui.mvy.get()
if _xrayeyalignmvy != 0:
self.flomni.feedback_disable()
self.flomni.rt_feedback_disable()
if not self.test_wo_movements:
self.flomni.umvr_fsamy_tracked(_xrayeyalignmvy / 1000, _internal=True)
time.sleep(2)
dev.omny_xray_gui.mvy.set(0)
self.flomni.feedback_enable_with_reset()
self.flomni.rt_feedback_enable_with_reset()
self.update_frame(keep_shutter_open)
time.sleep(0.1)
@@ -0,0 +1,304 @@
# 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. 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
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
- **`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.
- **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 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, 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:
### 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.
- **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
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) (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 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.
### 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
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 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
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) 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
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, 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 — 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.
- **`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.
- **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
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
No dependencies added, no code written, no new GUI elements built. This document is the handoff for
the follow-up branch.
@@ -0,0 +1,863 @@
# 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
future session can pick this up without re-deriving it. Companion docs:
`../../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), 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
A full `omny_fermat_scan` (via `omny.tomo_scan_projection(<angle>)`) 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.
**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).
**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.
**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.
**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;
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.
- `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
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, and the `omny_fermat_scan` rotation-crash section further
down for a case where following flomni's exact pattern was the fix.
## `osamroy` rotation offset — resolved (real hardware code was correct; only the simulator was wrong)
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).
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.
## `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). 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.
- 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.
- 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 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
- **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(<angle>)`
end to end, and the GUI flows listed under "Current status" above.
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 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 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` beyond what a fermat scan's own setup touches.
## 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.
- **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()`. 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)
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.
**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: 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)
`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).
## omny_fermat_scan rotation crashed on an arbitrary device (readback progressbar) (fixed)
`omny.tomo_scan_projection(1)` -> `omny_fermat_scan` crashed with `ValueError: '<some device>' 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.
**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).
**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.
**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.
**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.
## 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.
## 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.
## 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.
## 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).
## 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.
## 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).
**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`).
## 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.
## 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.
## 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.
## 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.
**Superseded 2026-10-18 (branch `feat/omny-sample-ioc`):** both instruments now keep one
soft-IOC record per slot (`FLOMNI-SAMPLE:<slot>:...` since 2026-10-17, `OMNY-SAMPLE:<slot>: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
"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-
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 <pkg>` → `Editable project location` first.
@@ -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.
@@ -0,0 +1,48 @@
# TODO: OMNY has bugs that were fixed in flomni (review 2026-10-24)
Found while reviewing `flomni.py` and its mixins (branch `fzp-move-beyond`, csaxs_bec commits
`1a5b53c`, `b939793` and the follow-up). OMNY was not changed; port these on the OMNY branch.
## 1. X-ray eye Ctrl-C handler cannot return osamx (`omny.py`, `start_x_ray_eye_alignment`)
```python
osamx_in = self._get_user_param_safe(dev.osamx, "in")
if np.isclose(osamx_in, dev.osamx.readback.get(), 0.5):
```
- `_get_user_param_safe()` looks the device up with `dev[device]` and needs the **name**
(`"osamx"`). With the device object the lookup raises (`KeyError`) inside the
`except KeyboardInterrupt` handler, so osamx is never returned to its in position and the
user sees a KeyError instead of the interrupt. flomni fix: `self._get_user_param_safe("fsamx", "in")`.
- The third positional argument of `np.isclose` is **rtol**, not atol: `0.5` means 50 % of the
readback. Use `np.isclose(a, b, rtol=0, atol=0.5)` (mm).
## 2. Special angles (`omny.py`, the per-angle loop of the tomo scan)
```python
if self._current_special_angles:
next_special_angle = self._current_special_angles[0]
if np.isclose(angle, next_special_angle, atol=0.5):
self._current_special_angles.pop(0)
num_repeats = self.special_angle_repeats
else:
num_repeats = 1
```
- `num_repeats` is unbound when special angles are set but the angle is not the next one ->
`UnboundLocalError` (flomni had the same bug, fixed earlier with `num_repeats = 1` up front).
- The angle is popped before the projection ran: a repeated attempt (timeout / beam check)
no longer sees it as special.
- `special_angle_tolerance` is never used (hard-coded 0.5); `__init__` sets it to 20 while
`set_special_angles()` defaults to 0.5; the `tolerance` docstring says "Number of repeats".
- Only the first list entry can match: a special angle that is never reached blocks all later ones.
flomni now: match any remaining special angle within `special_angle_tolerance`, pop it only after
the projection succeeded, keep the remaining list across a resume in the same session
(`Flomni._tomo_scan_at_angle`, tests `tests/tests_bec_ipython_client/test_flomni_special_angles.py`).
## Already shared (nothing to do for OMNY)
- `OMNYTools.tweak_cursor()` (OMNY_shared) no longer ends with a traceback when an arrow-key
escape sequence arrives in two parts or ESC is pressed alone (`read_escape_suffix()`), and its
"not in enabled devices" message names the device.
@@ -0,0 +1,82 @@
# 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).
## 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).
## 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
`~/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.
@@ -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,69 +20,158 @@ 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)
self.gui_window = self.gui.windows["main"].widget
self.fig200 = None
self.fig201 = None
self.fig202 = None
self.fig203 = None
self.progressbar = None
def __init__(self):
self.omny_window = None
self.text_box = None
self.progressbar = None
self.xeyegui = None
self.idle_text_box = 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
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 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()
# 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_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.fig200 is None:
self._omnycam_clear()
self.fig200 = self.gui_window.add_dock(name="omnycam200").add_widget("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)
if (
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.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.
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])
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()
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 cam200. Device does not exist.")
self.fig203 = self.gui_window.add_dock(name="omnycam203").add_widget("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
print(f"Cannot open {cam_name}. Device does not exist.")
self._omnycam_shown_pair = (cam_a, cam_b)
def omnygui_remove_all_docks(self):
self.gui_window.clear_all()
self.fig200 = None
self.fig201 = None
self.fig202 = None
self.fig203 = None
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.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()
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 = (
"<pre>"
+ "██████╗ ███████╗ ██████╗ ██████╗ ███╗ ███╗███╗ ██╗██╗ ██╗\n"
@@ -91,79 +184,114 @@ 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"
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) 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 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."
)
# 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()
except Exception as e:
logger.warning(f"Error in omnygui_show_progress: {e}")
def _omnygui_update_progress(self):
if self.progressbar is not None:
"""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.")
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)
# --- 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"
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'])}\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}")
File diff suppressed because it is too large. Load diff
@@ -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
@@ -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)
@@ -1,6 +1,9 @@
import datetime
import os
import time
import builtins
import h5py
import numpy as np
from rich import box
@@ -10,8 +13,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")
@@ -26,6 +27,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:
@@ -49,6 +74,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")
@@ -141,7 +187,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...")
@@ -371,7 +417,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",
):
@@ -472,7 +518,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 self.OMNYTools.yesno("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)
@@ -511,17 +560,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.")
@@ -740,9 +791,13 @@ 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")
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:
@@ -755,21 +810,31 @@ 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.")
self._otransfer_save_reference_images(file_suffix="put_mount_start")
dev.otransy.controller.socket_put_confirmed("XQ#GRPUT")
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)
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
# 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(
@@ -781,7 +846,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:
@@ -800,7 +865,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.")
@@ -811,11 +876,49 @@ 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")
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(
@@ -827,19 +930,24 @@ 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",
):
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)
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
@@ -861,20 +969,22 @@ 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.")
self._otransfer_save_reference_images(file_suffix="get_unmount_start")
dev.otransy.controller.socket_put_confirmed("XQ#GRGET")
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
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()
@@ -907,14 +1017,18 @@ 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 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."
)
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(
@@ -1056,9 +1170,99 @@ 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, 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, 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, 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()
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_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_measured(
self, container: str, slot_nr: int, status: str, meas_date: str, account: str = ""
):
return dev.omny_samples.set_sample_measured(container, slot_nr, status, meas_date, 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_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()
def otransfer_get_sample_owner_in_samplestage(self) -> str:
return dev.omny_samples.get_sample_owner_in_samplestage()
def otransfer_get_sample_status_in_gripper(self) -> str:
return dev.omny_samples.get_sample_status_in_gripper()
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
@@ -1124,12 +1328,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
# remove sample from slot and put to gripper -- name/owner/status/date
# carry over with it, the same physical sample
dev.omny_samples.transfer_sample(
(installedshuttle, _osampleposition), "GRIPPER"
)
# remove sample from slot and put to gripper
dev.omny_samples.unset_sample_slot(installedshuttle, _osampleposition)
dev.omny_samples.set_sample_in_gripper(samplename)
return True
else:
print("There is no sample availabe at that position.")
@@ -1217,9 +1420,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)
dev.omny_samples.unset_sample_slot("O", _osampleposition)
dev.omny_samples.set_sample_in_gripper(samplename)
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)
@@ -1238,9 +1439,17 @@ 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()
dev.omny_samples.unset_sample_in_samplestage()
dev.omny_samples.set_sample_in_gripper(samplename)
dev.omny_samples.transfer_sample("STAGE", "GRIPPER")
# leaving the sample stage without a tomoscan having been started on it:
# "screened" (Mirko 2026-10-27); a started/completed status is kept
if dev.omny_samples.get_sample_status("GRIPPER") == "none":
dev.omny_samples.set_sample_measured(
"GRIPPER",
0,
"screened",
datetime.date.today().strftime("%Y-%m"),
account=bec.active_account or "",
)
# FZP was picked
# if (_osampleposition == 10 || _osampleposition == 11 || _osampleposition == 100)
# _otransfer_modify_sample("omny",110,2,samplename)
@@ -1312,10 +1521,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()
dev.omny_samples.unset_sample_in_gripper()
dev.omny_samples.set_sample_slot(
installedshuttle, _osampleposition, samplename
dev.omny_samples.transfer_sample(
"GRIPPER", (installedshuttle, _osampleposition)
)
return True
else:
@@ -1404,9 +1611,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()
dev.omny_samples.set_sample_slot("O", _osampleposition, samplename)
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.")
@@ -1416,9 +1621,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()
dev.omny_samples.set_sample_in_samplestage(samplename)
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.")
@@ -1435,4 +1638,10 @@ class OMNYSampleTransferMixin:
)
print("omny.otransfer_get_sample(position) pick with the gripper from <position>")
print("omny.otransfer_put_sample(position) put with the gripper to <position>")
print("To modify the storage information see dev.omny_samples.help()")
print("omny.otransfer_show_all() show the full sample storage overview")
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()
@@ -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
@@ -75,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):
@@ -157,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)
@@ -184,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}: "
@@ -238,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())
@@ -270,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())
@@ -313,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: "
@@ -346,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)
@@ -395,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: "
@@ -424,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}")
print(f"Fit data submitted with shape {data.shape}:\n{data}")
@@ -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)
File diff suppressed because it is too large. Load diff
@@ -88,20 +88,12 @@ 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",
4: "Angles from file",
}
# 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, _sec_type4 only
# for type 4) -- 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.
# Mirrors _update_type_visibility()'s section/field gating -- 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). Which types are "equally spaced sub-tomograms"
# (_sec_type1) or "angles from file" (_sec_type4) is set per setup in SETUP_PROFILES[...]
# ["tomo_types"]: flomni/lamni 1 = sub-tomograms, 4 = angle file; OMNY 1/4/5 = 2/4/8
# sub-tomograms. tomo_angle_range is flomni-only (OMNY jobs never carry it).
_TYPE1_ONLY_PARAMS = {
"tomo_angle_range",
"zero_deg_reference_at_each_subtomo",
@@ -115,17 +107,32 @@ _TYPE23_PARAMS = {
_TYPE4_ONLY_PARAMS = {"tomo_angle_file"}
def _irrelevant_params_for_type(tomo_type: Any) -> set[str]:
def _is_angle_file_type(profile: dict, tomo_type: Any) -> bool:
"""True if `tomo_type` is this setup's "angles from file" type (flomni/lamni: 4;
OMNY has none -- its type 4 is "4 equally spaced sub-tomograms")."""
return bool(profile["tomo_types"].get(tomo_type, {}).get("angle_file"))
def _irrelevant_params_for_type(tomo_type: Any, profile: Optional[dict] = None) -> set[str]:
"""Param names that don't apply to a job's own tomo_type and should be
hidden from its tooltip."""
hidden from its tooltip. With the setup's `profile` the sub-tomogram and
angle-file types come from its "tomo_types" (OMNY's 4 means 4 sub-tomograms,
flomni/lamni's 4 means angles from file); without it a setup-neutral guess."""
irrelevant: set[str] = set()
if tomo_type != 1:
if profile is not None:
info = profile["tomo_types"].get(tomo_type, {})
subtomo_type = info.get("n_subtomos") is not None
angle_file_type = bool(info.get("angle_file"))
else:
subtomo_type = tomo_type in (1, 5)
angle_file_type = tomo_type == 4
if not subtomo_type:
irrelevant |= _TYPE1_ONLY_PARAMS
if tomo_type not in (2, 3):
irrelevant |= _TYPE23_PARAMS
if tomo_type != 2:
irrelevant |= _TYPE2_ONLY_PARAMS
if tomo_type != 4:
if not angle_file_type:
irrelevant |= _TYPE4_ONLY_PARAMS
return irrelevant
@@ -289,6 +296,50 @@ 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",
"zero_deg_reference_at_each_subtomo",
"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,
"zero_deg_reference_at_each_subtomo": False,
"corridor_size": -1,
"at_each_angle_hook": None,
}
def _hline() -> QFrame:
line = QFrame()
@@ -546,8 +597,8 @@ class OMNY_TomoParams(BECWidget, QWidget):
type_row = QHBoxLayout()
type_row.addWidget(QLabel("Tomo type:"))
self._pw["tomo_type"] = QComboBox()
for tid in self._profile["tomo_types"]:
self._pw["tomo_type"].addItem(f"{tid} – {TOMO_TYPES[tid]}", 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)
@@ -632,7 +683,7 @@ class OMNY_TomoParams(BECWidget, QWidget):
# type-4 section (angles from file) -- only for setups supporting it
self._sec_type4 = None
if 4 in self._profile["tomo_types"]:
if any(info.get("angle_file") for info in self._profile["tomo_types"].values()):
self._sec_type4 = self._build_type4_section()
vbox.addWidget(self._sec_type4)
@@ -759,7 +810,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)
@@ -777,6 +832,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)
@@ -1028,7 +1089,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)
@@ -1096,7 +1157,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
@@ -1134,7 +1197,7 @@ class OMNY_TomoParams(BECWidget, QWidget):
if params.get("tomo_type") not in self._profile["tomo_types"]:
allowed = ", ".join(str(t) for t in self._profile["tomo_types"])
return f"tomo_type must be one of {allowed}"
if params.get("tomo_type") == 4:
if _is_angle_file_type(self._profile, params.get("tomo_type")):
_summary, error = _angle_file_status(
params.get("tomo_angle_file", ""),
self._profile.get("angle_file_opposite_warning", True),
@@ -1175,13 +1238,24 @@ class OMNY_TomoParams(BECWidget, QWidget):
lbl.widget().setVisible(visible)
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))
self._set_form_row_visible(self._sec_type23, "_numprj_type3", tomo_type == 3)
self._set_form_row_visible(self._sec_type23, "golden_ratio_bunch_size", tomo_type == 2)
if self._sec_type4 is not None:
self._sec_type4.setVisible(tomo_type == 4)
if tomo_type == 4:
angle_file_type = bool(type_info.get("angle_file"))
self._sec_type4.setVisible(angle_file_type)
if angle_file_type:
self._update_angle_file_preview()
# ── type-1 projection preview ─────────────────────────────────────────────
@@ -1193,18 +1267,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("")
@@ -1469,18 +1547,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,
)
@@ -1526,17 +1608,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,
)
@@ -1833,7 +1919,7 @@ class TomoQueueDialog(QDialog):
_fmt_num(params.get("tomo_shellstep")),
_format_hook_detail(params, registered_hooks),
]
tooltip = _job_tooltip(job, action_registry)
tooltip = _job_tooltip(job, registry=action_registry, profile=self._profile)
for col, text in enumerate(cells):
item = QTableWidgetItem(text)
# Label/Details are now wide, free-text columns -- left-align
@@ -2629,7 +2715,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:
@@ -2638,19 +2724,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
@@ -2662,7 +2754,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.
@@ -2670,13 +2764,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
@@ -2686,7 +2783,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:
@@ -2699,6 +2796,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.
@@ -2742,6 +2879,41 @@ 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.
The real scan also draws a fresh random sub-step offset on every call
(see get_omny_fermat_spiral_pos()'s `seed` argument), which can shift
the actual scan-time count by a point or two near the threshold. A
fixed seed is used here only so this estimate is itself reproducible.
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,
seed=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(), via
lamni_smallest_tile_position_count(): the real scan algorithm
@@ -2814,7 +2986,6 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"has_180_mode": True,
"has_single_point": True,
# tomo_type 4 (angles from file) is flomni-only
"tomo_types": (1, 2, 3, 4),
"has_zero_deg_reference": True,
"has_always_forward": True,
"golden_bunch_size_min": 1,
@@ -2857,8 +3028,20 @@ 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", "fermat_asymmetry", "fovx", "fovy"],
# 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},
2: {"label": "Golden ratio (sorted in bunches)", "n_subtomos": None},
3: {"label": "Equally spaced, golden ratio start", "n_subtomos": None},
4: {"label": "Angles from file", "n_subtomos": None, "angle_file": True},
},
},
"lamni": {
"label": "LamNI",
@@ -2867,7 +3050,6 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"defaults": LAMNI_DEFAULTS,
"has_180_mode": False,
"has_single_point": False,
"tomo_types": (1, 2, 3, 4),
# laminography: 180-deg-opposite angles are not redundant
"angle_file_opposite_warning": False,
"has_zero_deg_reference": True,
@@ -2932,6 +3114,82 @@ 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},
4: {"label": "Angles from file", "n_subtomos": None, "angle_file": True},
},
},
"omny": {
"label": "OMNY",
"discriminator_attr": "osamroy",
"param_names": OMNY_QUEUE_PARAM_NAMES,
"defaults": OMNY_DEFAULTS,
"has_180_mode": False,
"has_single_point": 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),
], # 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"],
# 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
# 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
# `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_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 --
# 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},
},
},
}
@@ -2940,25 +3198,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):
@@ -3003,7 +3277,7 @@ def _format_hook_detail(params: dict, registered_hooks: list) -> str:
return f"hook: {hook_name} (not registered)"
def _job_tooltip(job: dict, registry: dict | None = None) -> str:
def _job_tooltip(job: dict, registry: dict | None = None, profile: Optional[dict] = None) -> str:
"""Full-detail multi-line description of a queue job, applied to every
cell in its row so hovering anywhere on the row shows what the Label
and Details columns truncate at typical dialog widths."""
@@ -3021,7 +3295,7 @@ def _job_tooltip(job: dict, registry: dict | None = None) -> str:
# works for both flomni and lamni jobs without needing to know which.
# Fields irrelevant to this job's own tomo_type (e.g. golden-ratio
# settings on a type-1 job) are skipped -- see _irrelevant_params_for_type().
irrelevant = _irrelevant_params_for_type(params.get("tomo_type"))
irrelevant = _irrelevant_params_for_type(params.get("tomo_type"), profile)
for name in sorted(params):
if name in irrelevant:
continue
+56 -34
View File
@@ -616,6 +616,28 @@ flomni_temphum:
deviceTags:
- ptycho_flomni
# ############################################################
# ############ flOMNI real-time loop settings #################
# ############################################################
# The loop settings (FZP, trigger, tracker; flOMNI FeedbackLoop/loopsettings) stored with every scan: read from the
# web HMI on the loop machine at the start of each scan (baseline). Web HMI down: available = 0, the scan continues.
# The user commands flomni.rt_settings go through this device (RPC), so the token is needed only here.
# token: the web HMI API token (~/loop-data/webhmi/api_token on mpc2844; behind the beamline firewall, Mirko
# 2026-10-23). When empty, token_file (on the machine of the device server) is used.
flomni_loop_settings:
description: flOMNI real-time loop settings (web HMI loop settings, snapshot at scan start)
deviceClass: csaxs_bec.devices.omny.flomni_loop_settings.FlomniLoopSettings
deviceConfig:
host: mpc2844.psi.ch
token: "3CcGHMdz16G7zuC6iPHEoZPWvKDY1HkCZnTlP1msZbg"
token_file: ~/.flomni_webhmi_token
timeout: 2.0
enabled: true
onFailure: buffer
readOnly: true
readoutPriority: baseline
deviceTags:
- ptycho_flomni
# ############################################################
# ########## OMNY / flOMNI / LamNI fast shutter ##############
# REPLACED BY SIMULATED BEAMLINE DEVICE !!!!!!!!!!!!!!!!
# ############################################################
@@ -706,37 +728,37 @@ calculated_signal:
# deviceTags:
# - ptycho_flomni
omny_panda_continuous:
readoutPriority: async
deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY
deviceConfig:
host: omny-panda.psi.ch
raw_stream_mode: true
# Only actually Arm() PandA for scans that consume its data -- generic BEC staging
# (stage_all_devices/pre_scan_all_devices) otherwise arms this device for every scan type
# (grid_scan, line_scan, alignment moves, ...), not just the ptycho scan that reads it. See
# docs/developer/panda_box_free_running_setup.md and
# AI_docs/PANDA_POSITION_VALIDATION.md sec 7 (stale frame_counter exposure).
arm_scan_allowlist:
- flomni_fermat_scan
signal_alias:
FMC_IN.VAL1.Value: cap_voltage_fzp_y
FMC_IN.VAL2.Value: cap_voltage_fzp_x
INENC1.VAL.Value: interf_st_fzp_y
INENC2.VAL.Value: interf_st_fzp_x
INENC3.VAL.Value: interf_st_rotz
INENC4.VAL.Value: interf_st_rotx
PCAP.GATE_DURATION.Value: pcap_gate_duration_value
# TTLIN1 (detector trigger) feeds both blocks below - confirmed 2026-09-15 against the
# omny-panda hardware, see docs/developer/panda_box_free_running_setup.md. PCAP.BITS0
# is a shared 32-bit word; PandaBoxOMNY masks it to bit 0 (TTLIN1.VAL) before
# publishing gate_detector_active. COUNTER1 counts TTLIN1's falling edge, i.e. one
# increment per completed exposure; it also doubles as the on_complete signal. Confirmed
# working end-to-end 2026-09-15; now under speed testing beyond the initial 100 Hz.
PCAP.BITS0.Value: gate_detector_active
COUNTER1.OUT.Value: frame_counter
enabled: true
readOnly: false
softwareTrigger: false
deviceTags:
- ptycho_flomni
# omny_panda_continuous:
# readoutPriority: async
# deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY
# deviceConfig:
# host: omny-panda.psi.ch
# raw_stream_mode: true
# # Only actually Arm() PandA for scans that consume its data -- generic BEC staging
# # (stage_all_devices/pre_scan_all_devices) otherwise arms this device for every scan type
# # (grid_scan, line_scan, alignment moves, ...), not just the ptycho scan that reads it. See
# # docs/developer/panda_box_free_running_setup.md and
# # AI_docs/PANDA_POSITION_VALIDATION.md sec 7 (stale frame_counter exposure).
# arm_scan_allowlist:
# - flomni_fermat_scan
# signal_alias:
# FMC_IN.VAL1.Value: cap_voltage_fzp_y
# FMC_IN.VAL2.Value: cap_voltage_fzp_x
# INENC1.VAL.Value: interf_st_fzp_y
# INENC2.VAL.Value: interf_st_fzp_x
# INENC3.VAL.Value: interf_st_rotz
# INENC4.VAL.Value: interf_st_rotx
# PCAP.GATE_DURATION.Value: pcap_gate_duration_value
# # TTLIN1 (detector trigger) feeds both blocks below - confirmed 2026-09-15 against the
# # omny-panda hardware, see docs/developer/panda_box_free_running_setup.md. PCAP.BITS0
# # is a shared 32-bit word; PandaBoxOMNY masks it to bit 0 (TTLIN1.VAL) before
# # publishing gate_detector_active. COUNTER1 counts TTLIN1's falling edge, i.e. one
# # increment per completed exposure; it also doubles as the on_complete signal. Confirmed
# # working end-to-end 2026-09-15; now under speed testing beyond the initial 100 Hz.
# PCAP.BITS0.Value: gate_detector_active
# COUNTER1.OUT.Value: frame_counter
# enabled: true
# readOnly: false
# softwareTrigger: false
# deviceTags:
# - ptycho_flomni
+4 -4
View File
@@ -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
@@ -24,5 +24,5 @@ fsh:
flomni:
- !include ../ptycho_flomni.yaml
machine:
- !include ../machine.yml
# machine:
# - !include ../machine.yml
@@ -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,31 @@ 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
# 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: on_request
userParameter:
pixel_calibration: 0.00044247787610619477 # mm/pixel, placeholder (flomni's value)
deviceTags:
- simulated_omny
rtx:
@@ -163,6 +187,7 @@ ofzpx:
- 0
port: 8081
sign: 1
sim_velocity: 10.0
sim_initial_position: -0.4317
enabled: true
onFailure: buffer
@@ -185,6 +210,7 @@ ofzpy:
- 0
port: 8081
sign: 1
sim_velocity: 10.0
sim_initial_position: 0.7944
enabled: true
onFailure: buffer
@@ -207,6 +233,7 @@ ofzpz:
- 0
port: 8081
sign: -1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -228,6 +255,7 @@ otransx:
- 0
port: 8081
sign: 1
sim_velocity: 100.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -249,7 +277,20 @@ otransy:
- 0
port: 8081
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.
# 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
enabled: true
onFailure: buffer
readOnly: false
@@ -271,6 +312,7 @@ otransz:
- 0
port: 8081
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -292,6 +334,7 @@ osamx:
- 0
port: 8082
sign: 1
sim_velocity: 10.0
sim_initial_position: -0.1
enabled: true
onFailure: buffer
@@ -313,6 +356,7 @@ osamz:
- 0
port: 8082
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
enabled: false
onFailure: buffer
@@ -334,6 +378,7 @@ oosay:
- 0
port: 8082
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -356,6 +401,7 @@ oosax:
- 0
port: 8082
sign: -1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -378,6 +424,7 @@ oosaz:
- 0
port: 8082
sign: -1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -400,6 +447,7 @@ oparkz:
- 0
port: 8082
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -421,7 +469,16 @@ oshuttleopen:
- 0
port: 8082
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
@@ -442,7 +499,20 @@ oshuttlealign:
- 0
port: 8082
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
# 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
@@ -463,6 +533,7 @@ osamy:
- 0
port: 8083
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -484,6 +555,7 @@ otracky:
- 0
port: 8083
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -505,7 +577,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 +599,7 @@ otrackz:
- 0
port: 8083
sign: -1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -547,6 +621,7 @@ oeyex:
- 0
port: 8083
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -568,6 +643,7 @@ oeyez:
- 0
port: 8083
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -589,6 +665,7 @@ oeyey:
- 0
port: 8083
sign: 1
sim_velocity: 10.0
sim_initial_position: 0
enabled: true
onFailure: buffer
@@ -652,7 +729,12 @@ oshield:
- 15.8
port: 3334
sign: 1
sim_initial_position: 0
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
# _otransfer_stage_to_ST()'s "oshield_current < 15" closed check.
sim_initial_position: 15.5
enabled: true
onFailure: buffer
readOnly: false
@@ -0,0 +1,171 @@
"""flomni_loop_settings: the operating settings of the flOMNI real-time loop as a baseline device.
Read at the start of every scan (readoutPriority: baseline; the flOMNI Fermat scan calls read_baseline_devices in
prepare_scan), so each scan file holds the loop settings it ran with: FZP on/off, FZP range, feedforward,
move-beyond, exposure during the motion, Abbe correction, detector trigger, tracker targets and thresholds
(definition: flOMNI FeedbackLoop/loopsettings/flomni.json; served by the web HMI on the loop machine, GET
/api/settings). Settings only, not all loop parameters (decision Mirko 2026-10-23).
Signals: one per setting (float, NaN when not available; names = the setting names), and
available 1 = read from the web HMI, 0 = not available (the scan continues; message says why)
message "" or the reason it is not available
unsaved number of settings whose live value differs from the .xmp (not saved as default)
inconsistent number of settings whose parameters disagree (e.g. FZP x on, y off)
rules_violated number of violated error rules
definition path and sha1 of the definition file on the loop machine
snapshot_json JSON: every setting (value, saved, unsaved, consistent, unit), the violated rules, the
scan state, the definition path and sha1
deviceConfig: token (the API token itself), else token_file (path to a file with it, default
~/.flomni_webhmi_token), optional url / host / timeout (default 2 s for the scan snapshot). Connection details:
devices/omny/webhmi_client.py.
flomni.rt_settings (the user commands) calls api_get / api_post of this device by RPC, so the token is needed
only here.
"""
import json
import math
import time
from ophyd import Component as Cpt
from ophyd import Device, Kind, Signal
from csaxs_bec.devices.omny.webhmi_client import WebHmiClient, WebHmiError
# the settings of flomni.json (2026-10-23); a setting added to the definition later is in snapshot_json only until
# it is added here
SETTINGS = (
"fzp_enable", "fzp_range_V_x", "fzp_range_V_y", "feedforward_enable", "move_beyond_um_x", "move_beyond_um_y",
"exposure_during_motion", "abbe_enable", "abbe_limit_um",
"trigger_tolerance_nm", "trigger_hold_ms", "trigger_min_delay_ms", "trigger_on_move",
"tracking_target_y", "tracking_target_z", "psd_threshold_low", "interferometer_threshold",
"interferometer_threshold_rot",
"sample_hold_fzp_V", "sample_hold_err_um", "sample_hold_zero",
"target_shaper_scale", "jump_max_um_x", "jump_max_um_y", # group motion, scan-mode profiles (2026-10-26)
"fzp_ff_priority", # FZP feedforward priority (2026-10-26)
"trigger_soft", "soft_exposure_ms", "soft_latency_ms", # soft trigger (2026-10-26)
)
RETRY_AFTER_FAILURE_S = 60.0 # web HMI not reachable: no new attempt for this long (a tomogram does not wait each time)
class FlomniLoopSettings(Device):
USER_ACCESS = ["show", "snapshot", "api_get", "api_post"]
available = Cpt(Signal, value=0, kind=Kind.normal)
message = Cpt(Signal, value="", kind=Kind.normal)
unsaved = Cpt(Signal, value=-1, kind=Kind.normal)
inconsistent = Cpt(Signal, value=-1, kind=Kind.normal)
rules_violated = Cpt(Signal, value=-1, kind=Kind.normal)
definition = Cpt(Signal, value="", kind=Kind.normal)
snapshot_json = Cpt(Signal, value="", kind=Kind.normal)
# one signal per setting
locals().update({_n: Cpt(Signal, value=math.nan, kind=Kind.normal) for _n in SETTINGS})
def __init__(self, prefix="", *, name, token=None, token_file=None, url=None, host=None, timeout=2.0,
device_manager=None, **kwargs):
super().__init__(prefix, name=name, **kwargs)
self._client = WebHmiClient(url=url, token=token, token_file=token_file, host=host,
device_manager=device_manager, timeout=timeout, client_name="BEC baseline")
# requests of flomni.rt_settings (RPC from the client, below): longer timeout, no back-off
self._api = WebHmiClient(url=url, token=token, token_file=token_file, host=host,
device_manager=device_manager, timeout=max(timeout, 10.0), client_name="BEC")
self._failed_at = None
self._last = None
def _unavailable(self, reason):
self.available.put(0)
self.message.put(str(reason))
for n in ("unsaved", "inconsistent", "rules_violated"):
getattr(self, n).put(-1)
self.definition.put("")
self.snapshot_json.put("")
for n in SETTINGS:
getattr(self, n).put(math.nan)
def update(self):
"""Read the settings from the web HMI into the signals. Never raises: not available -> available 0."""
now = time.time()
if self._failed_at is not None and now - self._failed_at < RETRY_AFTER_FAILURE_S:
return # keep the 'not available' values of the last failure
try:
snap = self._client.settings()
except WebHmiError as exc:
self._failed_at = now
self._unavailable(f"{exc} (next attempt after {RETRY_AFTER_FAILURE_S:.0f} s)")
return
except Exception as exc: # noqa: BLE001 (a broken answer must not stop a scan)
self._failed_at = now
self._unavailable(f"unexpected answer from the web HMI: {exc}")
return
self._failed_at = None
self._last = snap
values = {s["name"]: s for s in snap.get("settings", [])}
for n in SETTINGS:
v = values.get(n, {}).get("value")
getattr(self, n).put(float(v) if isinstance(v, (int, float)) else math.nan) # bool is an int too
self.unsaved.put(sum(1 for s in values.values() if s.get("unsaved")))
self.inconsistent.put(sum(1 for s in values.values() if not s.get("consistent", True)))
self.rules_violated.put(sum(1 for r in snap.get("rules", []) if not r.get("ok") and r.get("severity") == "error"))
self.definition.put(f"{snap.get('definition', '')} sha1 {snap.get('definition_sha1', '?')}")
missing = [n for n in values if n not in SETTINGS]
self.message.put(f"settings not stored as own signals (in snapshot_json): {', '.join(missing)}" if missing else "")
# compact: values and flags only (help texts and parameter mappings are in the definition, see its sha1)
compact = {
"instrument": snap.get("instrument"), "definition": snap.get("definition"),
"definition_sha1": snap.get("definition_sha1"), "scan": snap.get("scan"),
"settings": {n: {"value": s.get("value"), "saved": s.get("saved"), "unsaved": s.get("unsaved"),
"consistent": s.get("consistent"), "unit": s.get("unit", "")}
for n, s in values.items()},
"rules_violated": [{"name": r.get("name"), "severity": r.get("severity"), "message": r.get("message")}
for r in snap.get("rules", []) if not r.get("ok")],
}
self.snapshot_json.put(json.dumps(compact, separators=(",", ":")))
self.available.put(1)
def read(self):
self.update()
return super().read()
# ── RPC for flomni.rt_settings: the user commands go through this device, so the API token is needed only
# in its device config (device server), not on the client. Not called set / put: the device server treats
# those names as ophyd writes (refused on a readOnly device).
_API_PATHS = ("/api/settings", "/api/status")
def _check_path(self, path):
if not any(path == p or path.startswith(p + "/") for p in self._API_PATHS):
raise WebHmiError(f"path {path!r} not allowed (only /api/settings*, /api/status)")
def api_get(self, path: str = "/api/settings") -> dict:
"""GET a web HMI loop-settings endpoint; returns its JSON. Errors raise (passed to the client)."""
self._check_path(path)
return self._api._request(path)
def api_post(self, path: str, body: dict = None) -> dict:
"""POST to a web HMI loop-settings endpoint (plan / apply / save); returns its JSON."""
self._check_path(path)
return self._api._request(path, body or {})
def snapshot(self) -> dict:
"""Read now and return {setting: value} (empty when not available)."""
self._failed_at = None # an explicit request always tries
self.update()
if not self.available.get():
return {}
return {n: getattr(self, n).get() for n in SETTINGS}
def show(self):
"""Print the settings as they would be stored with a scan now."""
snap = self.snapshot()
if not snap:
print(f"loop settings not available: {self.message.get()}")
return
for n, v in snap.items():
print(f" {n:30s} {v:g}")
print(f"unsaved {self.unsaved.get()}, inconsistent {self.inconsistent.get()}, "
f"rules violated {self.rules_violated.get()}; {self.definition.get()}")
if self.message.get():
print(self.message.get())
+205 -78
View File
@@ -1,5 +1,29 @@
"""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:<slot>:USED 0 = free, 1 = sample present
FLOMNI-SAMPLE:<slot>:NAME sample name (long string, up to 256 chars)
FLOMNI-SAMPLE:<slot>:OWNER owner of the sample (free text, not necessarily an e-account)
FLOMNI-SAMPLE:<slot>:STATUS none / started / completed (measurement status)
FLOMNI-SAMPLE:<slot>:MEAS_DATE date of the measurement
FLOMNI-SAMPLE:<slot>:MEAS_ACCOUNT e-account active when the measurement status was last set
(2026-10-27; before, it went into OWNER if that was empty)
<slot> = 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<n>: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 +31,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 +72,41 @@ class FlomniSampleStorage(Device):
"get_sample_name",
"get_sample_owner",
"get_sample_name_and_owner",
"get_sample_status",
"get_sample_meas_date",
"get_sample_meas_account",
"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_meas_account = Dcpt(_slot_signals("MEAS_ACCOUNT", 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
)
sample_in_gripper_meas_account = Cpt(
EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:MEAS_ACCOUNT", string=True, auto_monitor=True
)
def __init__(self, prefix="", *, name, **kwargs):
@@ -60,72 +118,147 @@ 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,
"meas_account": self.sample_in_gripper_meas_account,
}[field]
group = {
"used": self.sample_placed,
"name": self.sample_names,
"owner": self.sample_owners,
"status": self.sample_status,
"meas_date": self.sample_meas_date,
"meas_account": self.sample_meas_account,
}[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, meas_account: 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("meas_account", slot_nr).set(meas_account 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 = "",
meas_account: str = "",
):
"""Place a sample record in slot `slot_nr` (0-20, or 100 = gripper)."""
self._write(slot_nr, 1, name, owner, status, meas_date, meas_account)
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, account: str = ""):
"""Record the measurement status/date of the sample in `slot_nr` and the
e-account it happened in (`account`, e.g. bec.active_account) in MEAS_ACCOUNT.
The owner is not touched (2026-10-27: owner and measurement account are
separate fields; before, the account was stored as owner when it was empty).
"""
if status not in STATUS_VALUES:
raise FlomniSampleStorageError(f"Invalid status {status!r}, expected one of {STATUS_VALUES}.")
self._sig("status", slot_nr).set(status).wait(timeout=5)
self._sig("meas_date", slot_nr).set(meas_date or "").wait(timeout=5)
if account:
self._sig("meas_account", slot_nr).set(account).wait(timeout=5)
def transfer_sample(self, from_slot: int, to_slot: int):
"""Move the complete record (name, owner, status, date, account) 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"], rec["meas_account"]
)
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_meas_account(self, slot_nr) -> str:
return self._str(self._sig("meas_account", slot_nr).get())
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 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),
"meas_account": self.get_sample_meas_account(slot_nr),
}
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)
account = self.get_sample_meas_account(slot_nr)
text += f" [measured: {status}{', ' + date if date else ''}{', ' + account if account else ''}]"
return text
t = PrettyTable()
t.title = "flOMNI sample storage"
@@ -141,21 +274,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")
+294 -183
View File
@@ -1,3 +1,35 @@
"""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:<slot>:USED 0 = free, 1 = sample present
OMNY-SAMPLE:<slot>:NAME sample name (long string, up to 256 chars)
OMNY-SAMPLE:<slot>:OWNER owner of the sample (free text, not necessarily an e-account)
OMNY-SAMPLE:<slot>:STATUS none / screened / started / completed (measurement status;
screened = was on the sample stage, no tomoscan started)
OMNY-SAMPLE:<slot>:MEAS_DATE date of the measurement (of the last status change)
OMNY-SAMPLE:<slot>:MEAS_ACCOUNT e-account active when the status was last raised
(2026-10-27; before, the account went into OWNER if it was empty)
<slot> (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_<container>:<pos>``
(VAL = placed, DESC = name) and ``...:<pos>: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 ophyd import Component as Cpt
@@ -6,11 +38,69 @@ 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_SCREENED = "screened" # was on the sample stage, no tomoscan started (2026-10-27)
STATUS_VALUES = (STATUS_NONE, STATUS_STARTED, STATUS_COMPLETED, STATUS_SCREENED)
FIELDS = ("used", "name", "owner", "status", "meas_date", "meas_account")
# 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",
@@ -23,7 +113,19 @@ 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",
"get_sample_status_in_samplestage",
"get_sample_status_in_gripper",
"get_sample_name",
"get_sample_owner",
"get_sample_name_and_owner",
"get_sample_status",
"get_sample_meas_date",
"get_sample_meas_account",
"get_sample_record",
"set_sample_measured",
"transfer_sample",
"is_sample_in_samplestage",
"set_shuttle_slot",
"unset_shuttle_slot",
@@ -36,259 +138,263 @@ class OMNYSampleStorage(Device):
SUB_VALUE = "value"
_default_sub = SUB_VALUE
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 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_meas_account = Dcpt(_sample_signals("meas_account"))
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)
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
)
# 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)
def set_sample_slot(self, container: str, slot_nr: int, name: str) -> bool:
if slot_nr > 20:
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
# ── 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,
"meas_account": self.sample_meas_account,
}[field]
return getattr(group, slot)
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)
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)
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)
elif container == "O":
getattr(self.sample_placed, f"sample{slot_nr}").set(1)
getattr(self.sample_names, f"sample{slot_nr}").set(name)
@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)
def unset_sample_slot(self, container: str, slot_nr: int) -> bool:
if slot_nr > 20:
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
# ── write ────────────────────────────────────────────────────────────────
def _write(self, slot: str, used: int, name: str, owner: str, status: str, meas_date: str, meas_account: 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("meas_account", slot).set(meas_account or "").wait(timeout=5)
self._sig("used", slot).set(int(used)).wait(timeout=5)
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("-")
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("-")
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("-")
elif container == "O":
getattr(self.sample_placed, f"sample{slot_nr}").set(0)
getattr(self.sample_names, f"sample{slot_nr}").set("-")
def set_sample_slot(
self,
container: str,
slot_nr: int,
name: str,
owner: str = "",
status: str = STATUS_NONE,
meas_date: str = "",
meas_account: 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, meas_account)
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_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 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_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 unset_sample_in_gripper(self):
self.unset_sample_slot(GRIPPER, 0)
def set_sample_in_gripper(self, name: str) -> bool:
self.sample_in_gripper.set(1)
self.sample_in_gripper_name.set(name)
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_gripper(self) -> bool:
self.sample_in_gripper.set(0)
self.sample_in_gripper_name.set("-")
def unset_sample_in_samplestage(self):
self.unset_sample_slot(STAGE, 0)
def set_sample_in_samplestage(self, name: str) -> bool:
self.sample_in_samplestage.set(1)
self.sample_in_samplestage_name.set(name)
def set_sample_measured(self, container: str, slot_nr: int, status: str, meas_date: str, account: str = ""):
"""Record the measurement status/date of the sample in (container, slot_nr)
(container "STAGE" for the sample stage) and the e-account it happened in
(`account`, e.g. bec.active_account) in MEAS_ACCOUNT. The owner is not
touched (2026-10-27: owner and measurement account are separate fields;
before, the account was stored as owner when the owner was empty).
"""
if status not in STATUS_VALUES:
raise OMNYSampleStorageError(f"Invalid status {status!r}, expected one of {STATUS_VALUES}.")
slot = slot_name(container, slot_nr)
self._sig("status", slot).set(status).wait(timeout=5)
self._sig("meas_date", slot).set(meas_date or "").wait(timeout=5)
if account:
self._sig("meas_account", slot).set(account).wait(timeout=5)
def unset_sample_in_samplestage(self) -> bool:
self.sample_in_samplestage.set(0)
self.sample_in_samplestage_name.set("-")
def transfer_sample(self, from_slot, to_slot):
"""Move the complete record (name, owner, status, date, account) 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"], rec["meas_account"])
self._write(src, 0, EMPTY_NAME, "", STATUS_NONE, "")
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 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)
def is_shuttle_slot_used(self, slot_nr: int) -> bool:
val = getattr(self.parking_placed, f"parking{slot_nr}").get()
return bool(val)
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)
# ── 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_meas_account(self, container: str, slot_nr: int = 0) -> str:
return self._str(self._sig("meas_account", 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),
"meas_account": self.get_sample_meas_account(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 = 0) -> str:
"""Sample name, plus owner/measured-status suffix when occupied --
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)
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)
date = self.get_sample_meas_date(container, slot_nr)
account = self.get_sample_meas_account(container, slot_nr)
suffix = f" ({owner})" if owner else ""
when = (f" {date}" if date else "") + (f" [{account}]" if account else "")
if status == STATUS_SCREENED:
suffix += " ○ screened" + when
elif status == STATUS_STARTED:
suffix += " ◐ started" + when
elif status == STATUS_COMPLETED:
suffix += " ✓ completed" + when
return name + suffix
def show_all(self):
t = PrettyTable()
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.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()}")
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():
print(f"\n Gripper: {self.get_sample_name_in_gripper()}\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):
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 +407,15 @@ 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.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)")
print(" dev.omny_samples.set_shuttle_slot('A',2)")
print(" omny.otransfer_help()")
+4 -60
View File
@@ -18,7 +18,6 @@ class OMNYTemperatures(Device):
"set_setpoint",
"show_all",
"help",
"_set_TEMP_default_setpoints",
"temperature_controller_TEMP_running",
"temperature_controller_CRYO_running",
]
@@ -96,65 +95,10 @@ class OMNYTemperatures(Device):
raise OMNYTemperaturesError("invalid type")
def _set_TEMP_default_setpoints(self):
self.set_setpoint("TEMP",8,-199.9)
time.sleep(0.1)
self.set_setpoint("TEMP",9,23)
time.sleep(0.1)
self.set_setpoint("TEMP",10,73.2)
time.sleep(0.1)
self.set_setpoint("TEMP",16,-199.9)
time.sleep(0.1)
self.set_setpoint("TEMP",17,23)
time.sleep(0.1)
self.set_setpoint("TEMP",18,26)
time.sleep(0.1)
self.set_setpoint("TEMP",19,26)
time.sleep(0.1)
self.set_setpoint("TEMP",20,26)
time.sleep(0.1)
self.set_setpoint("TEMP",21,23)
time.sleep(0.1)
self.set_setpoint("TEMP",22,-199.9)
time.sleep(0.1)
self.set_setpoint("TEMP",23,-199.9)
time.sleep(0.1)
self.set_setpoint("TEMP",24,-199.9)
time.sleep(0.1)
self.set_setpoint("TEMP",25,25)
time.sleep(0.1)
self.set_setpoint("TEMP",27,25)
time.sleep(0.1)
self.set_setpoint("TEMP",28,73.2)
time.sleep(0.1)
self.set_setpoint("TEMP",29,73.2)
time.sleep(0.1)
self.set_setpoint("TEMP",30,73.2)
time.sleep(0.1)
self.set_setpoint("TEMP",31,73.2)
time.sleep(0.1)
self.set_setpoint("TEMP",35,25)
time.sleep(0.1)
self.set_setpoint("TEMP",36,25)
time.sleep(0.1)
self.set_setpoint("TEMP",37,25)
time.sleep(0.1)
self.set_setpoint("TEMP",38,73.2)
time.sleep(0.1)
self.set_setpoint("TEMP",39,73.2)
time.sleep(0.1)
self.set_setpoint("TEMP",41,30)
time.sleep(0.1)
self.set_setpoint("TEMP",42,25)
time.sleep(0.1)
self.set_setpoint("TEMP",44,25)
time.sleep(0.1)
self.set_setpoint("TEMP",45,-199.9)
time.sleep(0.1)
self.set_setpoint("TEMP",46,73.2)
time.sleep(0.1)
self.set_setpoint("TEMP",47,73.2)
time.sleep(0.1)
# The default setpoints are stored only in the temperature controllers (EEPROM); the
# former _set_TEMP_default_setpoints() kept a second copy here (removed 2026-10-27,
# decision Mirko). The OMNY service tempcontrol.py seeds the :SET PVs from the
# controllers and writes a setpoint only when a :SET value is changed (heated channels).
def temperature_controller_TEMP_running(self):
time_diff = np.fabs(float(self.temperature_update_time.get()) - time.time())
+60 -62
View File
@@ -13,6 +13,7 @@ from ophyd_devices.utils.socket import SocketIO, raise_if_disconnected
from prettytable import PrettyTable
from csaxs_bec.devices.omny.rt.rt_ophyd import (
format_scan_timing,
RtCommunicationError,
RtError,
RtReadbackSignal,
@@ -45,12 +46,7 @@ class RtFlomniController(Controller):
"laser_tracker_check_signalstrength",
"laser_tracker_check_enabled",
"is_axis_moving",
"emitter_show_all",
"emitter_get",
"emitter_set_tracking_target_y",
"emitter_set_tracking_target_z",
"emitter_set_intensity_threshold_laser",
"emitter_set_psd_intensity_threshold_tracking_low",
"move_xyz",
]
def __init__(
@@ -398,62 +394,28 @@ class RtFlomniController(Controller):
return True
return False
def emitter_get(self) -> dict:
"""Read the runtime constants served by the ConstEmitter Orchestra module.
# emitter_* (ConstEmitter get / set through the CommunicationServer e-commands) removed 2026-10-23: the
# ConstEmitter is no longer in the loop; its values are loop settings, set through the web HMI
# (flomni.rt_settings, csaxs_bec/bec_ipython_client/plugins/flomni/flomni_realtime_system.py).
These are values that used to live in individual module parameter files
(tracking targets and intensity thresholds) and are now emitted centrally
so they can be tweaked live. The rotation-loop laser threshold
(intensity_threshold_rot_laser) is a pure startup constant and is
intentionally not reported here.
def move_xyz(self, x: float, y: float, z: float) -> None:
"""Move rtx, rty and rtz with one command (CommunicationServer p<x>,<y>,<z>).
Returns:
dict: current emitter values.
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.
"""
ret = self.socket_put_and_receive("es")
# remove trailing \n
ret = ret.split("\n")[0]
values = [float(val) for val in ret.split(",")]
if len(values) != 4:
raise RtCommunicationError(
f"Expected to receive 4 emitter values. Instead received {values}"
if self.laser_tracker_check_signalstrength() == "toolow":
raise RtError(
"The interferometer signal is too low for movements. Realignment required."
)
return {
"targetpos_tracking_y": values[0],
"targetpos_tracking_z": values[1],
"intensity_threshold_laser": values[2],
"PSD_intensity_threshold_tracking_low": values[3],
}
def emitter_show_all(self):
"""Print all ConstEmitter runtime constants as a table."""
t = PrettyTable()
t.title = "ConstEmitter Runtime Constants"
t.field_names = ["Name", "Value"]
for key, val in self.emitter_get().items():
t.add_row([key, val])
print(t)
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}")
def emitter_set_tracking_target_z(self, val: float) -> None:
"""Set the z tracking target position (PID_tracking_z)."""
self.socket_put(f"ez{val:.4f}")
def emitter_set_intensity_threshold_laser(self, val: float) -> None:
"""Set the shared laser intensity threshold.
Wired into the slew-rate limiters (x/y/z) and the PID loops
(x/y/z and the fast FZP x/y loops). Does not affect the
rotation loops, which use a separate fixed threshold.
"""
self.socket_put(f"et{val:.4f}")
def emitter_set_psd_intensity_threshold_tracking_low(self, val: float) -> None:
"""Set the low PSD intensity threshold used by the tracking PIDs (y and z)."""
self.socket_put(f"el{val:.4f}")
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 pid_y(self) -> float:
ret = float(self.socket_put_and_receive("G").strip())
@@ -858,6 +820,11 @@ class RtFlomniFlyer(Device):
)
self.average_stdeviations_x_st_fzp = 0
self.average_stdeviations_y_st_fzp = 0
# end-of-scan statistics: kickoff parameters from the scan, start / end of the scan
self._exp_time = None
self._frames_per_trigger = 1
self._t_kickoff = None
self._t_scan_end = None
self.readout_thread = None
self.scan_done_event = threading.Event()
self.scan_done_event.set()
@@ -898,6 +865,8 @@ class RtFlomniFlyer(Device):
signals = self._get_signals_from_table(return_table)
self.data.set(signals)
self._t_scan_end = time.monotonic() # scan finished in the RT loop (statistics)
if self.shutdown_event.wait(0.05):
logger.info("Shutdown event set, stopping readout.")
# if we are here, the shutdown_event is set. We can exit the readout loop.
@@ -919,11 +888,30 @@ class RtFlomniFlyer(Device):
status.set_finished()
self.progress.put(value=read_counter, max_value=number_of_positions_planned, done=True)
logger.info(
"Flomni statistics: Average of all standard deviations: x"
f" {self.average_stdeviations_x_st_fzp/read_counter*1000:.1f}, y"
f" {self.average_stdeviations_y_st_fzp/read_counter*1000:.1f}"
self._report_statistics(read_counter)
def _report_statistics(self, read_counter: int):
"""At the end of a scan: mean of the per-point standard deviations (sample vs FZP during each exposure,
RT_sampler) to the device-server log and to the BEC command line (Mirko 2026-10-26)."""
if read_counter == 0:
logger.info("Flomni statistics: no scan points read, no average of the standard deviations")
return
msg = (
"flOMNI scan statistics: average of the standard deviations during the exposures"
f" ({read_counter} points, sample vs FZP): x"
f" {self.average_stdeviations_x_st_fzp/read_counter*1000:.1f} nm, y"
f" {self.average_stdeviations_y_st_fzp/read_counter*1000:.1f} nm"
+ format_scan_timing(read_counter, self._t_kickoff, self._t_scan_end, self._exp_time,
self._frames_per_trigger)
)
logger.info(msg)
try:
self.controller.device_manager.connector.send_client_info(
msg, scope="flomni_scan_statistics", show_asap=True
)
except Exception as exc: # pylint: disable=broad-except
# the statistics are information only: never fail the readout for them
logger.warning(f"Flomni statistics not sent to the client: {exc}")
def _get_signals_from_table(self, return_table) -> dict:
self.average_stdeviations_x_st_fzp += float(return_table[4])
@@ -957,12 +945,22 @@ class RtFlomniFlyer(Device):
)
self.readout_thread.start()
def configure(self, d: dict) -> tuple:
"""The device server calls this with the kickoff parameters of the scan before kickoff():
exp_time / frames_per_trigger for the end-of-scan statistics (not passed: no exposure part)."""
d = dict(d or {})
self._exp_time = d.pop("exp_time", None)
self._frames_per_trigger = d.pop("frames_per_trigger", 1) or 1
return super().configure(d) if d else ({}, {})
def kickoff(self) -> DeviceStatus:
self.shutdown_event.clear()
self.scan_done_event.clear()
while not self.controller._min_scan_buffer_reached and not self.shutdown_event.wait(0.001):
...
self.controller.start_scan()
self._t_kickoff = time.monotonic()
self._t_scan_end = None
self.shutdown_event.wait(0.1)
status = DeviceStatus(self)
status.set_finished()
+43 -3
View File
@@ -13,7 +13,7 @@ from ophyd_devices.utils.controller import Controller, retry_once, threadlocked
from ophyd_devices.utils.socket import SocketIO, SocketSignal, raise_if_disconnected
from prettytable import PrettyTable
from csaxs_bec.devices.omny.rt.rt_ophyd import RtCommunicationError, RtError
from csaxs_bec.devices.omny.rt.rt_ophyd import RtCommunicationError, RtError, format_scan_timing
logger = bec_logger.logger
@@ -750,6 +750,11 @@ class RtLamniFlyer(Device):
self.average_stdeviations_x_st_fzp = 0
self.average_stdeviations_y_st_fzp = 0
self.average_lamni_angle = 0
# end-of-scan statistics: kickoff parameters from the scan, start / end of the scan
self._exp_time = None
self._frames_per_trigger = 1
self._t_kickoff = None
self._t_scan_end = None
self.readout_thread = None
self.scan_done_event = threading.Event()
self.scan_done_event.set()
@@ -765,12 +770,22 @@ class RtLamniFlyer(Device):
)
self.readout_thread.start()
def configure(self, d: dict) -> tuple:
"""The device server calls this with the kickoff parameters of the scan before kickoff():
exp_time / frames_per_trigger for the end-of-scan statistics (not passed: no exposure part)."""
d = dict(d or {})
self._exp_time = d.pop("exp_time", None)
self._frames_per_trigger = d.pop("frames_per_trigger", 1) or 1
return super().configure(d) if d else ({}, {})
def kickoff(self) -> DeviceStatus:
self.shutdown_event.clear()
self.scan_done_event.clear()
while not self.controller._min_scan_buffer_reached and not self.shutdown_event.wait(0.001):
...
self.controller.start_scan()
self._t_kickoff = time.monotonic()
self._t_scan_end = None
self.shutdown_event.wait(0.1)
status = DeviceStatus(self)
status.set_finished()
@@ -832,6 +847,8 @@ class RtLamniFlyer(Device):
signals = self._get_signals_from_table(return_table)
self.data.set(signals)
self._t_scan_end = time.monotonic() # scan finished in the RT loop (statistics)
if self.shutdown_event.wait(0.05):
logger.info("Shutdown event set, stopping readout.")
# if we are here, the shutdown_event is set. We can exit the readout loop.
@@ -853,9 +870,32 @@ class RtLamniFlyer(Device):
status.set_finished()
self.progress.put(value=read_counter, max_value=number_of_positions_planned, done=True)
logger.info(
f"LamNI statistics: Average of all standard deviations: x {self.average_stdeviations_x_st_fzp}, y {self.average_stdeviations_y_st_fzp}, angle {self.average_lamni_angle}."
self._report_statistics(read_counter)
def _report_statistics(self, read_counter: int):
"""At the end of a scan: mean of the per-point standard deviations (sample vs FZP during each exposure,
RT_sampler) and the mean angle to the device-server log and to the BEC command line (Mirko 2026-10-26,
as flOMNI). Before, the log line printed the sums, not the averages."""
if read_counter == 0:
logger.info("LamNI statistics: no scan points read, no average of the standard deviations")
return
msg = (
"LamNI scan statistics: average of the standard deviations during the exposures"
f" ({read_counter} points, sample vs FZP): x"
f" {self.average_stdeviations_x_st_fzp/read_counter*1000:.1f} nm, y"
f" {self.average_stdeviations_y_st_fzp/read_counter*1000:.1f} nm;"
f" average angle {self.average_lamni_angle/read_counter:.4f}"
+ format_scan_timing(read_counter, self._t_kickoff, self._t_scan_end, self._exp_time,
self._frames_per_trigger)
)
logger.info(msg)
try:
self.controller.device_manager.connector.send_client_info(
msg, scope="lamni_scan_statistics", show_asap=True
)
except Exception as exc: # pylint: disable=broad-except
# the statistics are information only: never fail the readout for them
logger.warning(f"LamNI statistics not sent to the client: {exc}")
def _get_signals_from_table(self, return_table) -> dict:
self.average_stdeviations_x_st_fzp += float(return_table[5])
+41 -18
View File
@@ -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
@@ -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
@@ -673,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"]
@@ -923,6 +938,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):
@@ -1251,7 +1272,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 = (
+20
View File
@@ -14,6 +14,26 @@ from ophyd_devices.utils.socket import SocketSignal
logger = bec_logger.logger
def format_scan_timing(n_points: int, t_start, t_end, exp_time=None, frames_per_trigger=1) -> str:
"""Time split of a flyer scan for the end-of-scan statistics (Mirko 2026-10-26, BEC side only): scan time from
the kickoff to the end of the scan in the RT loop (wall clock of the device server), exposure = points x
requested exp_time x frames_per_trigger (not measured: the detector may expose longer), overhead = the rest
(positioning, trigger, detector readout). '' when the times are missing."""
if not n_points or t_start is None or t_end is None or t_end <= t_start:
return ""
total = t_end - t_start
txt = f"; scan time {total:.1f} s ({total / n_points * 1000:.0f} ms/point)"
if exp_time:
expo = n_points * float(exp_time) * int(frames_per_trigger or 1)
over = total - expo
txt += (
f": exposure {expo:.1f} s ({expo / total * 100:.0f} %, requested {float(exp_time) * 1000:g} ms"
f" x {int(frames_per_trigger or 1)}), overhead {over:.1f} s ({over / n_points * 1000:.0f} ms/point:"
" positioning, trigger, detector)"
)
return txt
class RtCommunicationError(ControllerCommunicationError):
pass
+15 -57
View File
@@ -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()
+116
View File
@@ -0,0 +1,116 @@
"""Client of the web HMI on an OMNY-family loop machine (RT-system webhmi): REST requests with the API token.
Shared by the BEC client plugin (flomni.rt_settings, bec_ipython_client/plugins/flomni/flomni_realtime_system.py)
and the baseline device FlomniLoopSettings (devices/omny/flomni_loop_settings.py), so both talk to the web HMI the
same way. Standard library only (runs in the IPython client and in the device server).
URL: explicit `url`, else env FLOMNI_WEBHMI_URL, else http://<host>:8080 with <host> = `host`, else the host of the
rtx device config, else mpc2844.psi.ch. With the default URL a request that cannot reach port 8080 is repeated on
port 80 of the same host (nftables redirect 80 -> 8080 on the loop machine, the port the PSI gateway passes); that URL
is then kept. An explicit URL gets no fallback.
Token: `token` (the value, e.g. from a device config that is not committed), else `token_file`, else env
FLOMNI_WEBHMI_TOKEN_FILE, else ~/.flomni_webhmi_token. The same line as the web HMI's api_token_file on the loop
machine (flOMNI FeedbackLoop/loopsettings/README.md). Never commit a token.
"""
import json
import os
import urllib.error
import urllib.request
DEFAULT_TOKEN_FILE = "~/.flomni_webhmi_token"
DEFAULT_PORT = 8080
DEFAULT_HOST = "mpc2844.psi.ch"
class WebHmiError(Exception):
"""A request was refused (rules, scan running) or the web HMI could not be reached (.unreachable = True)."""
unreachable = False
class WebHmiClient:
def __init__(self, url=None, token=None, token_file=None, host=None, device_manager=None, timeout=5.0,
client_name="BEC"):
self._url = url
self._fallback = None # port-80 URL that answered when 8080 did not (kept for the session)
self._token_value = token
self._token_file = token_file
self._host = host
self._device_manager = device_manager
self.timeout = timeout
self.client_name = client_name
@property
def url(self):
if self._fallback:
return self._fallback
if self._url:
return self._url.rstrip("/")
env = os.environ.get("FLOMNI_WEBHMI_URL")
if env:
return env.rstrip("/")
host = self._host or DEFAULT_HOST
if not self._host:
try: # the host of the rt controller (device config of rtx)
host = self._device_manager.devices.rtx._config["deviceConfig"]["host"] or host
except Exception: # noqa: BLE001
pass
return f"http://{host}:{DEFAULT_PORT}"
def _explicit_url(self):
return bool(self._url or os.environ.get("FLOMNI_WEBHMI_URL"))
def _token(self):
if self._token_value:
return str(self._token_value).strip()
path = os.path.expanduser(self._token_file or os.environ.get("FLOMNI_WEBHMI_TOKEN_FILE") or DEFAULT_TOKEN_FILE)
try:
with open(path) as f:
tok = f.readline().strip()
except OSError:
raise WebHmiError(
f"No API token for the web HMI: {path} not readable. Copy the token from the loop machine "
"(~/loop-data/webhmi/api_token of the user control) into this file (mode 600), or set "
"FLOMNI_WEBHMI_TOKEN_FILE.") from None
if not tok:
raise WebHmiError(f"API token file {path} is empty")
return tok
def _request(self, path, body=None):
try:
return self._request_once(self.url, path, body)
except WebHmiError as exc:
# default URL on port 8080 not reachable: the loop machine also serves the web HMI on port 80
if exc.unreachable and not self._explicit_url() and not self._fallback:
alt = self.url.replace(f":{DEFAULT_PORT}", "")
r = self._request_once(alt, path, body)
self._fallback = alt
return r
raise
def _request_once(self, url, path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url + path, data=data, method="GET" if body is None else "POST",
headers={"Authorization": "Bearer " + self._token(),
"X-Client": self.client_name, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self.timeout) as r:
return json.load(r)
except urllib.error.HTTPError as exc:
try:
detail = json.load(exc).get("detail", exc.reason)
except Exception: # noqa: BLE001
detail = exc.reason
if exc.code == 401:
detail = f"{detail} (API token not accepted by the web HMI: compare with its api_token_file)"
raise WebHmiError(f"web HMI {url}{path}: {exc.code} {detail}") from None
except (urllib.error.URLError, OSError) as exc:
err = WebHmiError(f"web HMI {url} not reachable: {exc}")
err.unreachable = True
raise err from None
def settings(self) -> dict:
"""GET /api/settings: settings, rules, scan state, profiles."""
return self._request("/api/settings")
+93 -2
View File
@@ -34,6 +34,49 @@ 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
# 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
# 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)."""
@@ -56,6 +99,21 @@ 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
# 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()
@@ -138,7 +196,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()
@@ -179,6 +237,14 @@ 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"]
)
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:
@@ -235,6 +301,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):
@@ -466,6 +539,14 @@ 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
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"):
@@ -475,13 +556,19 @@ 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])
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["):
@@ -584,6 +671,8 @@ def _sim_state_kwargs(
sim_encpermm=None,
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:
@@ -595,6 +684,8 @@ 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,
"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,
+132 -4
View File
@@ -21,13 +21,18 @@ 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
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
@@ -71,6 +76,8 @@ class SimOMNYGalilMotor(OMNYGalilMotor):
sim_limit_low_active=None,
sim_limit_high_active=None,
sim_variables=None,
sim_quirky_positive_trips_low_limit=None,
sim_high_limit_trip_position=None,
**kwargs,
):
super().__init__(
@@ -111,6 +118,8 @@ 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,
sim_high_limit_trip_position=sim_high_limit_trip_position,
),
)
@@ -253,10 +262,25 @@ 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
storage.unset_sample_in_gripper()
# 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 (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))
# 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):
@@ -266,16 +290,120 @@ 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 the per-channel setpoints of the former
# OMNYTemperatures._set_TEMP_default_setpoints() (removed 2026-10-27: the defaults live
# only in the controllers; values checked against the controllers on mpc3217 the same
# day -- 73.2 K here vs. -199.9 degC = 73.25 K there).
# 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),
}
# 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."""
"""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) and the 3 known "CRYO" channels (see
_CRYO_CHANNELS above).
"""
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)
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
+1 -1
View File
@@ -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:<slot>:<field> 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
+64 -1
View File
@@ -238,7 +238,11 @@ class FlomniFermatScan(ScanBase):
"""
# send off the flyer
self.actions.kickoff(device=self.dev.rt_positions)
self.actions.kickoff(
device=self.dev.rt_positions,
# for the end-of-scan statistics of the flyer (exposure vs overhead)
parameters={"exp_time": self.exp_time, "frames_per_trigger": self.frames_per_trigger},
)
# start the readout loop of the flyer
status = self.actions.complete(device=self.dev.rt_positions, wait=False)
@@ -341,6 +345,9 @@ class FlomniFermatScan(ScanBase):
"""
dev = self.dev
# Trigger source of the flOMNI loop: hardware at the beamline, soft otherwise
self.set_trigger_source()
# Prepare DDG1
dev.ddg1.set_trigger(TRIGGERSOURCE.EXT_RISING_EDGE.value)
@@ -373,6 +380,62 @@ class FlomniFermatScan(ScanBase):
"Signal strength of the laser tracker is too low for scanning. Realignment required!"
)
def at_beamline(self) -> tuple[bool, str]:
"""
Is this session at the beamline? True when ddg1 is configured with a real (not simulated) device class
(flOMNI soft trigger, FeedbackLoop/soft_trigger.md; ddg1 only, Mirko 2026-10-27). A failing check counts as
beamline, so the hardware trigger is the fallback.
Returns:
(at_beamline, reason)
"""
try:
devices = self.device_manager.devices
if "ddg1" in devices:
cls = str((getattr(devices["ddg1"], "_config", None) or {}).get("deviceClass", ""))
if ".sim." not in cls and "Simulated" not in cls:
return True, f"ddg1 is {cls or 'not simulated'}"
return False, "ddg1 simulated or absent"
except Exception as exc: # pylint: disable=broad-except
return True, f"beamline check failed ({exc}), treated as beamline"
def set_trigger_source(self):
"""
Set the trigger source of the flOMNI loop (loop setting trigger_soft, web HMI via flomni_loop_settings):
at the beamline the hardware trigger (the loop's default); otherwise the soft trigger with the exposure
exp_time x frames_per_trigger, so a scan runs without detector or trigger PLC. At the beamline the scan is
aborted if the hardware trigger cannot be confirmed (with the soft trigger the detector would not be
triggered while flOMNI exposes internally).
"""
at_bl, reason = self.at_beamline()
if at_bl:
changes = {"trigger_soft": 0}
else:
changes = {"trigger_soft": 1,
"soft_exposure_ms": float(self.exp_time) * float(self.frames_per_trigger) * 1000.0}
try:
res = self.dev.flomni_loop_settings.api_post(
"/api/settings/apply", {"changes": changes, "profile": None, "adjust": False}
)
errors = [] if res and res.get("ok") else list((res or {}).get("errors") or ["no answer"])
except Exception as exc: # pylint: disable=broad-except
errors = [str(exc)]
source = "hardware" if at_bl else "soft"
if errors:
text = f"flOMNI trigger source {source} ({reason}) not set: {'; '.join(errors)}"
if at_bl:
raise ScanAbortion(text + ". At the beamline the hardware trigger must be confirmed.")
logger.warning(text)
self.device_manager.connector.raise_alarm(
severity=Alarms.WARNING,
info=messages.ErrorInfo(
error_message=text, compact_error_message=text, exception_type="TriggerSource",
device="flomni_loop_settings",
),
)
return
logger.info(f"flOMNI trigger source: {source} ({reason}), {changes}")
def flomni_rotation(self, angle: float):
"""
Rotate flomni to the requested angle.
+5 -1
View File
@@ -218,7 +218,11 @@ class LamniFermatScan(ScanBase):
This is where the main scan logic should be implemented.
"""
self.actions.kickoff(device="rt_positions")
self.actions.kickoff(
device="rt_positions",
# for the end-of-scan statistics of the flyer (exposure vs overhead)
parameters={"exp_time": self.exp_time, "frames_per_trigger": self.frames_per_trigger},
)
status = self.actions.complete(device="rt_positions", wait=False)
+61 -17
View File
@@ -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.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
@@ -46,6 +48,9 @@ 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 = {
"Scan Parameters": [
"fovx",
@@ -70,7 +75,7 @@ class OmnyFermatScan(ScanBase):
step: Annotated[float, ScanArgument(display_name="Step", description="Step size.", units=Units.µm, example=1)],
zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z.", units=Units.µm, example=0)],
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.1,
frames_per_trigger: DefaultArgType.FramesPerTrigger = 1,
**kwargs,
@@ -87,7 +92,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.
@@ -114,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)
self.omny_rotation_status = None
@@ -134,15 +146,23 @@ class OmnyFermatScan(ScanBase):
step=self.step,
spiral_type=0,
center=False,
cenx=self.cenx,
ceny=self.ceny,
zshift=self.zshift,
)
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
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:
@@ -267,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,
@@ -276,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,
seed: int | None = None,
):
"""
@@ -289,6 +312,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
seed(int | None): seed for the per-call random sub-step offset (see below).
Pass a fixed value for reproducible output (e.g. in tests); leave as None
to draw a fresh offset every call.
@@ -305,7 +331,7 @@ class OmnyFermatScan(ScanBase):
length_axis2 = np.abs(m2_stop - m2_start)
n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step)
z_pos = self.zshift
z_pos = zshift
# Randomize the sampling lattice's sub-step phase on every call (i.e. every
# projection), matching the legacy spec behaviour: this decorrelates position-tied
@@ -323,21 +349,39 @@ class OmnyFermatScan(ScanBase):
continue
if abs(y) > length_axis2 / 2:
continue
positions.append([x + self.cenx, y + self.ceny, z_pos])
positions.append([x + cenx, y + ceny, z_pos])
left_lower_corner = [
min(m1_start, m1_stop) + self.cenx,
min(m2_start, m2_stop) + self.ceny,
min(m1_start, m1_stop) + cenx,
min(m2_start, m2_stop) + ceny,
z_pos,
]
right_upper_corner = [
max(m1_start, m1_stop) + self.cenx,
max(m2_start, m2_stop) + self.ceny,
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)
@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()
@@ -366,20 +410,20 @@ 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:
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())
+32 -7
View File
@@ -185,8 +185,10 @@ Following functions exist to move the optics in and out, with self-explaining na
### Interferometer
All commands of the real-time system start with `rt_`: `flomni.rt_<TAB>` lists them.
The position feedback in flOMNI is controlled in closed loop to an interferometric position measurement. To show the signal of the interferometers:
`flomni.show_signal_strength_interferometer()`
`flomni.rt_signal_strength()` (or the former name `flomni.show_signal_strength_interferometer()`)
Typical values with proper alignment, sample stage at the measurement position and laser tracker running are in the range of
| Axis | Value |
@@ -201,20 +203,34 @@ Typical values with proper alignment, sample stage at the measurement position a
The horizontal interferometer is built according to the [tracking interferometer](https://www.dora.lib4ri.ch/psi/islandora/object/psi:7524). The tracker can be controlled by following commands. During commissioning of the setup it is worthy to check the status, but during general operation these commands should not be required.
- `flomni.laser_tracker_show_all()`
- `flomni.laser_tracker_on()`
- `flomni.laser_tracker_off()`
- `flomni.rt_laser_tracker_show_all()`
- `flomni.rt_laser_tracker_on()`
- `flomni.rt_laser_tracker_off()`
- `flomni.rt_laser_tracker_tweak()` (arrow keys move the tracking targets)
#### Interferometer feedback commands
The closed loop control of the Piezo stages can be controlled by
- `flomni.feedback_feedback_enable_with_reset()`.
- `flomni.rt_feedback_enable_with_reset()`.
_There is also an enable without reset, which is used during tomography scans, when using coarse stages to increase the scan range. It should not be required to use manually._
- `flomni.feedback_disable()`
- `flomni.feedback_status()`
- `flomni.rt_feedback_disable()`
- `flomni.rt_feedback_status()`
The former names `flomni.feedback_enable_with_reset()`, `feedback_enable_without_reset()`, `feedback_disable()`
and `feedback_status()` still work.
#### 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 must stay within a tolerance in x and in y for a short hold time (standard 10 nm, 3 ms). The local
contact can choose a coarser tolerance for fast low-resolution scans or a finer one for the highest resolution.
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.
- `dev.rtx.controller.move_xyz(x, y, z)` — moves rtx, rty and rtz with one command
### Scanning in 2D and sample alignment
@@ -758,6 +774,15 @@ removed later.
[See short version](flomni.md#sample-storage-and-transfer)
### Real-time loop settings
The operating settings of the real-time control loop (FZP scanner, detector trigger, laser tracker) are set by the
local contact (`flomni.rt_settings`, including the laser tracker targets and thresholds
`flomni.rt_settings.tracking_*`); please do not change them. Every scan stores the settings it ran with (baseline device
`flomni_loop_settings`).
Details for beamline scientists: `csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-realtime_system.md`.
### Heater
The [heater](https://www.dora.lib4ri.ch/psi/islandora/object/psi:45302) can be moved up and down by
+45 -29
View File
@@ -11,6 +11,8 @@ The sample storage, shuttles and parking positions are described in detail [here
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.<br>
_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)`._
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_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
@@ -56,20 +58,13 @@ 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()`.
### 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:
![OMNY sample shuttle](omny_shuttle.png)
@@ -83,34 +78,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_storage()`
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 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.
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_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.
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 |
| --- | --- |
@@ -133,6 +121,34 @@ 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 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()`
Each position is one record on the OMNY soft IOC, `OMNY-SAMPLE:<slot>:USED/NAME/OWNER/STATUS/MEAS_DATE` (slots `A1`…`C6`, `P10`…`P101`, `STAGE`, `GRIPPER`, `PARK1`…`PARK6`).
### 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.
+1 -1
View File
@@ -103,7 +103,7 @@ def build_flomni(services_config_path):
if abs(fsamx.readback.get() - fsamx_in) > 0.3:
fsamx.read_only = False
flomni_module.umv(fsamx, fsamx_in)
flomni.feedback_enable_with_reset()
flomni.rt_feedback_enable_with_reset()
return bec, flomni
+39 -17
View File
@@ -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)
@@ -417,36 +425,50 @@ 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 '- | <owner>'",
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 stores the measurement account separately, the owner is kept
samples.set_sample_slot(6, "owned_sample", "Group XY")
samples.set_sample_measured(6, "started", "2026-10", account="e99999")
check(
samples.get_sample_owner(6) == "Group XY" and samples.get_sample_meas_account(6) == "e99999",
"set_sample_measured stores the account in MEAS_ACCOUNT, owner untouched",
)
samples.unset_sample_slot(6)
frame_a = ids_cam.cam.get_image_data()
frame_b = ids_cam.cam.get_image_data()
+25 -3
View File
@@ -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),
@@ -230,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(
@@ -255,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 ---")
@@ -277,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")
+1 -1
View File
@@ -25,7 +25,7 @@ dependencies = [
"bec_widgets",
"zmq",
"opencv-python",
"dectris-compression", # for JFJ preview stream decompression
"dectris-compression~=0.3.1", # for JFJ preview stream decompression
"cbor2~=5.9.0",
]
@@ -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
@@ -0,0 +1,223 @@
"""Tests for flomni.rt_settings (flomni_realtime_system.py): the client of the web HMI loop-settings API on the
flOMNI loop machine. A small local HTTP server stands in for the web HMI (RT-system webhmi, /api/settings*); it checks
the token header and answers like the real server. No BEC service, no loop."""
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_realtime_system import (
FlomniRealtimeSystem,
RealtimeSystemError,
)
TOKEN = "test-token-0123456789abcdef"
SNAP = {
"instrument": "flOMNI", "note": "Live only.", "scan": {"mode": 0, "active": False, "text": ""},
"groups": [{"name": "trigger", "title": "Detector trigger"}, {"name": "tracking", "title": "Tracker"}],
"settings": [
{"name": "trigger_tolerance_nm", "group": "trigger", "unit": "nm", "value": 10.0, "saved": 10.0,
"unsaved": False, "consistent": True, "issues": [], "info": None},
{"name": "tracking_target_y", "group": "tracking", "unit": "", "value": 0.13, "saved": 0.13,
"unsaved": False, "consistent": True, "issues": [], "info": None},
{"name": "tracking_target_z", "group": "tracking", "unit": "", "value": 0.2, "saved": 0.2,
"unsaved": False, "consistent": True, "issues": [], "info": None},
],
"rules": [], "profiles": [{"name": "production", "title": "Production", "help": "", "values": {}}],
}
class _Handler(BaseHTTPRequestHandler):
requests = []
def log_message(self, *args):
pass
def _reply(self, code, body):
data = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _auth(self):
if self.headers.get("Authorization") != "Bearer " + TOKEN:
self._reply(401, {"detail": "login required"})
return False
return True
def do_GET(self):
if not self._auth():
return
if self.path == "/api/settings":
self._reply(200, SNAP)
elif self.path == "/api/settings/unsaved":
self._reply(200, [])
else:
self._reply(404, {"detail": "not found"})
def do_POST(self):
if not self._auth():
return
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
_Handler.requests.append((self.path, body, self.headers.get("X-Client")))
changes = body.get("changes", {})
if "move_beyond_um_x" in changes:
self._reply(200, {"ok": False, "errors": ["Move-beyond x does not fit"], "warnings": [], "changes": [],
"params": [], "profile": None, "applied": False})
return
ch = [{"name": k, "old": 0, "new": v, "adjusted": None} for k, v in changes.items()]
self._reply(200, {"ok": True, "errors": [], "warnings": [], "changes": ch,
"params": [{"module": "M", "param": "p", "old": [0], "new": [1]}] if ch or body.get("profile") else [],
"profile": body.get("profile"), "applied": self.path.endswith("apply"),
"note": "Live only: save as default to keep them."})
@pytest.fixture
def server(tmp_path):
_Handler.requests = []
srv = HTTPServer(("127.0.0.1", 0), _Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
tok = tmp_path / "token"
tok.write_text(TOKEN + "\n")
yield f"http://127.0.0.1:{srv.server_address[1]}", str(tok), tmp_path
srv.shutdown()
def test_read_and_shortcuts(server, capsys):
url, tok, _ = server
rts = FlomniRealtimeSystem(url=url, token_file=tok)
assert rts.get("trigger_tolerance_nm") == 10.0
assert rts.tracking_targets() == (0.13, 0.2)
rts.show()
out = capsys.readouterr().out
assert "trigger_tolerance_nm" in out and "Live only." in out
with pytest.raises(RealtimeSystemError):
rts.get("nosuch")
def test_set_sends_changes_and_token(server, capsys):
url, tok, _ = server
rts = FlomniRealtimeSystem(url=url, token_file=tok)
r = rts.trigger_set_tolerance_nm(5)
assert r["applied"]
path, body, client = _Handler.requests[-1]
assert path == "/api/settings/apply" and body == {"changes": {"trigger_tolerance_nm": 5}, "profile": None,
"adjust": False} and client == "BEC"
assert "Live only" in capsys.readouterr().out
rts.set(tracking_target_y=0.2, quiet=True)
assert capsys.readouterr().out == ""
rts.apply_profile("production", adjust=True)
assert _Handler.requests[-1][1] == {"changes": {}, "profile": "production", "adjust": True}
rts.check(fzp_enable=0, adjust=True)
assert _Handler.requests[-1][0] == "/api/settings/plan"
def test_refused_raises(server):
url, tok, _ = server
with pytest.raises(RealtimeSystemError, match="does not fit"):
FlomniRealtimeSystem(url=url, token_file=tok).set(move_beyond_um_x=0.5)
with pytest.raises(RealtimeSystemError, match="nothing to set"):
FlomniRealtimeSystem(url=url, token_file=tok).set()
def test_token_errors(server):
url, _, tmp = server
with pytest.raises(RealtimeSystemError, match="No API token"):
FlomniRealtimeSystem(url=url, token_file=str(tmp / "missing")).get()
bad = tmp / "bad"
bad.write_text("wrong-token-xxxxxxxxxxxx")
with pytest.raises(RealtimeSystemError, match="401"):
FlomniRealtimeSystem(url=url, token_file=str(bad)).get()
def test_unreachable():
with pytest.raises(RealtimeSystemError, match="not reachable"):
FlomniRealtimeSystem(url="http://127.0.0.1:9", token_file=__file__, timeout=1).get()
def test_port_80_fallback(monkeypatch):
"""Default URL (rtx host, port 8080) not reachable: the same request goes to port 80 (nftables redirect on the
loop machine) and that URL is kept; an explicit URL gets no fallback."""
monkeypatch.delenv("FLOMNI_WEBHMI_URL", raising=False)
dm = type("DM", (), {})()
dm.devices = type("D", (), {})()
dm.devices.rtx = type("R", (), {"_config": {"deviceConfig": {"host": "loophost"}}})()
rts = FlomniRealtimeSystem(device_manager=dm, token_file=__file__)
calls = []
def once(url, path, body=None):
calls.append(url)
if url.endswith(":8080"):
err = RealtimeSystemError("not reachable")
err.unreachable = True
raise err
return {"ok": True}
monkeypatch.setattr(rts, "_request_once", once)
assert rts._request("/api/settings") == {"ok": True}
assert calls == ["http://loophost:8080", "http://loophost"] and rts.url == "http://loophost"
explicit = FlomniRealtimeSystem(url="http://other:8080", token_file=__file__)
monkeypatch.setattr(explicit, "_request_once", once)
with pytest.raises(RealtimeSystemError):
explicit._request("/api/settings")
def test_routes_through_the_device():
"""With the BEC device flomni_loop_settings present and enabled, requests go to it by RPC (api_get / api_post),
no token on the client; errors of the device come back as RealtimeSystemError."""
calls = []
class Dev:
enabled = True
def api_get(self, path):
calls.append(("get", path))
return {"settings": [{"name": "trigger_tolerance_nm", "value": 10.0}]}
def api_post(self, path, body):
calls.append(("post", path, body))
if "move_beyond_um_x" in body.get("changes", {}):
raise RuntimeError("web HMI not reachable")
return {"ok": True, "errors": [], "warnings": [], "changes": [], "params": [], "applied": False}
dm = type("DM", (), {})()
dm.devices = type("D", (), {})()
dm.devices.flomni_loop_settings = Dev()
rts = FlomniRealtimeSystem(device_manager=dm, token_file="/nonexistent") # token never read
assert rts.get("trigger_tolerance_nm") == 10.0 and rts.route == "device flomni_loop_settings"
rts.check(fzp_enable=0)
assert calls[-1] == ("post", "/api/settings/plan", {"changes": {"fzp_enable": 0}, "profile": None, "adjust": False})
with pytest.raises(RealtimeSystemError, match="via device flomni_loop_settings: web HMI not reachable"):
rts.set(move_beyond_um_x=0.5)
dm.devices.flomni_loop_settings.enabled = False # disabled: direct HTTP
with pytest.raises(RealtimeSystemError, match="No API token"):
rts.get()
def test_help(server, capsys):
"""help(name) / help("all"): explanations from the definition, marked as beamline-scientist only."""
url, tok, _ = server
rts = FlomniRealtimeSystem(url=url, token_file=tok)
rts.help("trigger_tolerance_nm")
out = capsys.readouterr().out
assert "local contact only" in out and "trigger_tolerance_nm" in out and "live: 10" in out
rts.help("all")
assert "Detector trigger" in capsys.readouterr().out
rts.help()
assert "BEAMLINE SCIENTISTS" in capsys.readouterr().out
with pytest.raises(RealtimeSystemError, match="unknown setting"):
rts.help("nosuch")
def test_trigger_set_soft(server):
url, tok, _ = server
rts = FlomniRealtimeSystem(url=url, token_file=tok)
rts.trigger_set_soft(True, exposure_ms=200)
assert _Handler.requests[-1][1]["changes"] == {"trigger_soft": 1, "soft_exposure_ms": 200}
rts.trigger_set_soft(False, exposure_ms=200)
assert _Handler.requests[-1][1]["changes"] == {"trigger_soft": 0}
@@ -0,0 +1,229 @@
"""Fixes from the flomni.py review 2026-10-24: tomo_scan_resume() after a completed scan, special angles kept
on resume, the alignment scan repeats an angle after a timeout, absolute np.isclose tolerances."""
import builtins
import types
import pytest
import csaxs_bec.bec_ipython_client.plugins.flomni.flomni as flomni_module
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni, FlomniError, _ProgressProxy
class _FakeClient:
def __init__(self):
self._vars = {}
def get_global_var(self, key):
return self._vars.get(key)
def set_global_var(self, key, value):
self._vars[key] = value
def _make_flomni():
obj = object.__new__(Flomni)
obj.client = _FakeClient()
obj._progress_proxy = _ProgressProxy(obj.client)
return obj
def test_resume_after_completed_scan_does_nothing(monkeypatch):
flomni = _make_flomni()
flomni.progress["tomo_start_time"] = "2026-10-24T10:00:00"
flomni.progress["tomo_completed"] = True
monkeypatch.setattr(Flomni, "tomo_type", 1, raising=False)
flomni.tomo_scan = lambda **kw: pytest.fail("tomo_scan must not be called")
flomni._confirm_resume_outside_queue = lambda: True
flomni.tomo_scan_resume()
def test_resume_of_interrupted_scan_still_resumes(monkeypatch):
flomni = _make_flomni()
flomni.progress["tomo_start_time"] = "2026-10-24T10:00:00"
flomni.progress["subtomo"] = 3
flomni.progress["angle"] = 45.0
monkeypatch.setattr(Flomni, "tomo_type", 1, raising=False)
calls = []
flomni.tomo_scan = lambda **kw: calls.append(kw)
flomni._confirm_resume_outside_queue = lambda: True
flomni.tomo_scan_resume()
assert calls == [{"subtomo_start": 3, "start_angle": 45.0}]
def test_progress_reset_clears_completed():
flomni = _make_flomni()
flomni.progress["tomo_completed"] = True
flomni.progress.reset()
assert flomni.progress.get("tomo_completed") is False
class _Timeout(flomni_module.AlarmBase):
def __init__(self):
Exception.__init__(self, "timeout")
self.alarm_type = "TimeoutError"
class _Queue:
def __init__(self):
self.next_scan_number = 100
self.resets = 0
def request_queue_reset(self):
self.resets += 1
def test_alignment_scan_repeats_an_angle_after_timeout(monkeypatch, tmp_path):
flomni = _make_flomni()
queue = _Queue()
fake_dev = types.SimpleNamespace(
rtx=types.SimpleNamespace(
controller=types.SimpleNamespace(feedback_is_running=lambda: True)
),
fsamroy=object(),
)
monkeypatch.setitem(builtins.__dict__, "bec", types.SimpleNamespace(queue=queue))
monkeypatch.setitem(builtins.__dict__, "dev", fake_dev)
monkeypatch.setattr(flomni_module, "umv", lambda *a: None, raising=False)
monkeypatch.setattr(flomni_module, "filters_out_of_beam", lambda dev: (True, []))
monkeypatch.setattr(flomni_module.time, "sleep", lambda s: None)
monkeypatch.setattr(Flomni, "sample_name", "test", raising=False)
monkeypatch.setenv("HOME", str(tmp_path))
flomni.get_alignment_offset = lambda angle: (1.0, 0.0, 0.0)
flomni._check_eye_out_and_optics_in = lambda: True
flomni.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
flomni._scilog_write = lambda *a: True
angles = []
def projection(angle, _internal=False):
angles.append(angle)
queue.next_scan_number += 1 # one scan per attempt (the timed-out one included)
if angle == 90 and angles.count(90) == 1:
raise _Timeout()
flomni.tomo_scan_projection = projection
flomni.tomo_alignment_scan()
assert angles == [0, 45, 90, 90, 135, 180]
assert queue.resets == 1
lines = (tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt").read_text().splitlines()
assert lines[0] == "100 101 103 104 105" # the accepted scan per angle, not 100..104
@pytest.mark.parametrize(
"readback, ok", [(-1.14, True), (-1.30, True), (-1.40, False), (-0.80, False)]
)
def test_heater_down_needs_fsamx_in_within_0_2_mm(monkeypatch, readback, ok):
flomni = _make_flomni()
fsamx = types.SimpleNamespace(
user_parameter={"in": -1.14}, readback=types.SimpleNamespace(get=lambda: readback)
)
fheater = types.SimpleNamespace(
_config={"deviceConfig": {"axis_Id": "H"}}, user_parameter={"in": 5}
)
monkeypatch.setitem(
builtins.__dict__, "dev", types.SimpleNamespace(fsamx=fsamx, fheater=fheater)
)
monkeypatch.setattr(flomni_module, "dev", builtins.__dict__["dev"], raising=False)
moved = []
monkeypatch.setattr(flomni_module, "umv", lambda *a: moved.append(a), raising=False)
flomni.ensure_osa_back = lambda: None
if ok:
flomni.move_fheater_down()
assert moved
else:
with pytest.raises(FlomniError):
flomni.move_fheater_down()
assert not moved
class _DevContainer(dict):
"""dict keyed by device name, like bec_lib's DeviceContainer (attribute access too)."""
def __getattr__(self, name):
return self[name]
def test_xray_eye_ctrl_c_returns_fsamx_in(monkeypatch):
"""The KeyboardInterrupt handler passed the device instead of its name: dev[device] raised."""
import csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin as optics_module
flomni = _make_flomni()
fsamx = types.SimpleNamespace(
user_parameter={"in": -1.14}, readback=types.SimpleNamespace(get=lambda: -1.3)
)
fake_dev = _DevContainer(fsamx=fsamx)
monkeypatch.setitem(builtins.__dict__, "dev", fake_dev)
monkeypatch.setattr(flomni_module, "dev", fake_dev, raising=False)
monkeypatch.setattr(optics_module, "dev", fake_dev, raising=False)
moved = []
monkeypatch.setattr(flomni_module, "umv", lambda *a: moved.append(a), raising=False)
flomni.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: True)
flomni.rt_feedback_disable = lambda: None
class _Align:
def __init__(self, *a):
pass
def align(self, keep_shutter_open):
raise KeyboardInterrupt
monkeypatch.setattr(flomni_module, "XrayEyeAlign", _Align)
with pytest.raises(KeyboardInterrupt):
flomni.start_x_ray_eye_alignment()
assert moved == [(fsamx, -1.14)]
def test_tray_position_message_says_0_to_20():
flomni = _make_flomni()
flomni.check_position_is_valid(20)
with pytest.raises(FlomniError, match="0-20"):
flomni.check_position_is_valid(21)
def test_get_val_reasks_angle_range_and_bad_numbers(monkeypatch, capsys):
answers = iter(["270", "abc", "360"])
monkeypatch.setattr(builtins, "input", lambda prompt="": next(answers))
assert Flomni._get_val("Angular range", 180, int, choices=(180, 360)) == 360
out = capsys.readouterr().out
assert "one of 180, 360" in out and "Not a valid int" in out
def test_feye_out_raises_when_direct_beam_still_visible(monkeypatch):
import csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin as optics_module
flomni = _make_flomni()
flomni.csaxs = None
flomni.foptics_in = lambda: None
flomni.flomnigui_show_xeyealign = lambda: None
flomni.xrayeye_update_frame = lambda: None
flomni.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
fake_dev = types.SimpleNamespace(fsh=types.SimpleNamespace(fshclose=lambda: None))
monkeypatch.setattr(optics_module, "dev", fake_dev, raising=False)
monkeypatch.setitem(builtins.__dict__, "dev", fake_dev)
with pytest.raises(optics_module.FlomniOpticsError, match="direct beam"):
flomni.feye_out()
class _SlowStream:
"""Non-blocking stdin stand-in: returns the queued chunks one per read, None in between."""
def __init__(self, chunks):
self.chunks = list(chunks)
def read(self, n):
return self.chunks.pop(0) if self.chunks else None
def test_read_escape_suffix_waits_for_split_sequence():
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import (
read_escape_suffix,
)
assert read_escape_suffix(_SlowStream(["[", None, "A"])) == "[A"
assert read_escape_suffix(_SlowStream([])) is None # ESC alone
@@ -32,6 +32,7 @@ def _make_flomni(monkeypatch):
obj.tomo_angle_range = 180
obj.special_angles = []
obj.special_angle_repeats = 3
obj.special_angle_tolerance = 0.5
obj._current_special_angles = []
obj._print_progress = lambda: None
obj._format_duration = lambda seconds: "0s"
@@ -86,3 +87,34 @@ def test_special_angle_repeats_survive_a_retry(monkeypatch):
assert len(calls) == 1 + 3, "the retry must still repeat the special angle"
assert flomni._current_special_angles == []
def test_special_angle_tolerance_is_used(monkeypatch):
"""special_angle_tolerance was ignored (hard-coded 0.5) before 2026-10-24."""
flomni = _make_flomni(monkeypatch)
flomni.special_angles = [90.0]
flomni._current_special_angles = [90.0]
flomni.special_angle_tolerance = 2.0
calls = []
flomni._at_each_angle = lambda angle: calls.append(angle)
flomni._tomo_scan_at_angle(91.5, 1)
assert calls == [91.5] * 3
assert flomni._current_special_angles == []
def test_special_angles_match_in_any_order(monkeypatch):
"""An angle not reached first (e.g. a reverse sub-tomogram) no longer blocks the later ones."""
flomni = _make_flomni(monkeypatch)
flomni.special_angles = [30.0, 120.0]
flomni._current_special_angles = [30.0, 120.0]
calls = []
flomni._at_each_angle = lambda angle: calls.append(angle)
flomni._tomo_scan_at_angle(120.0, 1)
flomni._tomo_scan_at_angle(120.0, 1) # used once only
flomni._tomo_scan_at_angle(30.0, 1)
assert calls == [120.0] * 3 + [120.0] + [30.0] * 3
assert flomni._current_special_angles == []
@@ -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
@@ -678,7 +681,11 @@ def test_new_scan_resets_progress_before_eye_optics_gate_declines(monkeypatch, t
"must not acquire -- the gate declined"
)
flomni.tomo_scan() # returns early, no exception
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import FlomniError
# raises since 2026-10-24 (a plain return let the tomo queue mark the job "done")
with pytest.raises(FlomniError, match="not in measurement condition"):
flomni.tomo_scan()
assert flomni.progress["projection"] == 0
assert flomni.progress["total_projections"] == 1
@@ -762,3 +769,24 @@ def test_flomni_gui_progress_survives_zero_total(monkeypatch):
assert ring_values == [0.0, 50.0]
assert "Angles from file" in labels[0]
@pytest.mark.parametrize("zero_deg_reference", [False, True])
def test_type1_new_scan_takes_two_zero_deg_projections_at_the_start(monkeypatch, zero_deg_reference):
"""The fixed 0-deg shot + sub-tomogram 1's first projection (0 deg); the damage-check reference
before sub-tomogram 1 would have been a third one in a row (removed 2026-10-24)."""
flomni = make_flomni_for_golden_resume(monkeypatch, tomo_type=1)
flomni.tomo_angle_range = 180
flomni.tomo_angle_stepsize = 45.0
flomni.tomo_type1_always_forward = False
flomni.zero_deg_reference_at_each_subtomo = zero_deg_reference
calls = []
flomni._tomo_scan_at_angle = lambda angle, subtomo, **kw: calls.append((float(angle), subtomo))
flomni.tomo_scan()
assert calls[:2] == [(0.0, 1), (0.0, 1)]
assert calls[2][0] != 0.0
# sub-tomogram 3 (forward, starts near 0) still gets its reference shot when enabled
sub3 = [c for c in calls if c[1] == 3]
assert (sub3[0][0] == 0.0) == zero_deg_reference
@@ -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 == []
@@ -0,0 +1,298 @@
"""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
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()
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.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 _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_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()
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()}
fig201 = mock.MagicMock()
fig202 = mock.MagicMock()
console = mock.MagicMock()
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()
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
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()
@@ -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"]
@@ -0,0 +1,79 @@
"""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
@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
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}
@@ -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()
@@ -0,0 +1,467 @@
"""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}"
# 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
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 _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):
"""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} once the 0/180 wraparound gap is included: 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
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_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."""
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",
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)
# 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).
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()
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",
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)
# canned answers for: ctime, step, fovx, fovy, stitch_x, stitch_y,
# 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()
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",
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)
# 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)
# --- 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",
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
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",
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)
omny.tomo_parameters()
out = capsys.readouterr().out
assert "WARNING: below the minimum" not in out
assert "Estimated Fermat scan points per projection:" in out
@@ -0,0 +1,279 @@
"""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 types
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,
_irrelevant_params_for_type,
_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_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 True
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
# _compute_fermat_positions_omny() pins seed=0 for its own reproducibility --
# match it here rather than drawing an independent random offset.
expected = OmnyFermatScan.get_omny_fermat_spiral_pos(
-1.0, 1.0, -1.0, 1.0, step=2.0, spiral_type=0, seed=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)
# --- _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) -- the sub-tomogram types now
come from the omny profile's "tomo_types"."""
profile = SETUP_PROFILES["omny"]
for tomo_type in (1, 4, 5):
assert "zero_deg_reference_at_each_subtomo" not in _irrelevant_params_for_type(
tomo_type, profile
)
for tomo_type in (2, 3):
assert "zero_deg_reference_at_each_subtomo" in _irrelevant_params_for_type(
tomo_type, profile
)
# --- _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"
# --- 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_names = types.SimpleNamespace(STAGE=_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_names.STAGE
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]
@@ -0,0 +1,345 @@
"""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",
set_sample_measured=lambda *a, **k: None,
)
)
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, 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 "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
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", (), {"from_queue": True})]
job = omny._tomo_queue_proxy.as_list()[0]
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"}
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
@@ -0,0 +1,654 @@
"""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 datetime
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",
set_sample_measured=lambda *a, **k: None,
)
)
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.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.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)
# 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
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_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
_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_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_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_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."""
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
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
# --- 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_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
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
# 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
# --- _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.client = FakeGlobalVarClient()
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",
set_sample_measured=lambda *a, **k: None,
),
)
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"
# --- 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, <x>, dev.rty, <y>, dev.rtz, <z>)
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)]
@@ -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,
@@ -77,3 +78,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]
@@ -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
@@ -0,0 +1,108 @@
"""Tests for the baseline device flomni_loop_settings (devices/omny/flomni_loop_settings.py): the loop settings of
the flOMNI web HMI stored with every scan. A small local HTTP server stands in for the web HMI (/api/settings)."""
import json
import math
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from csaxs_bec.devices.omny import flomni_loop_settings as fls
from csaxs_bec.devices.omny.flomni_loop_settings import SETTINGS, FlomniLoopSettings
TOKEN = "device-token-0123456789abcdef"
SNAP = {
"instrument": "flOMNI", "definition": "/home/control/OMNY/flOMNI/FeedbackLoop/loopsettings/flomni.json",
"definition_sha1": "a" * 40, "scan": {"mode": 0, "active": False, "text": ""},
"settings": [
{"name": "fzp_enable", "value": 1, "unsaved": False, "consistent": True},
{"name": "fzp_range_V_x", "value": 1.5, "unsaved": True, "consistent": True},
{"name": "trigger_tolerance_nm", "value": 10.0, "unsaved": False, "consistent": False},
{"name": "new_setting_later", "value": 3.0, "unsaved": False, "consistent": True},
],
"rules": [{"name": "r1", "severity": "error", "ok": False}, {"name": "r2", "severity": "warning", "ok": False}],
}
class _Handler(BaseHTTPRequestHandler):
hits = 0
def log_message(self, *args):
pass
def do_GET(self):
_Handler.hits += 1
code, body = (200, SNAP) if self.headers.get("Authorization") == "Bearer " + TOKEN else (401, {"detail": "no"})
data = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
@pytest.fixture
def url():
_Handler.hits = 0
srv = HTTPServer(("127.0.0.1", 0), _Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{srv.server_address[1]}"
srv.shutdown()
def test_read_stores_settings(url):
dev = FlomniLoopSettings(name="flomni_loop_settings", url=url, token=TOKEN)
r = dev.read()
assert r["flomni_loop_settings_available"]["value"] == 1
assert r["flomni_loop_settings_fzp_enable"]["value"] == 1.0
assert r["flomni_loop_settings_fzp_range_V_x"]["value"] == 1.5
assert math.isnan(r["flomni_loop_settings_move_beyond_um_x"]["value"]) # not in the answer
assert r["flomni_loop_settings_unsaved"]["value"] == 1
assert r["flomni_loop_settings_inconsistent"]["value"] == 1
assert r["flomni_loop_settings_rules_violated"]["value"] == 1 # error rules only
assert r["flomni_loop_settings_definition"]["value"].endswith("sha1 " + "a" * 40)
assert "new_setting_later" in r["flomni_loop_settings_message"]["value"]
snap = json.loads(r["flomni_loop_settings_snapshot_json"]["value"])
assert snap["settings"]["fzp_enable"]["value"] == 1 and "profiles" not in snap
assert snap["settings"]["new_setting_later"]["value"] == 3.0 # later settings: in the JSON
assert [r["name"] for r in snap["rules_violated"]] == ["r1", "r2"]
assert set(SETTINGS) <= {k.replace("flomni_loop_settings_", "") for k in r}
def test_unavailable_does_not_raise_and_backs_off(url, monkeypatch):
dev = FlomniLoopSettings(name="ls", url=url, token="wrong-token-xxxxxxxxxxxx")
r = dev.read()
assert r["ls_available"]["value"] == 0 and "401" in r["ls_message"]["value"]
assert math.isnan(r["ls_fzp_enable"]["value"]) and r["ls_unsaved"]["value"] == -1
hits = _Handler.hits
dev.read() # within the back-off: no new request
assert _Handler.hits == hits
monkeypatch.setattr(fls, "RETRY_AFTER_FAILURE_S", 0.0)
dev.read()
assert _Handler.hits == hits + 1
def test_unreachable():
dev = FlomniLoopSettings(name="ls", url="http://127.0.0.1:9", token=TOKEN, timeout=0.5)
r = dev.read()
assert r["ls_available"]["value"] == 0 and "not reachable" in r["ls_message"]["value"]
def test_token_file_and_snapshot(url, tmp_path):
tok = tmp_path / "token"
tok.write_text(TOKEN + "\n")
dev = FlomniLoopSettings(name="ls", url=url, token_file=str(tok))
assert dev.snapshot()["fzp_range_V_x"] == 1.5
def test_api_methods_for_realtime_system(url):
"""api_get / api_post: the RPC used by flomni.rt_settings (token only in the device config)."""
dev = FlomniLoopSettings(name="ls", url=url, token=TOKEN)
assert dev.api_get("/api/settings")["instrument"] == "flOMNI"
with pytest.raises(Exception, match="not allowed"):
dev.api_get("/api/params") # only the loop-settings endpoints
with pytest.raises(Exception, match="not allowed"):
dev.api_post("/api/loop/stop", {})
assert "api_get" in FlomniLoopSettings.USER_ACCESS and "set" not in FlomniLoopSettings.USER_ACCESS
@@ -0,0 +1,108 @@
"""OMNYSampleStorage on the OMNY soft IOC records (OMNY-SAMPLE:<slot>:USED/NAME/OWNER/STATUS/MEAS_DATE/MEAS_ACCOUNT)."""
import pytest
from csaxs_bec.devices.omny.omny_sample_storage import (
SAMPLE_SLOTS,
OMNYSampleStorage,
OMNYSampleStorageError,
slot_name,
)
from csaxs_bec.devices.sim.sim_omny import SimOMNYSampleStorage
@pytest.fixture
def storage():
dev = SimOMNYSampleStorage(name="omny_samples", sim_samples={})
try:
yield dev
finally:
dev.destroy()
@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("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):
slot_name(container, slot_nr)
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.sample_meas_account.defn["A3"][1] == "OMNY-SAMPLE:A3:MEAS_ACCOUNT"
assert OMNYSampleStorage.parking_names.defn["parking3"][1] == "OMNY-SAMPLE:PARK3:NAME"
assert len(SAMPLE_SLOTS) == 18 + 10 + 2
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": "",
"meas_account": ""}
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", account="e20001")
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",
"meas_account": "e20001"}
def test_set_sample_measured_stores_the_account_not_the_owner(storage):
# owner (free text) and measurement account are separate fields (2026-10-27)
storage.set_sample_in_samplestage("pin3", "Group XY")
storage.set_sample_measured("STAGE", 0, "started", "2026-10", account="e22222")
assert storage.get_sample_owner_in_samplestage() == "Group XY"
assert storage.get_sample_status_in_samplestage() == "started"
assert storage.get_sample_meas_account("STAGE") == "e22222"
# the account of the latest status change is stored
storage.set_sample_measured("STAGE", 0, "completed", "2026-10", account="e33333")
assert storage.get_sample_meas_account("STAGE") == "e33333"
# no account given: the stored one is kept
storage.set_sample_measured("STAGE", 0, "completed", "2026-11")
assert storage.get_sample_meas_account("STAGE") == "e33333"
# loading a new sample resets status, date and account
storage.set_sample_in_samplestage("pin3b", "Group XY")
assert storage.get_sample_record("STAGE")["meas_account"] == ""
def test_screened_status(storage):
storage.set_sample_slot("C", 4, "pin5", "someone")
storage.set_sample_measured("C", 4, "screened", "2026-10", account="e44444")
assert storage.get_sample_status("C", 4) == "screened"
assert storage.get_sample_meas_account("C", 4) == "e44444"
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)
+32 -50
View File
@@ -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()
+29
View File
@@ -4,6 +4,7 @@ import pytest
from ophyd_devices.tests.utils import SocketMock
from csaxs_bec.devices.omny.rt import RtFlomniController, RtFlomniMotor
from csaxs_bec.devices.omny.rt.rt_flomni_ophyd import RtFlomniFlyer
from csaxs_bec.devices.omny.rt.rt_ophyd import RtError
@@ -120,3 +121,31 @@ def test_laser_tracker_signalstrength_rereads_after_too_low(rt_flomni, first, se
):
assert rt_flomni.laser_tracker_check_signalstrength(verbose=False) == expected
assert read_ssi.call_count == 2
def test_flyer_reports_average_stdev_to_the_client(rt_flomni):
flyer = RtFlomniFlyer(name="rt_positions", socket_cls=SocketMock, device_manager=rt_flomni.device_manager)
flyer.controller = rt_flomni
for row in (["0", "0", "1", "1", "0.002", "2", "2", "0.001", "0", "0"],
["1", "0", "1", "1", "0.004", "2", "2", "0.003", "0", "0"]):
flyer._get_signals_from_table(row)
flyer.configure({"exp_time": 0.1, "frames_per_trigger": 2}) # kickoff parameters of the scan
flyer._t_kickoff, flyer._t_scan_end = 100.0, 101.0
flyer._report_statistics(2)
send = rt_flomni.device_manager.connector.send_client_info
msg = send.call_args.args[0]
assert "x 3.0 nm, y 2.0 nm" in msg and "2 points" in msg
assert "scan time 1.0 s (500 ms/point)" in msg and "exposure 0.4 s (40 %" in msg and "overhead 0.6 s (300 ms/point" in msg
assert send.call_args.kwargs["show_asap"] is True
send.reset_mock()
flyer._report_statistics(0) # no points: nothing sent, no ZeroDivisionError
send.assert_not_called()
def test_format_scan_timing():
from csaxs_bec.devices.omny.rt.rt_ophyd import format_scan_timing
assert format_scan_timing(0, 0, 1) == "" and format_scan_timing(5, None, 1) == ""
assert format_scan_timing(10, 0, 2.0) == "; scan time 2.0 s (200 ms/point)" # no exp_time: no split
txt = format_scan_timing(10, 0, 2.0, 0.1, 1)
assert "exposure 1.0 s (50 %" in txt and "overhead 1.0 s (100 ms/point" in txt
@@ -0,0 +1,26 @@
from unittest import mock
from ophyd_devices.tests.utils import SocketMock
from csaxs_bec.devices.omny.rt.rt_lamni_ophyd import RtLamniController, RtLamniFlyer
def test_lamni_flyer_reports_average_stdev_to_the_client():
RtLamniController._reset_controller()
flyer = RtLamniFlyer(name="rt_positions", socket_cls=SocketMock, device_manager=mock.MagicMock())
flyer.controller = mock.MagicMock()
for i, (sx, sy) in enumerate(((0.002, 0.001), (0.004, 0.003))):
row = ["0"] * 21
row[0], row[5], row[8], row[19] = str(i), str(sx), str(sy), "0.5"
flyer._get_signals_from_table(row)
flyer.configure({"exp_time": 0.05}) # frames_per_trigger default 1
flyer._t_kickoff, flyer._t_scan_end = 10.0, 10.5
flyer._report_statistics(2)
send = flyer.controller.device_manager.connector.send_client_info
msg = send.call_args.args[0]
assert "x 3.0 nm, y 2.0 nm" in msg and "2 points" in msg and "average angle 0.5000" in msg
assert "scan time 0.5 s (250 ms/point)" in msg and "exposure 0.1 s (20 %" in msg
send.reset_mock()
flyer._report_statistics(0) # no points: nothing sent, no ZeroDivisionError
send.assert_not_called()
RtLamniController._reset_controller()
+90
View File
@@ -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()
@@ -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()
@@ -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()
@@ -0,0 +1,147 @@
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_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:
_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()
@@ -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()
@@ -0,0 +1,38 @@
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"
def test_untouched_slots_read_empty(storage):
rec = storage.get_sample_record("O", 100)
assert rec == {"used": False, "name": "-", "owner": "", "status": "none", "meas_date": "", "meas_account": ""}
assert storage.get_sample_name_in_samplestage() == "sim_stage_sample"
assert not storage.is_sample_in_gripper()
@@ -1,6 +1,8 @@
from unittest import mock
import numpy as np
import pytest
from bec_server.scan_server.scans import ScanAbortion
from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan
@@ -152,3 +154,72 @@ def test_randomize_offset_false_gives_unshifted_deterministic_lattice():
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2.0)
r = 0.57
np.testing.assert_allclose(a[0, :2], [r * np.sin(phi), r * np.cos(phi)])
# ── trigger source of the flOMNI loop (soft trigger, 2026-10-26) ──────────────
def _trigger_stub(devices, apply_result=None, apply_exc=None, exp_time=0.1, frames_per_trigger=2):
"""A stand-in for a FlomniFermatScan instance with a device list {name: deviceClass} and the loop-settings
device answering /api/settings/apply."""
scan = mock.MagicMock()
scan.device_manager.devices = {
name: mock.MagicMock(_config={"deviceClass": cls}) for name, cls in devices.items()
}
scan.exp_time = exp_time
scan.frames_per_trigger = frames_per_trigger
if apply_exc is not None:
scan.dev.flomni_loop_settings.api_post.side_effect = apply_exc
else:
scan.dev.flomni_loop_settings.api_post.return_value = (
apply_result if apply_result is not None else {"ok": True, "errors": []}
)
scan.at_beamline = lambda: FlomniFermatScan.at_beamline(scan)
return scan
SIM_DDG1 = "csaxs_bec.devices.sim.simulated_beamline_devices.SimulatedDDG1"
REAL_DDG1 = "csaxs_bec.devices.epics.delay_generator_csaxs.DDG1"
MOTOR = "ophyd_devices.devices.psi_motor.EpicsUserMotorVME"
def test_at_beamline_detection():
assert FlomniFermatScan.at_beamline(_trigger_stub({"ddg1": SIM_DDG1}))[0] is False
assert FlomniFermatScan.at_beamline(_trigger_stub({}))[0] is False
assert FlomniFermatScan.at_beamline(_trigger_stub({"ddg1": REAL_DDG1}))[0] is True
# only ddg1 decides: other devices (e.g. exposure box motors) do not count
assert FlomniFermatScan.at_beamline(_trigger_stub({"ddg1": SIM_DDG1, "ebtrx": MOTOR}))[0] is False
# a failing check counts as beamline (hardware trigger is the fallback)
scan = _trigger_stub({})
scan.device_manager.devices = None
assert FlomniFermatScan.at_beamline(scan)[0] is True
def test_trigger_source_soft_away_from_beamline():
scan = _trigger_stub({"ddg1": SIM_DDG1}, exp_time=0.1, frames_per_trigger=2)
FlomniFermatScan.set_trigger_source(scan)
path, body = scan.dev.flomni_loop_settings.api_post.call_args[0]
assert path == "/api/settings/apply"
assert body["changes"]["trigger_soft"] == 1
assert abs(body["changes"]["soft_exposure_ms"] - 200.0) < 1e-9
def test_trigger_source_hardware_at_beamline():
scan = _trigger_stub({"ddg1": REAL_DDG1, "ebtry": MOTOR})
FlomniFermatScan.set_trigger_source(scan)
body = scan.dev.flomni_loop_settings.api_post.call_args[0][1]
assert body["changes"] == {"trigger_soft": 0}
def test_trigger_source_not_confirmed_at_beamline_aborts():
scan = _trigger_stub({"ddg1": REAL_DDG1}, apply_exc=RuntimeError("web HMI not reachable"))
with pytest.raises(ScanAbortion, match="hardware trigger must be confirmed"):
FlomniFermatScan.set_trigger_source(scan)
scan = _trigger_stub({"ddg1": REAL_DDG1}, apply_result={"ok": False, "errors": ["a scan is running"]})
with pytest.raises(ScanAbortion, match="a scan is running"):
FlomniFermatScan.set_trigger_source(scan)
def test_trigger_source_failure_away_only_warns():
scan = _trigger_stub({"ddg1": SIM_DDG1}, apply_exc=RuntimeError("web HMI not reachable"))
FlomniFermatScan.set_trigger_source(scan)
scan.device_manager.connector.raise_alarm.assert_called_once()
+90 -8
View File
@@ -1,6 +1,7 @@
from unittest import mock
import numpy as np
import pytest
from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan
@@ -23,7 +24,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
@@ -46,10 +56,84 @@ def test_omny_rotation_moves_when_setpoint_matches_but_readback_far_off():
scan.actions.set.assert_called_once()
class _PosStub:
cenx = 0.0
ceny = 0.0
zshift = 0.0
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)
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."""
# same seed: both calls get the same random sub-step offset (see seed in the function)
baseline = OmnyFermatScan.get_omny_fermat_spiral_pos(-5, 5, -5, 5, step=1, spiral_type=0, seed=1)
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, seed=1
)
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)
def test_omny_fermat_spiral_reaches_elongated_fov_corners():
@@ -58,8 +142,6 @@ def test_omny_fermat_spiral_reaches_elongated_fov_corners():
# iterations needed and left the loop-generated points well short of the
# long axis's outer region (only the two explicit corner points reached it).
fovx, fovy = 190.0, 20.0 # stay under omny's fovx<200 / fovy<100 argument bounds
pos = OmnyFermatScan.get_omny_fermat_spiral_pos(
_PosStub(), -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1
)
pos = OmnyFermatScan.get_omny_fermat_spiral_pos(-fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1)
loop_points = pos[:-2] # exclude the two corner points appended after the loop
assert np.max(np.abs(loop_points[:, 0])) > 0.95 * (fovx / 2)